ai-chatbot/components/home/lovart-home.tsx
Your Name be5b9aef12
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: sync latest workspace changes
2026-04-05 02:08:26 +08:00

2509 lines
96 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 { 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,
BarChart3,
Bell,
Briefcase,
ChevronDown,
Clapperboard,
Edit,
Folder,
Heart,
Home,
ImageIcon,
Info,
Loader2,
LogOut,
Mail,
Monitor,
Palette,
Plus,
PlayCircle,
Settings,
Sparkles,
Star,
Trash2,
Upload,
User,
Users,
Wand2,
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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";
import { ImageAnnotator } from "@/components/image-annotator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
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;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
} | null;
};
type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number;
};
type TutorialVideo = {
id: string;
title: string;
videoUrl: string;
coverUrl?: string | null;
description?: string | null;
order: number;
isEnabled: boolean;
};
type PublicAuthSettings = {
isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean;
isGoogleConfigured?: boolean;
isRegistrationEnabled?: boolean;
};
type ContactConfig = {
phone?: string;
wechatQrUrl?: string;
};
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 = [
{ key: "nanoBananaPro", label: "Nano Banana Pro", icon: Sparkles, highlighted: true },
{ key: "design", label: "Design", icon: ImageIcon, highlighted: false },
{ key: "branding", label: "Branding", icon: Star, highlighted: false },
{ key: "illustration", label: "Illustration", icon: Palette, highlighted: false },
{ key: "ecommerce", label: "E-Commerce", icon: Monitor, highlighted: false },
{ key: "video", label: "Video", icon: Clapperboard, highlighted: false },
] as const;
type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
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 sidebarPopupBaseClass =
"rounded-2xl border-black/10 bg-[#f4f4f4]/90 p-2 backdrop-blur-md shadow-[0_16px_40px_rgba(0,0,0,0.12)]";
const [prompt, setPrompt] = useState("");
const [files, setFiles] = useState<UploadFileItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeCategory, setActiveCategory] = useState("全部");
const [showAllProjects, setShowAllProjects] = useState(false);
const [isProjectsOnlyView, setIsProjectsOnlyView] = 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 [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 [showImageEditor, setShowImageEditor] = useState(false);
const [editorImageUrl, setEditorImageUrl] = useState("");
const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false);
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
const [contactDialogOpen, setContactDialogOpen] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
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[];
agentModel?: {
id: string;
displayName: string;
avatarUrl?: string | null;
providerType: string;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
} | null;
} | null;
}>(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[];
lovartPromptTagVisibility?: Partial<LovartPromptTagVisibility>;
contactConfig?: ContactConfig;
membershipConfig?: {
enabled?: boolean;
};
toolbox1Name?: string | null;
toolbox1Enabled?: boolean;
toolbox2Name?: string | null;
toolbox2Enabled?: boolean;
productPlanningEnabled?: boolean;
agentEnabled?: boolean;
tutorialEnabled?: boolean;
tutorialVideoUrl?: string | null;
pointsPackages?: PointsPackage[];
dulupayConfig?: {
enabled?: boolean;
defaultType?: string;
};
}>("/api/site-settings", fetcher);
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
siteSettingsData?.tutorialEnabled ? "/api/tutorial-videos" : null,
fetcher
);
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
"/api/auth-settings",
fetcher
);
const { data: homeAnimationData } = useSWR<{
enabled?: boolean;
htmlContent?: string;
fileName?: string;
}>("/api/home-animation", fetcher);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
const currentPlan = membershipData?.currentPlan;
const autoComposerModel = useMemo<ModelOption>(
() => ({
id: "auto",
modelId: "auto",
displayName: "Agent模式",
providerType: "auto",
supportsImageGen: true,
avatarUrl: modelsData?.agentModel?.avatarUrl || null,
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
}),
[modelsData?.agentModel?.avatarUrl, modelsData?.agentModel?.imageGenConfig]
);
const composerModelValue = "auto";
const triggerComposerModel = autoComposerModel;
const isMembershipEnabled = siteSettingsData?.membershipConfig?.enabled !== false;
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 lovartPromptTagVisibility = useMemo<LovartPromptTagVisibility>(() => {
const raw = siteSettingsData?.lovartPromptTagVisibility || {};
return {
nanoBananaPro: raw.nanoBananaPro !== false,
design: raw.design !== false,
branding: raw.branding !== false,
illustration: raw.illustration !== false,
ecommerce: raw.ecommerce !== false,
video: raw.video !== false,
};
}, [siteSettingsData?.lovartPromptTagVisibility]);
const visiblePromptTags = useMemo(
() => PROMPT_TAGS.filter((tag) => lovartPromptTagVisibility[tag.key]),
[lovartPromptTagVisibility]
);
const contactConfig = useMemo(
() => ({
phone: String(siteSettingsData?.contactConfig?.phone || "").trim(),
wechatQrUrl: String(siteSettingsData?.contactConfig?.wechatQrUrl || "").trim(),
}),
[siteSettingsData?.contactConfig?.phone, siteSettingsData?.contactConfig?.wechatQrUrl]
);
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 shouldShowAllProjects = isProjectsOnlyView || showAllProjects;
const recentProjects = useMemo(() => {
const projects = canvasData?.data || [];
return shouldShowAllProjects ? projects : projects.slice(0, 4);
}, [canvasData?.data, shouldShowAllProjects]);
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 tutorialVideos = useMemo(() => {
const configured = (tutorialVideosData?.videos || [])
.filter((video) => video.isEnabled !== false && Boolean(video.videoUrl))
.sort((a, b) => a.order - b.order);
if (configured.length > 0) {
return configured;
}
const fallbackUrl = String(siteSettingsData?.tutorialVideoUrl || "").trim();
if (!fallbackUrl) {
return [] as TutorialVideo[];
}
return [
{
id: "fallback-tutorial",
title: "功能教程",
videoUrl: fallbackUrl,
coverUrl: null,
description: "默认教程视频",
order: 0,
isEnabled: true,
},
];
}, [siteSettingsData?.tutorialVideoUrl, tutorialVideosData?.videos]);
const canUseGoogleLogin = Boolean(
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
);
const canUseDingTalkLogin = Boolean(authSettingsData?.isDingTalkEnabled);
const canUseRegistration = authSettingsData?.isRegistrationEnabled !== false;
const hasThirdPartyLoginOptions = canUseGoogleLogin || canUseDingTalkLogin;
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (_error) {}
try {
await signOut({
callbackUrl: "/",
redirect: false,
});
} catch (_error) {}
window.location.replace("/");
};
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 fetchUploadWithRetry(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",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
});
}
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 = () => {
if (!canUseDingTalkLogin) {
toast.error("钉钉登录未启用");
return;
}
setLoginDialogOpen(false);
window.location.href = "/api/auth/dingtalk/login";
};
const handleRegister = () => {
if (!canUseRegistration) {
toast.error("当前未开放注册");
return;
}
setLoginDialogOpen(false);
router.push("/register");
};
const handleOpenImageEditor = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
setEditorImageUrl("");
setShowImageEditor(true);
};
const handleImageEditorConfirm = async (annotatedImageUrl: string) => {
let finalUrl = annotatedImageUrl;
if (annotatedImageUrl.startsWith("data:")) {
try {
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `edited-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
toast.error("上传失败");
return;
}
const result = await uploadResponse.json();
finalUrl = result.url;
} catch (_error) {
toast.error("上传失败");
return;
}
}
try {
const response = await fetch(finalUrl);
const blob = await response.blob();
const extension =
blob.type === "image/jpeg"
? "jpg"
: blob.type === "image/webp"
? "webp"
: "png";
const file = new File([blob], `edited-${Date.now()}.${extension}`, {
type: blob.type || "image/png",
});
setFiles((current) => [
...current,
{
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
previewUrl: finalUrl,
},
]);
setShowImageEditor(false);
setEditorImageUrl("");
toast.success("已添加到对话框");
} catch (_error) {
toast.error("图片处理失败,请重试");
}
};
const handleImageEditorCancel = () => {
setShowImageEditor(false);
setEditorImageUrl("");
};
const handleOpenTutorial = (video: TutorialVideo) => {
if (!video?.videoUrl) {
toast.info("暂无教程视频");
return;
}
setCurrentTutorialVideo(video);
setTutorialDialogOpen(true);
};
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: "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 handleDeleteProject = async (projectId: string, projectTitle?: string) => {
if (deletingProjectId === projectId) return;
const normalizedTitle = String(projectTitle || "").trim() || "未命名画布";
const confirmed = window.confirm(`确定删除「${normalizedTitle}」吗?删除后不可恢复。`);
if (!confirmed) return;
setDeletingProjectId(projectId);
try {
const response = await fetch(`/api/canvas/${projectId}`, {
method: "DELETE",
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.success === false) {
throw new Error(result?.error || "删除画布失败");
}
await mutateCanvas((current) => {
if (!current) return current;
return {
...current,
data: (current.data || []).filter((project) => project.id !== projectId),
};
}, false);
toast.success("画布已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除画布失败");
await mutateCanvas();
} finally {
setDeletingProjectId(null);
}
};
const openAllProjectsInPage = () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
setIsProjectsOnlyView(true);
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] [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
<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>
{isMembershipEnabled ? (
<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>
) : null}
{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>
{isMembershipEnabled ? (
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
) : null}
</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={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(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">
{isMembershipEnabled ? (
<AccountMenuItem
icon={Users}
label="创建团队"
onClick={() => toast.info("团队功能后续开放")}
/>
) : null}
<AccountMenuItem
icon={User}
label="账户管理"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
/>
<AccountMenuItem
icon={Info}
label="使用指南"
onClick={() => toast.info("使用指南后续开放")}
/>
<AccountMenuItem
icon={Mail}
label="联系我们"
onClick={() => setContactDialogOpen(true)}
/>
<AccountMenuItem
icon={LogOut}
label="退出登录"
onClick={() => {
void handleLogout();
}}
/>
</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">
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent side="right">新建项目</TooltipContent>
</Tooltip>
<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">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full bg-black/5"
onClick={() => {
setIsProjectsOnlyView(false);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
<Home className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">首页</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent side="right">项目列表</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent side="right">个人主页</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/gallery")}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">个人相册</TooltipContent>
</Tooltip>
{siteSettingsData?.tutorialEnabled ? (
<HoverCard openDelay={80} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
title="视频教程"
>
<PlayCircle className="h-[18px] w-[18px]" />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
className={`w-[300px] ${sidebarPopupBaseClass}`}
>
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">视频教程</div>
<div className="max-h-[380px] space-y-1 overflow-y-auto">
{tutorialVideos.length > 0 ? (
tutorialVideos.map((video) => (
<button
key={video.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-xl p-2 text-left hover:bg-black/[0.04]"
onClick={() => handleOpenTutorial(video)}
>
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-black/[0.06]">
{video.coverUrl ? (
<img
src={video.coverUrl}
alt={video.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<PlayCircle className="h-4 w-4 text-black/45" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-black/85">
{video.title}
</div>
{video.description ? (
<div className="truncate text-[11px] text-black/45">
{video.description}
</div>
) : null}
</div>
</button>
))
) : (
<div className="px-2 py-3 text-[12px] text-black/45">暂无教程视频</div>
)}
</div>
</HoverCardContent>
</HoverCard>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={handleOpenImageEditor}
title="图片编辑"
>
<Edit className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">图片编辑</TooltipContent>
</Tooltip>
{siteSettingsData?.agentEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/canvas?mode=agent")}
title="智能体"
>
<Wand2 className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">智能体</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/video-canvas")}
title="视频画布"
>
<Clapperboard className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">视频画布</TooltipContent>
</Tooltip>
{siteSettingsData?.productPlanningEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/product-planning")}
title="商品策划"
>
<BarChart3 className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">商品策划</TooltipContent>
</Tooltip>
) : null}
{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] ${sidebarPopupBaseClass}`}
>
<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] ${sidebarPopupBaseClass}`}
>
<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" || user?.type === "agent_admin" ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() =>
router.push(user?.type === "agent_admin" ? "/settings/agent-workflow" : "/settings")
}
title="系统设置"
>
<Settings className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">系统设置</TooltipContent>
</Tooltip>
) : null}
</div>
</TooltipProvider>
</aside>
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
{!isProjectsOnlyView && (
<section className="mt-8 text-center">
{isMembershipEnabled ? (
<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="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 折优惠"}
</div>
)}
<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>
<div className="inline-flex h-8 min-w-[130px] items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600">
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
{triggerComposerModel?.avatarUrl ? (
<img
src={triggerComposerModel.avatarUrl}
alt={triggerComposerModel.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">
{(triggerComposerModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>
<span className="max-w-[95px] truncate">
{triggerComposerModel?.displayName || "Agent模式"}
</span>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={triggerComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={triggerComposerModel?.providerType}
imageGenConfig={triggerComposerModel?.imageGenConfig || undefined}
hideImageCount
/>
</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">
{visiblePromptTags.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>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
</div>
) : null}
</section>
)}
<section
ref={projectSectionRef}
className={isProjectsOnlyView ? "mt-6" : "mt-20"}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-[18px] font-semibold tracking-tight">
{isProjectsOnlyView ? "所有项目" : "最近项目"}
</h2>
{!isProjectsOnlyView && 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) => (
<div
key={item.id}
className="group relative rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
>
<button
type="button"
onClick={() => router.push(`/canvas/${item.id}`)}
className="block w-full cursor-pointer text-left"
>
<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>
<button
type="button"
onClick={() => handleDeleteProject(item.id, item.title)}
disabled={deletingProjectId === item.id}
className="absolute right-5 top-5 z-20 inline-flex h-7 w-7 items-center justify-center rounded-full border border-black/10 bg-white/90 text-black/45 shadow-sm transition hover:border-[#ffb4b4] hover:text-[#ef4444] disabled:cursor-not-allowed disabled:opacity-80"
aria-label={`删除${item.title || "未命名画布"}`}
>
{deletingProjectId === item.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
))}
{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>
{!isProjectsOnlyView && (
<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"
title="回到顶部"
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" })}
>
<ArrowUp className="h-4 w-4" />
</button>
{showImageEditor ? (
<ImageAnnotator
imageUrl={editorImageUrl}
onConfirm={handleImageEditorConfirm}
submitMode="publish-new-canvas"
onCancel={handleImageEditorCancel}
onHomeClick={handleImageEditorCancel}
/>
) : null}
<Dialog
open={tutorialDialogOpen}
onOpenChange={(open) => {
setTutorialDialogOpen(open);
if (!open) {
setCurrentTutorialVideo(null);
}
}}
>
<DialogContent className="w-[92vw] max-w-[980px] overflow-hidden rounded-[20px] border-black/10 bg-black p-0 text-white">
<DialogHeader className="border-b border-white/10 px-4 py-3">
<DialogTitle className="truncate text-[14px] font-medium text-white">
{currentTutorialVideo?.title || "功能教程"}
</DialogTitle>
</DialogHeader>
<div className="aspect-video w-full bg-black">
{currentTutorialVideo?.videoUrl ? (
<video
key={currentTutorialVideo.videoUrl}
className="h-full w-full"
controls
autoPlay
preload="metadata"
src={currentTutorialVideo.videoUrl}
poster={currentTutorialVideo.coverUrl || undefined}
/>
) : null}
</div>
{currentTutorialVideo?.description ? (
<div className="px-4 pb-4 pt-3 text-[12px] text-white/70">
{currentTutorialVideo.description}
</div>
) : null}
</DialogContent>
</Dialog>
<Dialog open={contactDialogOpen} onOpenChange={setContactDialogOpen}>
<DialogContent className="w-[92vw] max-w-[420px] rounded-[20px] border-black/10 bg-white p-0">
<DialogHeader className="border-b border-black/10 px-5 py-4">
<DialogTitle className="text-[16px] font-semibold text-black">联系我们</DialogTitle>
</DialogHeader>
<div className="space-y-4 px-5 py-4">
{contactConfig.wechatQrUrl ? (
<div className="flex flex-col items-center gap-2">
<img
src={contactConfig.wechatQrUrl}
alt="微信二维码"
className="h-52 w-52 rounded-xl border border-black/10 object-cover"
/>
<p className="text-[12px] text-black/50">微信扫码添加客服</p>
</div>
) : (
<div className="rounded-xl border border-dashed border-black/20 bg-black/[0.02] px-3 py-5 text-center text-[13px] text-black/45">
暂未配置微信二维码
</div>
)}
<div className="rounded-xl border border-black/10 bg-black/[0.02] px-3.5 py-3">
<div className="text-[12px] text-black/50">联系电话</div>
<div className="mt-1 text-[15px] font-medium text-black">
{contactConfig.phone || "暂未配置联系电话"}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{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">
{canUseRegistration ? "登录或注册以继续" : "登录以继续"}
</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>
{canUseRegistration ? (
<div className="mt-3 text-center text-[13px] text-black/55">
还没有账号?
<button
type="button"
className="ml-1 font-medium text-black hover:underline"
onClick={handleRegister}
>
立即注册
</button>
</div>
) : null}
<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]/90 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 handleLogout();
}}
>
退出登录
</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]/90 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>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}