2667 lines
96 KiB
TypeScript
2667 lines
96 KiB
TypeScript
"use client";
|
||
|
||
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import { type CSSProperties, 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 { 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 = "让「𝗕𝘂𝗬𝗶」";
|
||
|
||
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 (
|
||
<DropdownMenuItem
|
||
onSelect={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>
|
||
</DropdownMenuItem>
|
||
);
|
||
}
|
||
|
||
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-[20px] w-[20px]";
|
||
|
||
const referenceIcons: Record<LovartHomeSidebarItemKey, ReferenceIconName> = {
|
||
newProject: "plus",
|
||
home: "home",
|
||
projects: "folder",
|
||
profile: "profile",
|
||
gallery: "brand",
|
||
tutorial: "tutorial",
|
||
imageEditor: "image-local-edit",
|
||
aiCad: "model-auto",
|
||
model3d: "model-auto",
|
||
scene: "layers",
|
||
agent: "skill",
|
||
videoCanvas: "video",
|
||
flowBoard: "connections",
|
||
productPlanning: "page",
|
||
toolbox1: "toolbox",
|
||
toolbox2: "toolbox",
|
||
settings: "settings",
|
||
};
|
||
const reference = referenceIcons[key];
|
||
if (reference && !item.iconUrl) {
|
||
return <ReferenceIcon name={reference} size={20} className={options?.filled ? "text-white" : undefined} />;
|
||
}
|
||
|
||
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;
|
||
|
||
const homeThemeControl = () => (
|
||
<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="z-[160] !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>
|
||
);
|
||
const homeUtilityControls = () => (
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label="通知"
|
||
title="通知"
|
||
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>
|
||
{homeThemeControl()}
|
||
</>
|
||
);
|
||
const homeAccountControls = () => (
|
||
<div className="flex w-full flex-col items-center gap-[12px]">
|
||
{isMembershipEnabled ? (
|
||
<HoverCard openDelay={100} closeDelay={120}>
|
||
<HoverCardTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
aria-label="积分与会员"
|
||
className="h-auto w-[36px] rounded-[8px] px-0 py-2 text-[#333] hover:bg-black/5 dark:text-white/75 dark:hover:bg-white/10"
|
||
>
|
||
<div className="flex flex-col items-center gap-[4px] text-[10px] leading-none">
|
||
<Zap className="h-[20px] w-[20px]" />
|
||
{membershipData?.summary?.currentCredits ?? 0}
|
||
</div>
|
||
</Button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
side="right"
|
||
align="end"
|
||
sideOffset={12}
|
||
className="z-[150] 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}
|
||
|
||
{isAuthenticated ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white"
|
||
aria-label="账号菜单"
|
||
title="账号菜单"
|
||
>
|
||
{userInitials}
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
side="right"
|
||
align="end"
|
||
sideOffset={12}
|
||
className="z-[150] max-h-[calc(100svh-24px)] w-[300px] overflow-y-auto 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 ? (
|
||
<DropdownMenuItem asChild>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
>
|
||
升级
|
||
</Button>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||
<DropdownMenuItem asChild>
|
||
<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>
|
||
</DropdownMenuItem>
|
||
</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)}
|
||
/>
|
||
<div className="flex items-center justify-between border-t border-black/10 px-2 py-2 dark:border-white/10">
|
||
<span className="text-[13px] text-black/65 dark:text-white/65">
|
||
通知与主题
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
{homeUtilityControls()}
|
||
</div>
|
||
</div>
|
||
<AccountMenuItem
|
||
icon={LogOut}
|
||
label="退出登录"
|
||
onClick={() => {
|
||
void handleLogout();
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : (
|
||
<>
|
||
{homeThemeControl()}
|
||
<button
|
||
type="button"
|
||
className="flex h-[32px] w-[36px] items-center justify-center rounded-full border border-black/15 bg-white text-[11px] 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>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="lovart-home-surface 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}
|
||
<div className="fixed bottom-5 left-4 z-[100] flex w-[44px] items-center rounded-full bg-[#f5f5f5] py-2 shadow-sm lg:hidden dark:bg-[#141416]">
|
||
{homeAccountControls()}
|
||
</div>
|
||
|
||
<LovartSidebar
|
||
accountControls={homeAccountControls()}
|
||
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
|
||
style={
|
||
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
|
||
}
|
||
className={cn(
|
||
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
|
||
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
|
||
)}
|
||
>
|
||
{!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}
|
||
onOpenGallery={() => featuredSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}
|
||
/>
|
||
)}
|
||
|
||
|
||
{!isProjectsOnlyView && (
|
||
<LovartFeaturedGallery
|
||
onOpenProjects={openAllProjectsInPage}
|
||
onOpenMaterials={handleOpenImageEditor}
|
||
activeCategory={activeCategory}
|
||
categories={discoveryTabs}
|
||
featuredItems={featuredItems}
|
||
featuredLoadMoreRef={featuredLoadMoreRef}
|
||
featuredSectionRef={featuredSectionRef}
|
||
isLoadingFeatured={isLoadingFeatured}
|
||
isLoadingMoreFeatured={isLoadingMoreFeatured}
|
||
setActiveCategory={setActiveCategory}
|
||
/>
|
||
)}
|
||
|
||
{(isProjectsOnlyView || recentProjects.length > 0) && (
|
||
<LovartProjectsSection
|
||
deletingProjectId={deletingProjectId}
|
||
hasMoreProjects={hasMoreProjects}
|
||
isProjectsOnlyView={isProjectsOnlyView}
|
||
onCreateProject={openNewCanvas}
|
||
onDeleteProject={handleDeleteProject}
|
||
onOpenProject={openCanvasProject}
|
||
onPrepareCreate={preloadCanvasWorkspace}
|
||
onPrepareProject={prepareCanvasProject}
|
||
onViewAllProjects={openAllProjectsInPage}
|
||
projects={recentProjects}
|
||
projectSectionRef={projectSectionRef}
|
||
/>
|
||
)}
|
||
</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-[9998] 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>
|
||
);
|
||
}
|