ai-chatbot/components/home/lovart-home.tsx

2605 lines
95 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 dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import {
ArrowRight,
ArrowUp,
BarChart3,
Bell,
Box,
Briefcase,
ChevronDown,
Clapperboard,
Edit,
Folder,
Home,
ImageIcon,
Info,
Layers3,
Loader2,
LogOut,
Mail,
Monitor,
Moon,
Palette,
Plus,
PlayCircle,
Settings,
Sparkles,
Star,
Sun,
Upload,
User,
Users,
Wand2,
Workflow,
X,
Zap,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import type { ImageGenerationParams } from "@/components/image-params-bar";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { cn, fetcher } from "@/lib/utils";
import {
type CanvasLaunchAttachment,
getCanvasLaunchStorageKey,
} from "@/lib/canvas-launch";
import {
preloadCanvasWorkspace,
prepareCanvasNavigation,
} from "@/lib/client/canvas-workspace-preload";
import type { MembershipConfig } from "@/lib/membership/config";
import {
normalizeLovartHomeSidebarConfig,
type LovartHomeSidebarItemKey,
} from "@/lib/home/lovart-sidebar-config";
import { BrandTopBar } from "@/components/layout/brand-top-bar";
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
import {
LOVART_PROMO_BANNER_HEIGHT,
LovartPromoBanner,
} from "@/components/home/lovart/lovart-promo-banner";
const HOME_PROMO_DISMISS_STORAGE_KEY = "lovart-home-promo-dismissed-v1";
const ImageAnnotatorLazy = dynamic(
() => import("@/components/image-annotator").then((module) => module.ImageAnnotator),
{
loading: () => (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 text-white backdrop-blur-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
正在加载图片编辑器...
</div>
),
ssr: false,
}
);
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;
organizations?: Array<{ name?: string } | string>;
dingTalkOrganizations?: Array<{ name?: string } | string>;
};
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;
siteLogoDark?: string | null;
surpriseCategories?: string[];
heroPromoText?: string | null;
heroPromoActionText?: string | null;
heroTitleText?: string | null;
heroSubtitleText?: string | null;
};
type HomeSessionResponse = {
valid: boolean;
user?: HomeUser | null;
userType?: string | null;
};
type UploadFileItem = {
id: string;
file: File;
previewUrl: string;
};
type ThemeMode = "system" | "light" | "dark";
const THEME_MODE_OPTIONS: Array<{
value: ThemeMode;
label: string;
description: string;
icon: LucideIcon;
}> = [
{
value: "system",
label: "跟随系统",
description: "随浏览器/系统深浅色自动切换",
icon: Monitor,
},
{
value: "light",
label: "浅色模式",
description: "始终使用明亮界面",
icon: Sun,
},
{
value: "dark",
label: "深色模式",
description: "始终使用暗色界面",
icon: Moon,
},
];
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;
const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
LovartHomeSidebarItemKey,
LucideIcon
> = {
newProject: Plus,
home: Home,
projects: Folder,
profile: User,
gallery: ImageIcon,
tutorial: PlayCircle,
imageEditor: Edit,
aiCad: Box,
model3d: Box,
scene: Layers3,
agent: Wand2,
videoCanvas: Clapperboard,
flowBoard: Workflow,
productPlanning: BarChart3,
toolbox1: Briefcase,
toolbox2: Briefcase,
settings: Settings,
};
type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
const FEATURED_PAGE_SIZE = 24;
const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
function SidebarSvgIcon({
iconUrl,
fallback: FallbackIcon,
imgClassName,
fallbackClassName,
}: {
iconUrl?: string | null;
fallback: LucideIcon;
imgClassName: string;
fallbackClassName: string;
}) {
if (iconUrl) {
return (
<img
alt=""
aria-hidden="true"
className={imgClassName}
src={iconUrl}
/>
);
}
return <FallbackIcon className={fallbackClassName} />;
}
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 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 dark:text-white/85 dark:hover:bg-white/6"
>
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
<Icon className="h-[16px] w-[16px]" />
</span>
<span className="flex-1">{label}</span>
</button>
);
}
export function LovartHome({
user: initialUser = null,
isAuthenticated: initialIsAuthenticated = false,
siteName: initialSiteName = "AI Chatbot",
siteLogo: initialSiteLogo = null,
siteLogoDark: initialSiteLogoDark = null,
surpriseCategories: initialSurpriseCategories = ["全部", "精选"],
heroPromoText: initialHeroPromoText = null,
heroPromoActionText: initialHeroPromoActionText = null,
heroTitleText: initialHeroTitleText = null,
heroSubtitleText: initialHeroSubtitleText = null,
}: LovartHomeProps = {}) {
const router = useRouter();
const { theme, resolvedTheme, setTheme } = useTheme();
const [prompt, setPrompt] = useState("");
const [files, setFiles] = useState<UploadFileItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeCategory, setActiveCategory] = useState("全部");
const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [dismissedHomeBannerIdentity, setDismissedHomeBannerIdentity] =
useState<string | null>(null);
const [hasResolvedHomeBannerDismissal, setHasResolvedHomeBannerDismissal] =
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 [composerModelId, setComposerModelId] = useState("");
const [hasComposerModelInteracted, setHasComposerModelInteracted] =
useState(false);
const [isComposerAgentModeActive, setIsComposerAgentModeActive] =
useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [showDingTalkOrgMenu, setShowDingTalkOrgMenu] = 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 [isThemeReady, setIsThemeReady] = useState(false);
const isSecondaryDataReady = true;
const [isFeaturedDataReady, setIsFeaturedDataReady] = useState(false);
const [isHomeAnimationReady, setIsHomeAnimationReady] = useState(false);
const projectSectionRef = useRef<HTMLDivElement | null>(null);
const featuredSectionRef = useRef<HTMLElement | null>(null);
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
const filesRef = useRef<UploadFileItem[]>([]);
useEffect(() => {
setIsThemeReady(true);
}, []);
const { data: sessionData, mutate: mutateSession } = useSWR<HomeSessionResponse>(
"/api/auth/validate-session",
fetcher,
{
dedupingInterval: 5000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
}
);
const user = sessionData?.user ?? initialUser;
const isAuthenticated = sessionData?.valid ?? initialIsAuthenticated;
useEffect(() => {
if (typeof window === "undefined" || isFeaturedDataReady) {
return;
}
const loadFeaturedData = () => setIsFeaturedDataReady(true);
const fallbackTimer = window.setTimeout(loadFeaturedData, 2500);
if (!featuredSectionRef.current || !("IntersectionObserver" in window)) {
return () => window.clearTimeout(fallbackTimer);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadFeaturedData();
}
},
{ rootMargin: "900px 0px 900px 0px" }
);
observer.observe(featuredSectionRef.current);
return () => {
window.clearTimeout(fallbackTimer);
observer.disconnect();
};
}, [isFeaturedDataReady]);
useEffect(() => {
if (typeof window === "undefined" || !isSecondaryDataReady) {
return;
}
const loadHomeAnimation = () => setIsHomeAnimationReady(true);
const idleWindow = window as Window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (typeof idleWindow.requestIdleCallback === "function") {
const idleId = idleWindow.requestIdleCallback(loadHomeAnimation, {
timeout: 3200,
});
return () => idleWindow.cancelIdleCallback?.(idleId);
}
const timer = window.setTimeout(loadHomeAnimation, 1800);
return () => window.clearTimeout(timer);
}, [isSecondaryDataReady]);
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
success: boolean;
data: CanvasProject[];
}>(isAuthenticated && isSecondaryDataReady ? "/api/canvas" : null, fetcher);
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
useSWRInfinite<FeaturedResponse>(
(pageIndex, previousPageData) => {
if (!isSecondaryDataReady || !isFeaturedDataReady) {
return null;
}
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 && isSecondaryDataReady ? "/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 && isSecondaryDataReady ? "/api/models" : null, fetcher);
const { data: navLinksData } = useSWR<{ links: NavLink[] }>(
isSecondaryDataReady ? "/api/nav-links" : null,
fetcher
);
const { data: siteSettingsData } = useSWR<{
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
lovartPromoText?: string | null;
lovartPromoActionText?: string | null;
bannerEnabled?: boolean;
bannerText?: string | null;
bannerLink?: string | null;
bannerActionText?: string | null;
lovartHeroTitle?: string | null;
lovartHeroSubtitle?: string | null;
surpriseCategories?: string[];
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;
videoCanvasEnabled?: boolean;
tutorialEnabled?: boolean;
tutorialVideoUrl?: string | null;
pointsPackages?: PointsPackage[];
dulupayConfig?: {
enabled?: boolean;
defaultType?: string;
};
lovartHomeSidebarConfig?: unknown;
}>("/api/site-settings?scope=home", fetcher);
const siteName = String(
siteSettingsData?.siteName || initialSiteName || "AI Chatbot"
).trim();
const siteLogo = String(siteSettingsData?.siteLogo || initialSiteLogo || "").trim();
const siteLogoDark = String(
siteSettingsData?.siteLogoDark || initialSiteLogoDark || ""
).trim();
const heroPromoText =
siteSettingsData?.lovartPromoText ?? initialHeroPromoText;
const heroPromoActionText =
siteSettingsData?.lovartPromoActionText ?? initialHeroPromoActionText;
const homeBannerText = String(siteSettingsData?.bannerText || "").trim();
const homeBannerActionText = String(
siteSettingsData?.bannerActionText || "立即查看"
).trim();
const rawHomeBannerLink = String(siteSettingsData?.bannerLink || "").trim();
const homeBannerLink =
/^(https?:\/\/|\/(?!\/))/i.test(rawHomeBannerLink)
? rawHomeBannerLink
: "";
const homeBannerIdentity = useMemo(
() =>
homeBannerText
? JSON.stringify({
actionText: homeBannerActionText,
link: homeBannerLink,
text: homeBannerText,
})
: "",
[homeBannerActionText, homeBannerLink, homeBannerText]
);
const shouldShowHomeBanner = Boolean(
siteSettingsData?.bannerEnabled === true &&
homeBannerIdentity &&
hasResolvedHomeBannerDismissal &&
dismissedHomeBannerIdentity !== homeBannerIdentity
);
const heroTitleText =
siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText;
const heroSubtitleText =
siteSettingsData?.lovartHeroSubtitle ?? initialHeroSubtitleText;
const surpriseCategories =
siteSettingsData?.surpriseCategories ?? initialSurpriseCategories;
const effectiveResolvedTheme = isThemeReady ? resolvedTheme : undefined;
const displayLogo =
effectiveResolvedTheme === "dark"
? siteLogoDark || siteLogo || ""
: siteLogo || siteLogoDark || "";
useEffect(() => {
if (!homeBannerIdentity) {
setDismissedHomeBannerIdentity(null);
setHasResolvedHomeBannerDismissal(true);
return;
}
try {
const storedIdentity = window.localStorage.getItem(
HOME_PROMO_DISMISS_STORAGE_KEY
);
setDismissedHomeBannerIdentity(
storedIdentity === homeBannerIdentity ? homeBannerIdentity : null
);
} catch {
setDismissedHomeBannerIdentity(null);
}
setHasResolvedHomeBannerDismissal(true);
}, [homeBannerIdentity]);
const dismissHomeBanner = useCallback(() => {
if (!homeBannerIdentity) return;
try {
window.localStorage.setItem(
HOME_PROMO_DISMISS_STORAGE_KEY,
homeBannerIdentity
);
} catch {
// The banner still closes for this page when storage is unavailable.
}
setDismissedHomeBannerIdentity(homeBannerIdentity);
}, [homeBannerIdentity]);
const openHomeBannerLink = useCallback(() => {
if (!homeBannerLink) return;
if (homeBannerLink.startsWith("/")) {
router.push(homeBannerLink);
return;
}
window.open(homeBannerLink, "_blank", "noopener,noreferrer");
}, [homeBannerLink, router]);
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
? "/api/tutorial-videos"
: null,
fetcher
);
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
loginDialogOpen ? "/api/auth-settings" : null,
fetcher
);
const { data: homeAnimationData } = useSWR<{
enabled?: boolean;
htmlContent?: string;
fileName?: string;
}>(
isSecondaryDataReady && isHomeAnimationReady ? "/api/home-animation" : null,
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 composerModelOptions = useMemo<ModelOption[]>(() => {
const seen = new Set<string>(["auto"]);
const imageModels = (modelsData?.models || []).filter((model) => {
if (!model?.id || seen.has(model.id)) return false;
if (model.supportsImageGen === false) return false;
seen.add(model.id);
return true;
});
return [autoComposerModel, ...imageModels];
}, [autoComposerModel, modelsData?.models]);
const fallbackComposerModel =
composerModelOptions.find((model) => model.id !== "auto") ||
autoComposerModel;
const selectedComposerModel =
composerModelOptions.find((model) => model.id === composerModelId) ||
fallbackComposerModel;
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))
.filter((item) => item !== "精选");
const uniqueConfigured = Array.from(new Set(configured));
if (uniqueConfigured.length > 0) {
const configuredTabs = [
"全部",
...uniqueConfigured.filter((item) => item !== "全部"),
];
return Array.from(new Set(configuredTabs)).slice(0, 12);
}
return ["全部", ...DISCOVERY_DEFAULT_TABS].slice(0, 12);
}, [surpriseCategories]);
useEffect(() => {
if (discoveryTabs.includes(activeCategory)) return;
setActiveCategory(discoveryTabs[0] || "全部");
}, [activeCategory, discoveryTabs]);
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 lovartHomeSidebarConfig = useMemo(
() =>
normalizeLovartHomeSidebarConfig(siteSettingsData?.lovartHomeSidebarConfig),
[siteSettingsData?.lovartHomeSidebarConfig]
);
const getLovartSidebarItem = (key: LovartHomeSidebarItemKey) =>
lovartHomeSidebarConfig.items[key];
const isLovartSidebarItemEnabled = (key: LovartHomeSidebarItemKey) =>
getLovartSidebarItem(key).enabled !== false;
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 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 recentProjects = useMemo(() => {
const projects = canvasData?.data || [];
return isProjectsOnlyView ? projects : projects.slice(0, 4);
}, [canvasData?.data, isProjectsOnlyView]);
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 dingTalkOrganizations = useMemo(() => {
const raw = Array.isArray(authSettingsData?.organizations)
? authSettingsData.organizations
: Array.isArray(authSettingsData?.dingTalkOrganizations)
? authSettingsData.dingTalkOrganizations
: [];
return raw
.map((org) => (typeof org === "string" ? org : org?.name || ""))
.map((name) => name.trim())
.filter(Boolean);
}, [authSettingsData?.organizations, authSettingsData?.dingTalkOrganizations]);
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 {
const { signOut } = await import("next-auth/react");
await signOut({
callbackUrl: "/",
redirect: false,
});
} catch (_error) {}
window.location.replace("/");
};
useEffect(() => {
if (!isAuthenticated) return;
setLoginDialogOpen(false);
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated || typeof window === "undefined") return;
const idleWindow = window as typeof window & {
cancelIdleCallback?: (handle: number) => void;
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (typeof idleWindow.requestIdleCallback === "function") {
const idleId = idleWindow.requestIdleCallback(preloadCanvasWorkspace, {
timeout: 1200,
});
return () => idleWindow.cancelIdleCallback?.(idleId);
}
const timer = window.setTimeout(preloadCanvasWorkspace, 450);
return () => window.clearTimeout(timer);
}, [isAuthenticated]);
useEffect(() => {
if (!loginDialogOpen) {
setIsLoggingIn(false);
setLoginPassword("");
setShowDingTalkOrgMenu(false);
}
}, [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);
}
}, []);
useEffect(() => {
const fallbackModelId = fallbackComposerModel.id || "auto";
if (
!hasComposerModelInteracted &&
!isComposerAgentModeActive &&
composerModelId === "auto" &&
fallbackModelId !== "auto"
) {
setComposerModelId(fallbackModelId);
return;
}
if (composerModelOptions.some((model) => model.id === composerModelId)) {
return;
}
setComposerModelId(fallbackModelId);
}, [
composerModelId,
composerModelOptions,
fallbackComposerModel.id,
hasComposerModelInteracted,
isComposerAgentModeActive,
]);
const handleComposerModelChange = useCallback((modelId: string) => {
setHasComposerModelInteracted(true);
setIsComposerAgentModeActive(modelId === "auto");
setComposerModelId(modelId);
}, []);
const toggleComposerAgentMode = useCallback(() => {
setHasComposerModelInteracted(true);
const nextIsActive = !isComposerAgentModeActive;
setIsComposerAgentModeActive(nextIsActive);
setComposerModelId(nextIsActive ? "auto" : fallbackComposerModel.id || "");
}, [fallbackComposerModel.id, isComposerAgentModeActive]);
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 { fetchUploadWithRetry } = await import(
"@/lib/client/upload-with-retry"
);
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 { signIn } = await import("next-auth/react");
const result = await signIn("credentials", {
email,
password: loginPassword,
redirect: false,
});
if (!result || result.error || result.ok === false) {
toast.error("登录失败,请检查账号密码");
return;
}
await mutateSession();
window.location.href = "/";
} catch (_error) {
toast.error("登录失败,请稍后重试");
} finally {
setIsLoggingIn(false);
}
};
const handleGoogleLogin = async () => {
if (!canUseGoogleLogin) {
toast.error("Google 登录未启用");
return;
}
const { signIn } = await import("next-auth/react");
await signIn("google", { callbackUrl: "/" });
};
const startDingTalkLoginForOrg = (orgName: string) => {
setLoginDialogOpen(false);
window.location.href = `/api/auth/dingtalk/login?org=${encodeURIComponent(orgName)}`;
};
const handleDingTalkLogin = () => {
if (!canUseDingTalkLogin) {
toast.error("钉钉登录未启用");
return;
}
if (dingTalkOrganizations.length > 1) {
setShowDingTalkOrgMenu((prev) => !prev);
return;
}
if (dingTalkOrganizations.length === 1) {
startDingTalkLoginForOrg(dingTalkOrganizations[0] as string);
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 { fetchUploadWithRetry } = await import(
"@/lib/client/upload-with-retry"
);
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 || user?.type === "guest") {
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.status === 401) {
openLoginDialog();
throw new Error("请先登录正式账号后再充值");
}
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;
preloadCanvasWorkspace();
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;
prepareCanvasNavigation(canvasId);
if (typeof window !== "undefined") {
sessionStorage.setItem(
getCanvasLaunchStorageKey(canvasId),
JSON.stringify({
prompt:
prompt.trim() ||
(uploadedAttachments.length > 0
? "请基于这些图片继续生成设计方案"
: ""),
attachments: uploadedAttachments,
autoSend: true,
createdAt: Date.now(),
selectedModelId: isComposerAgentModeActive
? "auto"
: selectedComposerModel.id || fallbackComposerModel.id || "",
imageParams,
})
);
}
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
setFiles([]);
setPrompt("");
void mutateCanvas((current) => {
if (!current) return current;
return {
...current,
data: [
canvasResult.data as CanvasProject,
...(current.data || []).filter((item) => item.id !== canvasId),
],
};
}, false);
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
} finally {
setIsSubmitting(false);
}
};
const openNewCanvas = async () => {
if (!isAuthenticated) {
openLoginDialog();
return;
}
if (isSubmitting) return;
preloadCanvasWorkspace();
setIsSubmitting(true);
try {
const response = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "未命名画布" }),
});
const result = await response.json();
if (!response.ok || !result?.data?.id) {
throw new Error(result?.error || "创建项目失败");
}
const canvasId = String(result.data.id);
prepareCanvasNavigation(canvasId);
void mutateCanvas((current) => {
if (!current) return current;
return {
...current,
data: [
result.data as CanvasProject,
...(current.data || []).filter((item) => item.id !== canvasId),
],
};
}, false);
router.push(`/canvas/${canvasId}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败");
} finally {
setIsSubmitting(false);
}
};
const prepareCanvasProject = useCallback(
(projectId: string) => {
prepareCanvasNavigation(projectId);
router.prefetch(`/canvas/${projectId}`);
},
[router]
);
const openCanvasProject = useCallback(
(projectId: string) => {
prepareCanvasProject(projectId);
router.push(`/canvas/${projectId}`);
},
[prepareCanvasProject, router]
);
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);
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 renderLovartSidebarIcon = (
key: LovartHomeSidebarItemKey,
options?: {
active?: boolean;
filled?: boolean;
sizeClassName?: string;
}
) => {
const item = getLovartSidebarItem(key);
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]";
if (options?.filled) {
return (
<SidebarSvgIcon
iconUrl={item.iconUrl}
fallback={FallbackIcon}
imgClassName={cn(sizeClassName, "object-contain", "brightness-0 invert")}
fallbackClassName={cn(sizeClassName, "text-white")}
/>
);
}
return (
<SidebarSvgIcon
iconUrl={item.iconUrl}
fallback={FallbackIcon}
imgClassName={cn(
sizeClassName,
"object-contain",
options?.active
? "brightness-0 opacity-90 dark:invert dark:opacity-95"
: "brightness-0 opacity-88 dark:invert dark:opacity-72"
)}
fallbackClassName={cn(
sizeClassName,
options?.active
? "text-[#202020] dark:text-white"
: "text-[#202020] dark:text-white/72"
)}
/>
);
};
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]);
const selectedThemeMode: ThemeMode =
isThemeReady && (theme === "light" || theme === "dark" || theme === "system")
? theme
: "system";
const selectedThemeOption =
THEME_MODE_OPTIONS.find((option) => option.value === selectedThemeMode) ||
THEME_MODE_OPTIONS[0];
const SelectedThemeIcon = selectedThemeOption.icon;
return (
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
{shouldShowHomeBanner ? (
<LovartPromoBanner
actionText={homeBannerActionText}
link={homeBannerLink}
onAction={openHomeBannerLink}
onDismiss={dismissHomeBanner}
text={homeBannerText}
/>
) : null}
<BrandTopBar
contentClassName="pl-[80px]"
logo={displayLogo}
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
showBrand={false}
siteName={siteName || "Lovart"}
>
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/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] border border-black/10 bg-white px-2 text-[14px] text-black/72 shadow-sm hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
>
<div className="inline-flex items-center gap-1 rounded-[9px] px-1.5 py-0.5 text-xs">
<Zap className="h-3 w-3 text-amber-500" />
{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 dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-4">
<div className="mb-4 flex items-start justify-between">
<div>
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
{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] dark:border-white/10 dark:text-white/90">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
<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 dark:text-white/75">
<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 dark:text-white/75 dark:hover:text-white"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
title={`主题模式:${selectedThemeOption.label}`}
aria-label={`主题模式:${selectedThemeOption.label}`}
>
<SelectedThemeIcon className="h-[15px] w-[15px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="flex items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
return (
<DropdownMenuItem
key={option.value}
aria-label={option.label}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/70 dark:text-white/70"
)}
onSelect={() => setTheme(option.value)}
title={option.label}
>
<OptionIcon className="h-[15px] w-[15px]" />
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
{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 dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-3.5">
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
<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 dark:text-white">
{userDisplayName}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/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 dark:border-white/10">
<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 dark:text-white/80 dark:hover:bg-white/6"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
>
<span>积分</span>
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
{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 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
onClick={openLoginDialog}
>
登录
</button>
)}
</BrandTopBar>
<LovartSidebar
agentEnabled={siteSettingsData?.agentEnabled !== false}
getItem={getLovartSidebarItem}
isEnabled={isLovartSidebarItemEnabled}
isProjectsOnlyView={isProjectsOnlyView}
onAllProjects={openAllProjectsInPage}
onGallery={() => router.push("/gallery")}
onHome={() => {
setIsProjectsOnlyView(false);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
onImageEditor={handleOpenImageEditor}
onNavLink={openNavLink}
onNewCanvas={openNewCanvas}
onRoute={openNavLink}
onTutorial={handleOpenTutorial}
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}
renderIcon={renderLovartSidebarIcon}
settingsHref={
user?.type === "admin"
? "/settings"
: user?.type === "agent_admin"
? "/settings/agent-workflow"
: null
}
siteLogo={displayLogo}
siteName={siteName || "Lovart"}
topOffset={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
toolbox1Links={toolbox1Links}
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
toolbox2Links={toolbox2Links}
tutorialEnabled={Boolean(siteSettingsData?.tutorialEnabled)}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={siteSettingsData?.videoCanvasEnabled !== false}
tone={effectiveResolvedTheme === "dark" ? "dark" : "light"}
/>
<main
className={cn(
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
)}
>
{!isProjectsOnlyView && (
<LovartHeroComposer
composerPlaceholder={composerPlaceholder}
displayLogo={displayLogo}
files={files}
handleAddFiles={handleAddFiles}
heroPromoActionText={heroPromoActionText}
heroPromoText={heroPromoText}
heroSubtitleText={heroSubtitleText}
heroTitleText={heroTitleText}
homeAnimationData={
isHomeAnimationReady ? homeAnimationData : undefined
}
isAgentModeActive={isComposerAgentModeActive}
isMembershipEnabled={isMembershipEnabled}
isSubmitting={isSubmitting}
modelOptions={composerModelOptions}
onModelChange={handleComposerModelChange}
onStartAgentMode={toggleComposerAgentMode}
onSubmit={createCanvasFromComposer}
openUpgradeDialog={() => setUpgradeDialogOpen(true)}
prompt={prompt}
removeFile={removeFile}
setPrompt={setPrompt}
siteName={siteName}
selectedModelId={selectedComposerModel.id}
/>
)}
<LovartProjectsSection
deletingProjectId={deletingProjectId}
hasMoreProjects={hasMoreProjects}
isProjectsOnlyView={isProjectsOnlyView}
onCreateProject={openNewCanvas}
onDeleteProject={handleDeleteProject}
onOpenProject={openCanvasProject}
onPrepareCreate={preloadCanvasWorkspace}
onPrepareProject={prepareCanvasProject}
onViewAllProjects={openAllProjectsInPage}
projects={recentProjects}
projectSectionRef={projectSectionRef}
/>
{!isProjectsOnlyView && (
<LovartFeaturedGallery
activeCategory={activeCategory}
categories={discoveryTabs}
featuredItems={featuredItems}
featuredLoadMoreRef={featuredLoadMoreRef}
featuredSectionRef={featuredSectionRef}
isLoadingFeatured={isLoadingFeatured}
isLoadingMoreFeatured={isLoadingMoreFeatured}
setActiveCategory={setActiveCategory}
/>
)}
</main>
<button
type="button"
title="回到顶部"
className="fixed bottom-6 right-6 z-20 flex h-9 w-9 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow-sm transition-colors duration-150 hover:text-black dark:border-white/12 dark:bg-[#141416] dark:text-white/55 dark:hover:text-white"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<ArrowUp className="h-4 w-4" />
</button>
{showImageEditor ? (
<ImageAnnotatorLazy
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 dark:border-white/10 dark:bg-[#141416]">
<DialogHeader className="border-b border-black/10 px-5 py-4 dark:border-white/10">
<DialogTitle className="text-[16px] font-semibold text-black dark:text-white">联系我们</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 dark:border-white/10"
/>
<p className="text-[12px] text-black/50 dark:text-white/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 dark:border-white/12 dark:bg-white/[0.03] dark:text-white/45">
暂未配置微信二维码
</div>
)}
<div className="rounded-xl border border-black/10 bg-black/[0.02] px-3.5 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="text-[12px] text-black/50 dark:text-white/50">联系电话</div>
<div className="mt-1 text-[15px] font-medium text-black dark:text-white">
{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-[400px] rounded-[18px] bg-white p-5 shadow-2xl dark:bg-[#141416]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{displayLogo ? (
<img
src={displayLogo}
alt={siteName}
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-3 w-3" />
</div>
)}
<span className="text-[16px] font-semibold leading-none text-black dark:text-white">
{siteName}
</span>
</div>
<button
type="button"
className="rounded-full p-1 text-black/45 hover:bg-black/5 hover:text-black/70 dark:text-white/45 dark:hover:bg-white/7 dark:hover:text-white/75"
onClick={() => setLoginDialogOpen(false)}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-4 text-center">
<h3 className="text-[16px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3>
<p className="mt-1.5 text-[12px] text-black/45 dark:text-white/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] dark:border-white/12 dark:text-white dark:hover:bg-white/[0.04]"
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 ? (
<div className="relative">
<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] dark:border-white/12 dark:text-white dark:hover:bg-white/[0.04]"
onClick={handleDingTalkLogin}
>
使用钉钉
</button>
{dingTalkOrganizations.length > 1 && showDingTalkOrgMenu ? (
<div className="absolute left-0 right-0 top-full z-30 mt-2 max-h-60 overflow-auto rounded-xl border border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#18181c]">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-[14px] text-black hover:bg-black/[0.04] dark:text-white dark:hover:bg-white/[0.04]"
onClick={() => startDingTalkLoginForOrg(org)}
>
<span>{org}</span>
</button>
))}
</div>
) : null}
</div>
) : null}
<div className="my-2 border-t border-black/10 dark:border-white/10" />
</div>
) : null}
<form
className="space-y-2.5"
onSubmit={(event) => {
event.preventDefault();
handleEmailLogin();
}}
>
<Input
value={loginEmail}
onChange={(event) => setLoginEmail(event.target.value)}
placeholder="电子邮箱"
type="email"
className="h-10 rounded-[9px] border-black/20 px-3 text-[16px] md:text-[13px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35"
/>
<Input
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
placeholder="密码"
type="password"
className="h-10 rounded-[9px] border-black/20 px-3 text-[16px] md:text-[13px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35"
/>
<Button
type="submit"
className="mt-1 h-10 w-full rounded-[9px] bg-black text-[13px] text-white transition-colors duration-150 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-2.5 text-center text-[12px] text-black/55 dark:text-white/55">
还没有账号?
<button
type="button"
className="ml-1 font-medium text-black hover:underline dark:text-white"
onClick={handleRegister}
>
立即注册
</button>
</div>
) : null}
<p className="mt-4 text-center text-xs text-black/40 dark:text-white/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 dark:border-white/10 dark:bg-[#101014]/95 dark:text-white">
<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 dark:border-white/8">
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "account" ? "text-black dark:text-white" : "text-black/35 dark:text-white/35"}`}
onClick={() => setAccountCenterTab("account")}
>
账户管理
</button>
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "usage" ? "text-black dark:text-white" : "text-black/35 dark:text-white/35"}`}
onClick={() => setAccountCenterTab("usage")}
>
用量
</button>
<button
type="button"
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "billing" ? "text-black dark:text-white" : "text-black/35 dark:text-white/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 dark:border-white/10 dark:bg-[#17171b]">
<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 dark:text-white">
{accountProfile.name}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/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 dark:border-white/12 dark:bg-[#111115] dark:text-white/75 dark:hover:bg-white/6"
onClick={() => {
void handleLogout();
}}
>
退出登录
</Button>
</div>
</div>
</div>
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3.5 dark:border-white/10 dark:bg-[#17171b]">
<div className="mb-2 flex items-center gap-4 text-[13px] font-semibold">
<span className="text-black dark:text-white">账户信息</span>
<span className="text-black/30 dark:text-white/30">设备管理</span>
</div>
<div className="grid grid-cols-[110px_1fr] gap-y-2 text-[12px]">
<div className="text-black/55 dark:text-white/50">当前订阅</div>
<div className="text-black dark:text-white">{accountProfile.planName}</div>
<div className="text-black/55 dark:text-white/50">可用积分</div>
<div className="text-black dark:text-white">{accountProfile.availablePoints.toLocaleString()}</div>
<div className="text-black/55 dark:text-white/50">每月更新积分</div>
<div className="text-black dark:text-white">{accountProfile.monthlyCredits.toLocaleString()}</div>
<div className="text-black/55 dark:text-white/50">每日免费积分</div>
<div className="text-black dark:text-white">{accountProfile.dailyFreeCredits.toLocaleString()}</div>
</div>
</div>
</div>
) : null}
{accountCenterTab === "usage" ? (
<div className="rounded-[16px] border border-black/10 bg-white dark:border-white/10 dark:bg-[#17171b]">
<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 dark:border-white/8 dark:text-white/75">
<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] dark:border-white/10 dark:bg-[#111115] dark:text-white">
<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] dark:bg-white/[0.03]" : ""}`}
>
<div className="truncate pr-3 text-black/80 dark:text-white/80">{record.detail}</div>
<div className="text-black/55 dark:text-white/50">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
<div className="text-black/65 dark:text-white/55">{formatDateTime(record.createdAt)}</div>
<div className={record.amount < 0 ? "text-black dark:text-white" : "text-[#0f8a3c] dark:text-[#48d98a]"}>
{record.amount > 0 ? `+${record.amount}` : record.amount}
</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35 dark:text-white/35">暂无数据</div>
)}
</div>
) : null}
{accountCenterTab === "billing" ? (
<div className="rounded-[16px] border border-black/10 bg-white dark:border-white/10 dark:bg-[#17171b]">
<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 dark:border-white/8 dark:text-white/75">
<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] dark:bg-white/[0.03]" : ""}`}
>
<div className="text-black/65 dark:text-white/55">{formatDateTime(record.date)}</div>
<div className="text-black/80 dark:text-white/80">{record.category}</div>
<div className="text-black/80 dark:text-white/80">{record.amount > 0 ? `+${record.amount}` : record.amount}</div>
<div className="text-black/55 dark:text-white/50">{record.status === "completed" ? "完成" : record.status === "pending" ? "处理中" : "失败"}</div>
<div className="text-black/35 dark:text-white/35">{record.invoice || "-"}</div>
</div>
))}
</div>
) : (
<div className="flex h-[340px] items-center justify-center text-black/35 dark:text-white/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 dark:border-white/10 dark:bg-[#101014]/95 dark:text-white">
<div className="flex h-full max-h-[90vh] flex-col">
<DialogHeader className="border-b border-black/6 px-5 py-4 dark:border-white/8">
<DialogTitle className="text-center text-3xl font-bold text-black dark:text-white">升级您的套餐</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] dark:bg-[#4b3b13] dark:text-[#f8d980]">
{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 dark:border-white/10 dark:bg-[#17171b]">
<div className="mb-2 flex items-center">
<h4 className="text-base font-semibold text-black dark:text-white">积分充值</h4>
</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 dark:border-white/10 dark:bg-[#111115]"
>
<div className="text-sm font-semibold text-black dark:text-white">{pkg.name}</div>
<div className="mt-1 text-xs text-black/55 dark:text-white/50">
{pkg.points.toLocaleString()} 积分
</div>
<div className="mt-2 text-xl font-bold text-black dark:text-white">
{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 dark:bg-[#17171b] ${
plan.highlighted
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)] dark:border-[#7d87ff] dark:shadow-[0_0_0_2px_rgba(125,135,255,0.35)]"
: "border-black/10 dark:border-white/10"
}`}
>
<div className="mb-2.5 flex items-center justify-between">
<h3 className="text-lg font-semibold text-black dark:text-white">{plan.name}</h3>
{plan.badge ? (
<span className="rounded-full bg-[#eef0ff] px-2 py-0.5 text-[10px] text-[#5b65db] dark:bg-[#26284a] dark:text-[#b9c0ff]">
{plan.badge}
</span>
) : null}
</div>
<div className="mb-2.5">
{plan.monthlyPrice <= 0 ? (
<div className="text-3xl font-bold text-black dark:text-white">免费</div>
) : billingCycle === "monthly" ? (
<div className="text-3xl font-bold text-black dark:text-white">
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
<span className="ml-1 text-xs font-normal text-black/45 dark:text-white/45">/月</span>
</div>
) : (
<div>
<div className="text-3xl font-bold text-black dark:text-white">
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
<span className="ml-1 text-xs font-normal text-black/45 dark:text-white/45">/年</span>
</div>
<div className="mt-1 text-[11px] text-black/45 dark:text-white/45">
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
</div>
</div>
)}
</div>
<div className="mb-3 text-xs text-black/60 dark:text-white/55">
每月 {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 dark:text-white/60">
{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 dark:bg-white/35" />
<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 dark:border-white/12 dark:bg-[#17171b] dark:text-white/40">
会员配置暂未启用,请在系统设置中完善会员 JSON 配置。
</div>
)}
<section className="mt-7">
<h4 className="mb-3 text-center text-2xl font-semibold text-black dark:text-white">常见问题</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 dark:border-white/10 dark:bg-[#17171b] dark:text-white/70"
>
<summary className="cursor-pointer list-none font-medium">{faq.question}</summary>
<p className="mt-2 text-black/60 dark:text-white/55">{faq.answer}</p>
</details>
))}
</div>
</section>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}