ai-chatbot/components/home/lovart-home.tsx
Your Name 9a3b28fa8e
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
新首页侧边工具栏改为垂直居中定位
2026-02-15 00:37:32 +08:00

2045 lines
80 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, signOut } from "next-auth/react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import {
ArrowRight,
ArrowUp,
Bell,
Briefcase,
ChevronDown,
Clapperboard,
Folder,
Heart,
Home,
ImageIcon,
Info,
Loader2,
LogOut,
Mail,
Monitor,
Palette,
Plus,
Send,
Settings,
Sparkles,
Star,
Upload,
User,
Users,
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;
avatarUrl?: string | null;
providerType: string;
supportsImageGen?: boolean;
};
type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number;
};
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 PointsPackage = {
id: string;
name: string;
points: number;
price: number;
currency: string;
isEnabled: boolean;
};
type AccountCenterResponse = {
profile: {
name: string;
email: string;
currentPlanName: string;
currentPlanId: string;
availablePoints: number;
monthlyCredits: number;
dailyFreeCredits: number;
};
usageRecords: Array<{
id: string;
detail: string;
status: "consumed" | "earned";
amount: number;
createdAt: string;
}>;
billingRecords: Array<{
id: string;
date: string;
category: string;
amount: number;
status: "completed" | "pending" | "failed";
invoice: string;
}>;
};
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;
const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
if (!trimmed) return "";
const sanitizedPrefix = fixedPrefix.trim();
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
return trimmed.slice(sanitizedPrefix.length).trimStart();
}
const legacyPrefixRemoved = trimmed
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
.replace(/^让\s*lovart\s*/i, "")
.trimStart();
if (legacyPrefixRemoved !== trimmed) {
return legacyPrefixRemoved;
}
return trimmed;
}
function getUserInitials(user: HomeUser | null) {
const source = (user?.name || user?.email || "mu").trim();
if (!source) return "mu";
return source.slice(0, 2).toLowerCase();
}
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}`;
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { hour12: false });
}
function AccountMenuItem({
icon: Icon,
label,
onClick,
}: {
icon: typeof User;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5"
>
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70">
<Icon className="h-[16px] w-[16px]" />
</span>
<span className="flex-1">{label}</span>
</button>
);
}
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 [accountCenterOpen, setAccountCenterOpen] = useState(false);
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
"account"
);
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
const [creatingPaymentPackageId, setCreatingPaymentPackageId] = useState<string | null>(null);
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: accountCenterData } = useSWR<AccountCenterResponse>(
isAuthenticated && accountCenterOpen ? "/api/user/account-center" : null,
fetcher
);
const { data: modelsData } = useSWR<{
models: ModelOption[];
}>(isAuthenticated ? "/api/models" : null, fetcher);
const { data: navLinksData } = useSWR<{ links: NavLink[] }>(
"/api/nav-links",
fetcher
);
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPrefix?: string | null;
lovartComposerPreviewTexts?: string[];
toolbox1Name?: string | null;
toolbox1Enabled?: boolean;
toolbox2Name?: string | null;
toolbox2Enabled?: boolean;
pointsPackages?: PointsPackage[];
dulupayConfig?: {
enabled?: boolean;
defaultType?: 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: "Agent模式",
providerType: "auto",
supportsImageGen: true,
},
...imageModels,
],
[imageModels]
);
const currentComposerModel = useMemo(
() => allComposerModels.find((model) => model.id === selectedModelId),
[allComposerModels, selectedModelId]
);
const discoveryTabs = useMemo(() => {
const configured = (surpriseCategories || [])
.map((item) => String(item || "").trim())
.filter(Boolean)
// legacy typo compatibility: treat "惊喜" as "精选"
.map((item) => (item === "惊喜" ? "精选" : item));
const uniqueConfigured = Array.from(new Set(configured));
const hasRealConfig =
uniqueConfigured.length > 0 &&
!(uniqueConfigured.length === 1 && uniqueConfigured[0] === "全部");
const fallback = ["全部", "精选", ...DISCOVERY_DEFAULT_TABS];
const base = hasRealConfig ? uniqueConfigured : fallback;
const withoutAll = base.filter((item) => item !== "全部");
const merged = ["全部", ...withoutAll];
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 [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
}, [siteSettingsData?.lovartComposerPreviewTexts]);
const composerFixedPrefix = useMemo(() => {
return (
String(siteSettingsData?.lovartComposerPrefix || "").trim() ||
COMPOSER_PREFIX_DEFAULT
);
}, [siteSettingsData?.lovartComposerPrefix]);
const composerPreviewTails = useMemo(() => {
return composerPreviewTexts
.map((text) => normalizeComposerPreviewTail(text, composerFixedPrefix))
.filter(Boolean);
}, [composerFixedPrefix, composerPreviewTexts]);
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 filteredUsageRecords = useMemo(() => {
const records = accountCenterData?.usageRecords || [];
if (usageFilter === "all") return records;
return records.filter((item) => item.status === usageFilter);
}, [accountCenterData?.usageRecords, usageFilter]);
const enabledPointsPackages = useMemo(() => {
return (siteSettingsData?.pointsPackages || []).filter(
(item) => item?.isEnabled !== false && item.price > 0 && item.points > 0
);
}, [siteSettingsData?.pointsPackages]);
const toolbox1Links = useMemo(
() =>
(navLinksData?.links || [])
.filter((link) => (link.toolboxGroup || 1) === 1 && link.isEnabled !== false)
.sort((a, b) => a.order - b.order),
[navLinksData?.links]
);
const toolbox2Links = useMemo(
() =>
(navLinksData?.links || [])
.filter((link) => link.toolboxGroup === 2 && link.isEnabled !== false)
.sort((a, b) => a.order - b.order),
[navLinksData?.links]
);
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 (composerPreviewTails.length === 0) {
setTypedPreviewText("");
return () => {
if (timer) clearTimeout(timer);
};
}
let textIndex = 0;
let charIndex = 0;
const typeForward = () => {
if (disposed) return;
const currentText = composerPreviewTails[textIndex] || "";
if (charIndex < currentText.length) {
charIndex += 1;
setTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeForward, 55);
return;
}
if (composerPreviewTails.length === 1) {
setTypedPreviewText(currentText);
return;
}
timer = setTimeout(typeBackward, 1200);
};
const typeBackward = () => {
if (disposed) return;
const currentText = composerPreviewTails[textIndex] || "";
if (charIndex > 0) {
charIndex -= 1;
setTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeBackward, 35);
return;
}
textIndex = (textIndex + 1) % composerPreviewTails.length;
timer = setTimeout(typeForward, 260);
};
setTypedPreviewText("");
timer = setTimeout(typeForward, 260);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
};
}, [composerPreviewTails]);
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().toLowerCase();
if (!email || !loginPassword) {
toast.error("请输入邮箱和密码");
return;
}
setIsLoggingIn(true);
try {
const result = await signIn("credentials", {
email,
password: loginPassword,
redirect: false,
});
if (!result || result.error || result.ok === false) {
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 handleCreateDulupayOrder = async (packageId: string) => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
if (creatingPaymentPackageId) return;
setCreatingPaymentPackageId(packageId);
try {
const response = await fetch("/api/payment/dulupay/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
packageId,
payType: siteSettingsData?.dulupayConfig?.defaultType || "alipay",
}),
});
const result = await response.json();
if (!response.ok || !result?.paymentUrl) {
throw new Error(result?.error || "创建支付订单失败");
}
window.location.href = result.paymentUrl;
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建支付订单失败");
} finally {
setCreatingPaymentPackageId(null);
}
};
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 openNavLink = (url: string) => {
const value = String(url || "").trim();
if (!value) return;
if (/^https?:\/\//i.test(value)) {
window.open(value, "_blank", "noopener,noreferrer");
return;
}
if (value.startsWith("/")) {
router.push(value);
return;
}
window.open(`https://${value}`, "_blank", "noopener,noreferrer");
};
const composerPlaceholder = `${composerFixedPrefix}${typedPreviewText}${
previewCursorVisible ? " |" : ""
}`;
const userInitials = useMemo(() => getUserInitials(user), [user]);
const userDisplayName = useMemo(() => {
if (!user) return "";
return (user.name || user.email || "用户").toString();
}, [user]);
const accountProfile = useMemo(() => {
return {
name: accountCenterData?.profile?.name || userDisplayName || "用户",
email: accountCenterData?.profile?.email || user?.email || "",
planName: accountCenterData?.profile?.currentPlanName || currentPlan?.name || "Free",
availablePoints:
accountCenterData?.profile?.availablePoints ?? membershipData?.summary?.currentCredits ?? 0,
monthlyCredits:
accountCenterData?.profile?.monthlyCredits ?? currentPlan?.monthlyCredits ?? 0,
dailyFreeCredits:
accountCenterData?.profile?.dailyFreeCredits ?? membershipData?.summary?.dailyFreeCredits ?? 0,
};
}, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]);
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 hover:text-white"
>
<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 text-white">
<Zap className="h-3 w-3 text-amber-300" />
{membershipData?.summary?.currentCredits ?? 0}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent
align="end"
sideOffset={10}
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl"
>
<div className="p-4">
<div className="mb-4 flex items-start justify-between">
<div>
<div className="text-[22px] font-semibold leading-none text-black">
{currentPlan?.name || "Free"}
</div>
</div>
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px]">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80">
<Sparkles className="h-3.5 w-3.5" />
积分
</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-3.5 w-3.5" />
每日免费积分
</span>
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
</div>
</div>
<button
type="button"
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
{isAuthenticated ? (
<HoverCard openDelay={80} closeDelay={120}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
aria-label="Account menu"
>
{userInitials}
</button>
</HoverCardTrigger>
<HoverCardContent
align="end"
sideOffset={12}
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl"
>
<div className="p-3.5">
<div className="rounded-3xl border border-black/10 bg-white p-3.5">
<div className="flex justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-black">
{userDisplayName}
</div>
<div className="truncate text-[12px] text-black/45">
{user?.email || ""}
</div>
</div>
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="mt-4 border-t border-black/10 pt-3">
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5"
onClick={() => setUpgradeDialogOpen(true)}
>
<span>积分</span>
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60">
{membershipData?.summary?.currentCredits ?? 0}
<ArrowRight className="h-4 w-4" />
</span>
</button>
</div>
</div>
<div className="mt-3 space-y-1">
<AccountMenuItem
icon={Users}
label="创建团队"
onClick={() => toast.info("团队功能后续开放")}
/>
<AccountMenuItem
icon={User}
label="账户管理"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
/>
<AccountMenuItem
icon={Info}
label="使用指南"
onClick={() => toast.info("使用指南后续开放")}
/>
<AccountMenuItem
icon={Mail}
label="联系我们"
onClick={() => toast.info("联系我们后续开放")}
/>
<AccountMenuItem
icon={LogOut}
label="退出登录"
onClick={() => {
void signOut({ redirect: true, callbackUrl: "/" });
}}
/>
</div>
</div>
</HoverCardContent>
</HoverCard>
) : (
<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-1/2 z-30 hidden -translate-y-1/2 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>
{siteSettingsData?.toolbox1Enabled !== false && toolbox1Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
title={siteSettingsData?.toolbox1Name || "工具箱"}
>
<Briefcase className="h-[18px] w-[18px]" />
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox1Name || "工具箱"}
</div>
<div className="space-y-1">
{toolbox1Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{siteSettingsData?.toolbox2Enabled === true && toolbox2Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
title={siteSettingsData?.toolbox2Name || "工具箱2"}
>
<Briefcase className="h-[18px] w-[18px]" />
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox2Name || "工具箱2"}
</div>
<div className="space-y-1">
{toolbox2Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{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-[130px] rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600">
<span className="flex min-w-0 items-center gap-1.5">
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
{currentComposerModel?.avatarUrl ? (
<img
src={currentComposerModel.avatarUrl}
alt={currentComposerModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
{(currentComposerModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="max-w-[95px] truncate">
{currentComposerModel?.displayName || "Agent模式"}
</span>
</span>
</SelectTrigger>
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{[{ id: "auto", displayName: "Agent模式", avatarUrl: null }, ...imageModels].map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
{model.avatarUrl ? (
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
</span>
)}
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
</span>
{(selectedModelId || "auto") === model.id ? (
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</SelectItem>
))}
</div>
</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="cursor-pointer 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 cursor-pointer 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 will-change-transform group-hover:scale-[1.045]"
/>
) : (
<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>
{featuredItems.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35">
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
</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 cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5"
>
<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 will-change-transform group-hover:scale-[1.04]"
/>
</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>
))}
</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/85 p-4 backdrop-blur-[1px]"
onClick={() => setLoginDialogOpen(false)}
>
<div
className="w-full max-w-[460px] rounded-[24px] bg-white p-6 shadow-2xl sm:p-7"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
{siteLogo ? (
<img
src={siteLogo}
alt={siteName}
className="h-7 w-7 rounded-full object-cover"
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white">
<Sparkles className="h-3.5 w-3.5" />
</div>
)}
<span className="text-[18px] 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-4 w-4" />
</button>
</div>
<div className="mb-5 text-center">
<h3 className="text-[18px] font-semibold text-black">欢迎来到 {siteName}</h3>
<p className="mt-2 text-[13px] text-black/45">登录或注册以继续</p>
</div>
{hasThirdPartyLoginOptions ? (
<div className="space-y-2.5">
{canUseGoogleLogin ? (
<button
type="button"
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[15px] 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-12 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[15px] 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-12 rounded-xl border-black/20 px-3.5 text-[14px]"
/>
<Input
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
placeholder="密码"
type="password"
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px]"
/>
<Button
type="submit"
className="mt-1 h-12 w-full rounded-xl bg-black text-[15px] 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-4 text-center text-xs text-black/40">
继续即表示您同意使用条款和隐私政策
</p>
</div>
</div>
) : null}
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
<DialogContent className="max-h-[88vh] w-[92vw] max-w-[980px] overflow-hidden rounded-[22px] border-black/10 bg-[#f6f6f7] p-0">
<DialogHeader className="sr-only">
<DialogTitle>账户管理</DialogTitle>
</DialogHeader>
<div className="flex h-full max-h-[88vh] flex-col p-4">
<div className="mb-2.5 flex items-center gap-5 border-b border-black/8 pb-2">
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "account" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("account")}
>
账户管理
</button>
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "usage" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("usage")}
>
用量
</button>
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "billing" ? "text-black" : "text-black/35"}`}
onClick={() => setAccountCenterTab("billing")}
>
账单
</button>
</div>
<div className="flex-1 overflow-y-auto pr-1">
{accountCenterTab === "account" ? (
<div className="space-y-4">
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0">
<div className="truncate text-[14px] font-semibold text-black">
{accountProfile.name}
</div>
<div className="truncate text-[12px] text-black/45">{accountProfile.email}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
className="h-8 rounded-xl bg-[#2f3542] px-3 text-[12px] text-white hover:bg-[#242a36] hover:text-white"
onClick={() => toast.info("编辑资料功能后续开放")}
>
编辑资料
</Button>
<Button
variant="ghost"
className="h-8 rounded-xl border border-black/10 bg-white px-3 text-[12px] text-black/75 hover:bg-black/5"
onClick={() => {
void signOut({ redirect: true, callbackUrl: "/" });
}}
>
退出登录
</Button>
</div>
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3.5">
<div className="mb-2 flex items-center gap-4 text-[13px] font-semibold">
<span className="text-black">账户信息</span>
<span className="text-black/30">设备管理</span>
</div>
<div className="grid grid-cols-[110px_1fr] gap-y-2 text-[12px]">
<div className="text-black/55">当前订阅</div>
<div className="text-black">{accountProfile.planName}</div>
<div className="text-black/55">可用积分</div>
<div className="text-black">{accountProfile.availablePoints.toLocaleString()}</div>
<div className="text-black/55">每月更新积分</div>
<div className="text-black">{accountProfile.monthlyCredits.toLocaleString()}</div>
<div className="text-black/55">每日免费积分</div>
<div className="text-black">{accountProfile.dailyFreeCredits.toLocaleString()}</div>
</div>
</div>
</div>
) : null}
{accountCenterTab === "usage" ? (
<div className="rounded-[16px] border border-black/10 bg-white">
<div className="grid grid-cols-[2fr_1fr_2fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
<span>明细</span>
<Select value={usageFilter} onValueChange={(value: "all" | "consumed" | "earned") => setUsageFilter(value)}>
<SelectTrigger className="h-7 w-[104px] border-black/10 bg-white text-[12px]">
<span>{usageFilter === "all" ? "全部" : usageFilter === "consumed" ? "已消耗" : "已获取"}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="consumed">已消耗</SelectItem>
<SelectItem value="earned">已获取</SelectItem>
</SelectContent>
</Select>
<span>日期</span>
<span>积分消耗</span>
</div>
{filteredUsageRecords.length > 0 ? (
<div>
{filteredUsageRecords.map((record, idx) => (
<div
key={record.id}
className={`grid grid-cols-[2fr_1fr_2fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
>
<div className="truncate pr-3 text-black/80">{record.detail}</div>
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
<div className="text-black/65">{formatDateTime(record.createdAt)}</div>
<div className={record.amount < 0 ? "text-black" : "text-[#0f8a3c]"}>
{record.amount > 0 ? `+${record.amount}` : record.amount}
</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
)}
</div>
) : null}
{accountCenterTab === "billing" ? (
<div className="rounded-[16px] border border-black/10 bg-white">
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
<span>日期</span>
<span>类别</span>
<span>金额</span>
<span>状态</span>
<span>发票</span>
</div>
{accountCenterData?.billingRecords && accountCenterData.billingRecords.length > 0 ? (
<div>
{accountCenterData.billingRecords.map((record, idx) => (
<div
key={record.id}
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
>
<div className="text-black/65">{formatDateTime(record.date)}</div>
<div className="text-black/80">{record.category}</div>
<div className="text-black/80">{record.amount > 0 ? `+${record.amount}` : record.amount}</div>
<div className="text-black/55">{record.status === "completed" ? "完成" : record.status === "pending" ? "处理中" : "失败"}</div>
<div className="text-black/35">{record.invoice || "-"}</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
)}
</div>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[980px] overflow-hidden rounded-2xl border-black/10 bg-[#f7f7f7] p-0">
<div className="flex h-full max-h-[90vh] flex-col">
<DialogHeader className="border-b border-black/6 px-5 py-4">
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>
<div className="mt-2.5 flex items-center justify-center gap-2">
<Button
variant={billingCycle === "monthly" ? "default" : "outline"}
className="h-7 rounded-full px-3.5 text-[11px]"
onClick={() => setBillingCycle("monthly")}
>
月付
</Button>
<Button
variant={billingCycle === "annual" ? "default" : "outline"}
className="h-7 rounded-full px-3.5 text-[11px]"
onClick={() => setBillingCycle("annual")}
>
年付
</Button>
<span className="rounded-full bg-[#ffe68f] px-2 py-0.5 text-[11px] text-[#8b5e00]">
{membershipConfig?.annualDiscountLabel || "限时优惠"}
</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-3.5">
{siteSettingsData?.dulupayConfig?.enabled === true &&
enabledPointsPackages.length > 0 ? (
<section className="mb-5 rounded-2xl border border-black/10 bg-white p-3.5">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-base font-semibold text-black">积分充值</h4>
<span className="text-[11px] text-black/45">支持 DuluPay 即时到账</span>
</div>
<div className="grid grid-cols-1 gap-2.5 md:grid-cols-3">
{enabledPointsPackages.map((pkg) => {
const isPaying = creatingPaymentPackageId === pkg.id;
return (
<div
key={pkg.id}
className="rounded-xl border border-black/10 bg-[#fafafa] p-3"
>
<div className="text-sm font-semibold text-black">{pkg.name}</div>
<div className="mt-1 text-xs text-black/55">
{pkg.points.toLocaleString()} 积分
</div>
<div className="mt-2 text-xl font-bold text-black">
{formatPrice(
pkg.currency === "CNY" ? "¥" : `${pkg.currency} `,
pkg.price
)}
</div>
<Button
className="mt-2.5 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
disabled={isPaying}
onClick={() => handleCreateDulupayOrder(pkg.id)}
>
{isPaying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
跳转中
</span>
) : (
"立即充值"
)}
</Button>
</div>
);
})}
</div>
</section>
) : null}
{membershipPlans.length > 0 ? (
<div className="grid grid-cols-1 gap-3 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-xl border bg-white p-3 ${
plan.highlighted
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
: "border-black/10"
}`}
>
<div className="mb-2.5 flex items-center justify-between">
<h3 className="text-lg font-semibold text-black">{plan.name}</h3>
{plan.badge ? (
<span className="rounded-full bg-[#eef0ff] px-2 py-0.5 text-[10px] text-[#5b65db]">
{plan.badge}
</span>
) : null}
</div>
<div className="mb-2.5">
{plan.monthlyPrice <= 0 ? (
<div className="text-3xl font-bold text-black">免费</div>
) : billingCycle === "monthly" ? (
<div className="text-3xl font-bold text-black">
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
<span className="ml-1 text-xs font-normal text-black/45">/月</span>
</div>
) : (
<div>
<div className="text-3xl font-bold text-black">
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
<span className="ml-1 text-xs font-normal text-black/45">/年</span>
</div>
<div className="mt-1 text-[11px] text-black/45">
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
</div>
</div>
)}
</div>
<div className="mb-3 text-xs text-black/60">
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
</div>
<Button
className="mb-3 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
disabled={isCurrent}
onClick={() => {
if (isCurrent) return;
toast.info("支付能力后续接入,已预留套餐配置接口");
}}
>
{isCurrent ? "当前套餐" : "升级"}
</Button>
<ul className="space-y-1 text-[11px] text-black/65">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<span className="mt-1 h-1 w-1 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-7">
<h4 className="mb-3 text-center text-2xl 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-3.5 py-2.5 text-xs 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-7">
<h4 className="mb-2 text-center text-2xl font-semibold text-black">
模型积分消耗 & 每月生成数量
</h4>
<p className="mb-3 text-center text-[11px] text-black/45">
以下表格基于系统设置中的会员配置,可直接在后台调整。
</p>
<div className="overflow-x-auto rounded-xl border border-black/10 bg-white">
<table className="w-full min-w-[820px] text-xs">
<thead>
<tr className="bg-black/[0.03]">
<th className="px-2.5 py-2.5 text-left font-semibold text-black/75">模型</th>
{membershipPlans.map((plan) => (
<th key={plan.id} className="px-2.5 py-2.5 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-2.5 py-2 text-[11px] 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-2.5 py-2 text-black/70">{row.model}</td>
{membershipPlans.map((plan) => (
<td key={`${row.model}-${plan.id}`} className="px-2.5 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>
);
}