2986 lines
117 KiB
TypeScript
2986 lines
117 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { signIn, signOut } from "next-auth/react";
|
||
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,
|
||
Heart,
|
||
Home,
|
||
ImageIcon,
|
||
Info,
|
||
Loader2,
|
||
LogOut,
|
||
Mail,
|
||
Monitor,
|
||
Moon,
|
||
Palette,
|
||
Plus,
|
||
PlayCircle,
|
||
Settings,
|
||
Sparkles,
|
||
Star,
|
||
Sun,
|
||
Trash2,
|
||
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,
|
||
DropdownMenuLabel,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { cn, fetcher } from "@/lib/utils";
|
||
import {
|
||
type CanvasLaunchAttachment,
|
||
getCanvasLaunchStorageKey,
|
||
} from "@/lib/canvas-launch";
|
||
import type { MembershipConfig } from "@/lib/membership/config";
|
||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import {
|
||
normalizeLovartHomeSidebarConfig,
|
||
type LovartHomeSidebarItemKey,
|
||
} from "@/lib/home/lovart-sidebar-config";
|
||
import { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||
|
||
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
|
||
|
||
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 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,
|
||
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 MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
|
||
const FEATURED_PAGE_SIZE = 24;
|
||
const COMPOSER_PREFIX_DEFAULT = "让Buyi-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 formatRelativeTime(value?: string) {
|
||
if (!value) return "更新于今天";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "更新于今天";
|
||
const diffMs = Date.now() - date.getTime();
|
||
const minute = 60 * 1000;
|
||
const hour = 60 * minute;
|
||
const day = 24 * hour;
|
||
if (diffMs < hour) return `更新于 ${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
|
||
if (diffMs < day) return `更新于 ${Math.round(diffMs / hour)} 小时前`;
|
||
return `更新于 ${Math.round(diffMs / day)} 天前`;
|
||
}
|
||
|
||
function formatPrice(currency: string, value: number) {
|
||
if (value <= 0) return "免费";
|
||
return `${currency}${value}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "-";
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function AccountMenuItem({
|
||
icon: Icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
icon: typeof User;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 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 = null,
|
||
isAuthenticated = 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 sidebarPopupBaseClass =
|
||
"rounded-[24px] border border-black/8 bg-white/96 p-2.5 text-[#111827] shadow-[0_24px_64px_rgba(15,23,42,0.12)] backdrop-blur-xl";
|
||
const lovartSidebarButtonClass = (active = false) =>
|
||
cn(
|
||
"group flex h-10 w-10 items-center justify-center rounded-xl text-[#6b7280] transition-all duration-200 hover:bg-black/[0.04] hover:text-[#111827]",
|
||
active &&
|
||
"bg-[#111827] text-white shadow-[0_10px_22px_rgba(15,23,42,0.18)]"
|
||
);
|
||
const [prompt, setPrompt] = useState("");
|
||
const [files, setFiles] = useState<UploadFileItem[]>([]);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [activeCategory, setActiveCategory] = useState("全部");
|
||
const [showAllProjects, setShowAllProjects] = useState(false);
|
||
const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false);
|
||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
|
||
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
|
||
"account"
|
||
);
|
||
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
|
||
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
|
||
const [creatingPaymentPackageId, setCreatingPaymentPackageId] = useState<string | null>(null);
|
||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
});
|
||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||
const [loginEmail, setLoginEmail] = useState("");
|
||
const [loginPassword, setLoginPassword] = useState("");
|
||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||
const [showDingTalkOrgMenu, setShowDingTalkOrgMenu] = useState(false);
|
||
const [showImageEditor, setShowImageEditor] = useState(false);
|
||
const [editorImageUrl, setEditorImageUrl] = useState("");
|
||
const [ImageAnnotatorLazy, setImageAnnotatorLazy] =
|
||
useState<ImageAnnotatorComponent | null>(null);
|
||
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 [isSecondaryDataReady, setIsSecondaryDataReady] = useState(false);
|
||
const [isFeaturedDataReady, setIsFeaturedDataReady] = 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(() => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const loadSecondaryData = () => setIsSecondaryDataReady(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(loadSecondaryData, {
|
||
timeout: 1200,
|
||
});
|
||
return () => idleWindow.cancelIdleCallback?.(idleId);
|
||
}
|
||
|
||
const timer = window.setTimeout(loadSecondaryData, 550);
|
||
return () => window.clearTimeout(timer);
|
||
}, []);
|
||
|
||
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]);
|
||
|
||
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;
|
||
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 heroTitleText =
|
||
siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText;
|
||
const heroSubtitleText =
|
||
siteSettingsData?.lovartHeroSubtitle ?? initialHeroSubtitleText;
|
||
const surpriseCategories =
|
||
siteSettingsData?.surpriseCategories ?? initialSurpriseCategories;
|
||
const displayLogo =
|
||
resolvedTheme === "dark"
|
||
? siteLogoDark || siteLogo || ""
|
||
: siteLogo || siteLogoDark || "";
|
||
const shouldInvertLogo =
|
||
resolvedTheme === "dark" && !siteLogoDark && Boolean(siteLogo);
|
||
|
||
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 ? "/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 composerModelValue = "auto";
|
||
const triggerComposerModel = autoComposerModel;
|
||
const isMembershipEnabled = siteSettingsData?.membershipConfig?.enabled !== false;
|
||
|
||
const discoveryTabs = useMemo(() => {
|
||
const configured = (surpriseCategories || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
// legacy typo compatibility: treat "惊喜" as "精选"
|
||
.map((item) => (item === "惊喜" ? "精选" : item));
|
||
|
||
const uniqueConfigured = Array.from(new Set(configured));
|
||
const hasRealConfig =
|
||
uniqueConfigured.length > 0 &&
|
||
!(uniqueConfigured.length === 1 && uniqueConfigured[0] === "全部");
|
||
|
||
const fallback = ["全部", "精选", ...DISCOVERY_DEFAULT_TABS];
|
||
const base = hasRealConfig ? uniqueConfigured : fallback;
|
||
|
||
const withoutAll = base.filter((item) => item !== "全部");
|
||
const merged = ["全部", ...withoutAll];
|
||
|
||
return Array.from(new Set(merged)).slice(0, 12);
|
||
}, [surpriseCategories]);
|
||
|
||
const composerPreviewTexts = useMemo(() => {
|
||
const configured = (siteSettingsData?.lovartComposerPreviewTexts || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
|
||
if (configured.length > 0) {
|
||
return configured;
|
||
}
|
||
|
||
return [
|
||
"打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
];
|
||
}, [siteSettingsData?.lovartComposerPreviewTexts]);
|
||
|
||
const 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 lovartPromptTagVisibility = useMemo<LovartPromptTagVisibility>(() => {
|
||
const raw = siteSettingsData?.lovartPromptTagVisibility || {};
|
||
return {
|
||
nanoBananaPro: raw.nanoBananaPro !== false,
|
||
design: raw.design !== false,
|
||
branding: raw.branding !== false,
|
||
illustration: raw.illustration !== false,
|
||
ecommerce: raw.ecommerce !== false,
|
||
video: raw.video !== false,
|
||
};
|
||
}, [siteSettingsData?.lovartPromptTagVisibility]);
|
||
|
||
const visiblePromptTags = useMemo(
|
||
() => PROMPT_TAGS.filter((tag) => lovartPromptTagVisibility[tag.key]),
|
||
[lovartPromptTagVisibility]
|
||
);
|
||
|
||
const contactConfig = useMemo(
|
||
() => ({
|
||
phone: String(siteSettingsData?.contactConfig?.phone || "").trim(),
|
||
wechatQrUrl: String(siteSettingsData?.contactConfig?.wechatQrUrl || "").trim(),
|
||
}),
|
||
[siteSettingsData?.contactConfig?.phone, siteSettingsData?.contactConfig?.wechatQrUrl]
|
||
);
|
||
|
||
const loadedFeaturedImages = useMemo(() => {
|
||
return (featuredPages || []).flatMap((page) => page.images || []);
|
||
}, [featuredPages]);
|
||
|
||
const featuredPagination = featuredPages?.[featuredPages.length - 1]?.pagination;
|
||
const hasMoreFeatured = Boolean(
|
||
featuredPagination && featuredPagination.page < featuredPagination.totalPages
|
||
);
|
||
const isLoadingFeatured = !featuredPages && isFeaturedValidating;
|
||
const isLoadingMoreFeatured =
|
||
isFeaturedValidating &&
|
||
!!featuredPages &&
|
||
featuredPageCount > 0 &&
|
||
featuredPageCount > featuredPages.length;
|
||
|
||
const featuredItems = useMemo(() => {
|
||
const images = loadedFeaturedImages;
|
||
if (activeCategory === "全部") {
|
||
return images;
|
||
}
|
||
return images.filter((item) =>
|
||
item.featuredCategory
|
||
? String(item.featuredCategory) === activeCategory
|
||
: String(item.prompt || "").toLowerCase().includes(activeCategory.toLowerCase())
|
||
);
|
||
}, [loadedFeaturedImages, activeCategory]);
|
||
|
||
const shouldShowAllProjects = isProjectsOnlyView || showAllProjects;
|
||
|
||
const recentProjects = useMemo(() => {
|
||
const projects = canvasData?.data || [];
|
||
return shouldShowAllProjects ? projects : projects.slice(0, 4);
|
||
}, [canvasData?.data, shouldShowAllProjects]);
|
||
|
||
const hasMoreProjects = useMemo(() => {
|
||
return (canvasData?.data || []).length > 4;
|
||
}, [canvasData?.data]);
|
||
|
||
const filteredUsageRecords = useMemo(() => {
|
||
const records = accountCenterData?.usageRecords || [];
|
||
if (usageFilter === "all") return records;
|
||
return records.filter((item) => item.status === usageFilter);
|
||
}, [accountCenterData?.usageRecords, usageFilter]);
|
||
|
||
const enabledPointsPackages = useMemo(() => {
|
||
return (siteSettingsData?.pointsPackages || []).filter(
|
||
(item) => item?.isEnabled !== false && item.price > 0 && item.points > 0
|
||
);
|
||
}, [siteSettingsData?.pointsPackages]);
|
||
|
||
const toolbox1Links = useMemo(
|
||
() =>
|
||
(navLinksData?.links || [])
|
||
.filter((link) => (link.toolboxGroup || 1) === 1 && link.isEnabled !== false)
|
||
.sort((a, b) => a.order - b.order),
|
||
[navLinksData?.links]
|
||
);
|
||
|
||
const toolbox2Links = useMemo(
|
||
() =>
|
||
(navLinksData?.links || [])
|
||
.filter((link) => link.toolboxGroup === 2 && link.isEnabled !== false)
|
||
.sort((a, b) => a.order - b.order),
|
||
[navLinksData?.links]
|
||
);
|
||
const tutorialVideos = useMemo(() => {
|
||
const configured = (tutorialVideosData?.videos || [])
|
||
.filter((video) => video.isEnabled !== false && Boolean(video.videoUrl))
|
||
.sort((a, b) => a.order - b.order);
|
||
if (configured.length > 0) {
|
||
return configured;
|
||
}
|
||
|
||
const fallbackUrl = String(siteSettingsData?.tutorialVideoUrl || "").trim();
|
||
if (!fallbackUrl) {
|
||
return [] as TutorialVideo[];
|
||
}
|
||
|
||
return [
|
||
{
|
||
id: "fallback-tutorial",
|
||
title: "功能教程",
|
||
videoUrl: fallbackUrl,
|
||
coverUrl: null,
|
||
description: "默认教程视频",
|
||
order: 0,
|
||
isEnabled: true,
|
||
},
|
||
];
|
||
}, [siteSettingsData?.tutorialVideoUrl, tutorialVideosData?.videos]);
|
||
|
||
const canUseGoogleLogin = Boolean(
|
||
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
|
||
);
|
||
const 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 {
|
||
await signOut({
|
||
callbackUrl: "/",
|
||
redirect: false,
|
||
});
|
||
} catch (_error) {}
|
||
|
||
window.location.replace("/");
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return;
|
||
setLoginDialogOpen(false);
|
||
}, [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);
|
||
}
|
||
}, []);
|
||
|
||
const handleImageParamsChange = (nextParams: ImageGenerationParams) => {
|
||
setImageParams(nextParams);
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
localStorage.setItem("lovart-home-image-params", JSON.stringify(nextParams));
|
||
} catch (error) {
|
||
console.error("[LovartHome] Failed to save image params:", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!featuredLoadMoreRef.current || !hasMoreFeatured) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries[0]?.isIntersecting || isLoadingMoreFeatured) return;
|
||
setFeaturedPageCount((current) => current + 1);
|
||
},
|
||
{ rootMargin: "1000px 0px 1200px 0px" }
|
||
);
|
||
|
||
observer.observe(featuredLoadMoreRef.current);
|
||
|
||
return () => observer.disconnect();
|
||
}, [hasMoreFeatured, isLoadingMoreFeatured, setFeaturedPageCount]);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
if (composerPreviewTails.length === 0) {
|
||
setTypedPreviewText("");
|
||
return () => {
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}
|
||
|
||
let textIndex = 0;
|
||
let charIndex = 0;
|
||
|
||
const typeForward = () => {
|
||
if (disposed) return;
|
||
const currentText = composerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex < currentText.length) {
|
||
charIndex += 1;
|
||
setTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeForward, 55);
|
||
return;
|
||
}
|
||
|
||
if (composerPreviewTails.length === 1) {
|
||
setTypedPreviewText(currentText);
|
||
return;
|
||
}
|
||
|
||
timer = setTimeout(typeBackward, 1200);
|
||
};
|
||
|
||
const typeBackward = () => {
|
||
if (disposed) return;
|
||
const currentText = composerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex > 0) {
|
||
charIndex -= 1;
|
||
setTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeBackward, 35);
|
||
return;
|
||
}
|
||
|
||
textIndex = (textIndex + 1) % composerPreviewTails.length;
|
||
timer = setTimeout(typeForward, 260);
|
||
};
|
||
|
||
setTypedPreviewText("");
|
||
timer = setTimeout(typeForward, 260);
|
||
|
||
return () => {
|
||
disposed = true;
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}, [composerPreviewTails]);
|
||
|
||
useEffect(() => {
|
||
const blinkTimer = setInterval(() => {
|
||
setPreviewCursorVisible((value) => !value);
|
||
}, 450);
|
||
|
||
return () => clearInterval(blinkTimer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
filesRef.current = files;
|
||
}, [files]);
|
||
|
||
useEffect(() => {
|
||
if (!showImageEditor || ImageAnnotatorLazy) {
|
||
return;
|
||
}
|
||
|
||
let disposed = false;
|
||
void import("@/components/image-annotator").then((module) => {
|
||
if (!disposed) {
|
||
setImageAnnotatorLazy(() => module.ImageAnnotator);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [ImageAnnotatorLazy, showImageEditor]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
filesRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||
};
|
||
}, []);
|
||
|
||
const handleAddFiles = (targetFiles: FileList | null) => {
|
||
if (!targetFiles || targetFiles.length === 0) return;
|
||
|
||
const accepted = Array.from(targetFiles)
|
||
.filter((file) => file.type.startsWith("image/"))
|
||
.slice(0, 6);
|
||
|
||
if (accepted.length === 0) {
|
||
toast.error("请上传图片文件");
|
||
return;
|
||
}
|
||
|
||
const next = accepted.map((file) => ({
|
||
id: `${file.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
file,
|
||
previewUrl: URL.createObjectURL(file),
|
||
}));
|
||
|
||
setFiles((prev) => [...prev, ...next].slice(0, 6));
|
||
};
|
||
|
||
const removeFile = (id: string) => {
|
||
setFiles((prev) => {
|
||
const target = prev.find((item) => item.id === id);
|
||
if (target) {
|
||
URL.revokeObjectURL(target.previewUrl);
|
||
}
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
};
|
||
|
||
const uploadSelectedFiles = async (): Promise<CanvasLaunchAttachment[]> => {
|
||
const result: CanvasLaunchAttachment[] = [];
|
||
|
||
for (const item of files) {
|
||
const formData = new FormData();
|
||
formData.append("file", item.file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
const data = await response.json();
|
||
if (!response.ok || !data?.url) {
|
||
throw new Error(data?.error || "上传失败");
|
||
}
|
||
|
||
result.push({
|
||
url: data.url,
|
||
name: item.file.name,
|
||
contentType: item.file.type || "image/jpeg",
|
||
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
const openLoginDialog = () => {
|
||
setLoginDialogOpen(true);
|
||
};
|
||
|
||
const handleEmailLogin = async () => {
|
||
const email = loginEmail.trim().toLowerCase();
|
||
if (!email || !loginPassword) {
|
||
toast.error("请输入邮箱和密码");
|
||
return;
|
||
}
|
||
|
||
setIsLoggingIn(true);
|
||
try {
|
||
const result = await signIn("credentials", {
|
||
email,
|
||
password: loginPassword,
|
||
redirect: false,
|
||
});
|
||
|
||
if (!result || result.error || result.ok === false) {
|
||
toast.error("登录失败,请检查账号密码");
|
||
return;
|
||
}
|
||
|
||
window.location.href = "/";
|
||
} catch (_error) {
|
||
toast.error("登录失败,请稍后重试");
|
||
} finally {
|
||
setIsLoggingIn(false);
|
||
}
|
||
};
|
||
|
||
const handleGoogleLogin = async () => {
|
||
if (!canUseGoogleLogin) {
|
||
toast.error("Google 登录未启用");
|
||
return;
|
||
}
|
||
await signIn("google", { callbackUrl: "/" });
|
||
};
|
||
|
||
const 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 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;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const uploadedAttachments = await uploadSelectedFiles();
|
||
const title = prompt.trim()
|
||
? prompt.trim().slice(0, 24)
|
||
: `新建项目 ${new Date().toLocaleDateString("zh-CN")}`;
|
||
|
||
const canvasResponse = await fetch("/api/canvas", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
const canvasResult = await canvasResponse.json();
|
||
|
||
if (!canvasResponse.ok || !canvasResult?.data?.id) {
|
||
throw new Error(canvasResult?.error || "创建项目失败");
|
||
}
|
||
|
||
const canvasId: string = canvasResult.data.id;
|
||
|
||
if (typeof window !== "undefined") {
|
||
sessionStorage.setItem(
|
||
getCanvasLaunchStorageKey(canvasId),
|
||
JSON.stringify({
|
||
prompt:
|
||
prompt.trim() ||
|
||
(uploadedAttachments.length > 0
|
||
? "请基于这些图片继续生成设计方案"
|
||
: ""),
|
||
attachments: uploadedAttachments,
|
||
autoSend: true,
|
||
createdAt: Date.now(),
|
||
selectedModelId: "auto",
|
||
imageParams,
|
||
})
|
||
);
|
||
}
|
||
|
||
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||
setFiles([]);
|
||
setPrompt("");
|
||
await mutateCanvas();
|
||
router.push(`/canvas/${canvasId}`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建项目失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const openNewCanvas = () => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
router.push("/canvas?new=1");
|
||
};
|
||
|
||
const handleDeleteProject = async (projectId: string, projectTitle?: string) => {
|
||
if (deletingProjectId === projectId) return;
|
||
|
||
const normalizedTitle = String(projectTitle || "").trim() || "未命名画布";
|
||
const confirmed = window.confirm(`确定删除「${normalizedTitle}」吗?删除后不可恢复。`);
|
||
if (!confirmed) return;
|
||
|
||
setDeletingProjectId(projectId);
|
||
try {
|
||
const response = await fetch(`/api/canvas/${projectId}`, {
|
||
method: "DELETE",
|
||
});
|
||
const result = await response.json().catch(() => null);
|
||
if (!response.ok || result?.success === false) {
|
||
throw new Error(result?.error || "删除画布失败");
|
||
}
|
||
|
||
await mutateCanvas((current) => {
|
||
if (!current) return current;
|
||
return {
|
||
...current,
|
||
data: (current.data || []).filter((project) => project.id !== projectId),
|
||
};
|
||
}, false);
|
||
toast.success("画布已删除");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "删除画布失败");
|
||
await mutateCanvas();
|
||
} finally {
|
||
setDeletingProjectId(null);
|
||
}
|
||
};
|
||
|
||
const openAllProjectsInPage = () => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
setIsProjectsOnlyView(true);
|
||
setShowAllProjects(true);
|
||
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const openNavLink = (url: string) => {
|
||
const value = String(url || "").trim();
|
||
if (!value) return;
|
||
|
||
if (/^https?:\/\//i.test(value)) {
|
||
window.open(value, "_blank", "noopener,noreferrer");
|
||
return;
|
||
}
|
||
|
||
if (value.startsWith("/")) {
|
||
router.push(value);
|
||
return;
|
||
}
|
||
|
||
window.open(`https://${value}`, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
const 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 invert opacity-100" : "brightness-0 opacity-60"
|
||
)}
|
||
fallbackClassName={cn(
|
||
sizeClassName,
|
||
options?.active ? "text-white" : "text-[#6b7280]"
|
||
)}
|
||
/>
|
||
);
|
||
};
|
||
|
||
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 =
|
||
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 bg-[#f4f4f4] 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">
|
||
<BrandTopBar
|
||
logo={displayLogo}
|
||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||
shouldInvertLogo={shouldInvertLogo}
|
||
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] bg-[#1c1c1e] px-2.5 text-[14px] text-white hover:bg-black hover:text-white"
|
||
>
|
||
<span className="mr-1.5">升级</span>
|
||
<div className="inline-flex items-center gap-1 rounded-[9px] bg-white/10 px-1.5 py-0.5 text-xs text-white">
|
||
<Zap className="h-3 w-3 text-amber-300" />
|
||
{membershipData?.summary?.currentCredits ?? 0}
|
||
</div>
|
||
</Button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
align="end"
|
||
sideOffset={10}
|
||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl 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 items-center gap-1.5 rounded-[14px] border border-black/10 bg-white px-2.5 text-[12px] font-medium 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-3.5 w-3.5" />
|
||
<span className="hidden sm:inline">{selectedThemeOption.label}</span>
|
||
<ChevronDown className="h-3.5 w-3.5 opacity-55" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
sideOffset={10}
|
||
className="w-[220px] rounded-2xl border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||
>
|
||
<DropdownMenuLabel className="px-3 py-2 text-[12px] text-black/45 dark:text-white/45">
|
||
个人主题模式
|
||
</DropdownMenuLabel>
|
||
{THEME_MODE_OPTIONS.map((option) => {
|
||
const OptionIcon = option.icon;
|
||
const isSelected = selectedThemeMode === option.value;
|
||
|
||
return (
|
||
<DropdownMenuItem
|
||
key={option.value}
|
||
className={cn(
|
||
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-left 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/78 dark:text-white/78"
|
||
)}
|
||
onSelect={() => setTheme(option.value)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"flex h-8 w-8 items-center justify-center rounded-xl",
|
||
isSelected
|
||
? "bg-white/14 text-white dark:bg-black/8 dark:text-black"
|
||
: "bg-black/5 text-black/65 dark:bg-white/8 dark:text-white/65"
|
||
)}
|
||
>
|
||
<OptionIcon className="h-4 w-4" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-[13px] font-semibold leading-4">
|
||
{option.label}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 block text-[11px] leading-4",
|
||
isSelected
|
||
? "text-white/70 dark:text-black/60"
|
||
: "text-black/45 dark:text-white/42"
|
||
)}
|
||
>
|
||
{option.description}
|
||
</span>
|
||
</span>
|
||
</DropdownMenuItem>
|
||
);
|
||
})}
|
||
</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>
|
||
|
||
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex">
|
||
<TooltipProvider delayDuration={120}>
|
||
{isLovartSidebarItemEnabled("newProject") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
onClick={openNewCanvas}
|
||
className="flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[20px] border border-black/8 bg-white/96 shadow-[0_20px_46px_rgba(15,23,42,0.12)] backdrop-blur-xl transition-transform duration-200 hover:-translate-y-0.5 hover:bg-white"
|
||
title={getLovartSidebarItem("newProject").label}
|
||
>
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-[12px] bg-[#111827] text-white shadow-[0_10px_20px_rgba(15,23,42,0.18)]">
|
||
{renderLovartSidebarIcon("newProject", {
|
||
filled: true,
|
||
sizeClassName: "h-4 w-4",
|
||
})}
|
||
</div>
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("newProject").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]" tone="light">
|
||
{isLovartSidebarItemEnabled("home") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass(!isProjectsOnlyView)}
|
||
onClick={() => {
|
||
setIsProjectsOnlyView(false);
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}}
|
||
title={getLovartSidebarItem("home").label}
|
||
>
|
||
{renderLovartSidebarIcon("home", {
|
||
active: !isProjectsOnlyView,
|
||
})}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("home").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("projects") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass(isProjectsOnlyView)}
|
||
onClick={openAllProjectsInPage}
|
||
title={getLovartSidebarItem("projects").label}
|
||
>
|
||
{renderLovartSidebarIcon("projects", {
|
||
active: isProjectsOnlyView,
|
||
})}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("projects").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("profile") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => toast.info("个人主页模块后续开放")}
|
||
title={getLovartSidebarItem("profile").label}
|
||
>
|
||
{renderLovartSidebarIcon("profile")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("profile").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("gallery") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/gallery")}
|
||
title={getLovartSidebarItem("gallery").label}
|
||
>
|
||
{renderLovartSidebarIcon("gallery")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("gallery").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("tutorial") &&
|
||
siteSettingsData?.tutorialEnabled ? (
|
||
<HoverCard openDelay={80} closeDelay={100}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
title={getLovartSidebarItem("tutorial").label}
|
||
>
|
||
{renderLovartSidebarIcon("tutorial")}
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
side="right"
|
||
align="start"
|
||
className={`w-[300px] ${sidebarPopupBaseClass}`}
|
||
>
|
||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
|
||
{getLovartSidebarItem("tutorial").label}
|
||
</div>
|
||
<div className="max-h-[380px] space-y-1 overflow-y-auto">
|
||
{tutorialVideos.length > 0 ? (
|
||
tutorialVideos.map((video) => (
|
||
<button
|
||
key={video.id}
|
||
type="button"
|
||
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04]"
|
||
onClick={() => handleOpenTutorial(video)}
|
||
>
|
||
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04]">
|
||
{video.coverUrl ? (
|
||
<img
|
||
src={video.coverUrl}
|
||
alt={video.title}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center">
|
||
<PlayCircle className="h-4 w-4 text-black/35" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[13px] font-medium text-black/85">
|
||
{video.title}
|
||
</div>
|
||
{video.description ? (
|
||
<div className="truncate text-[11px] text-black/42">
|
||
{video.description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="px-2 py-3 text-[12px] text-black/42">暂无教程视频</div>
|
||
)}
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("imageEditor") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={handleOpenImageEditor}
|
||
title={getLovartSidebarItem("imageEditor").label}
|
||
>
|
||
{renderLovartSidebarIcon("imageEditor")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("imageEditor").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("aiCad") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/ai-cad")}
|
||
title={getLovartSidebarItem("aiCad").label}
|
||
>
|
||
{renderLovartSidebarIcon("aiCad")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("aiCad").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("agent") &&
|
||
siteSettingsData?.agentEnabled !== false ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/canvas?mode=agent")}
|
||
title={getLovartSidebarItem("agent").label}
|
||
>
|
||
{renderLovartSidebarIcon("agent")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("agent").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("videoCanvas") &&
|
||
siteSettingsData?.videoCanvasEnabled !== false ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/video-canvas")}
|
||
title={getLovartSidebarItem("videoCanvas").label}
|
||
>
|
||
{renderLovartSidebarIcon("videoCanvas")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("videoCanvas").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("flowBoard") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/flow-board")}
|
||
title={getLovartSidebarItem("flowBoard").label}
|
||
>
|
||
{renderLovartSidebarIcon("flowBoard")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("flowBoard").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("productPlanning") &&
|
||
siteSettingsData?.productPlanningEnabled !== false ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() => router.push("/product-planning")}
|
||
title={getLovartSidebarItem("productPlanning").label}
|
||
>
|
||
{renderLovartSidebarIcon("productPlanning")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("productPlanning").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("toolbox1") &&
|
||
siteSettingsData?.toolbox1Enabled !== false &&
|
||
toolbox1Links.length > 0 ? (
|
||
<HoverCard openDelay={80} closeDelay={80}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
title={getLovartSidebarItem("toolbox1").label}
|
||
>
|
||
{renderLovartSidebarIcon("toolbox1")}
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
side="right"
|
||
align="start"
|
||
className={`w-[248px] ${sidebarPopupBaseClass}`}
|
||
>
|
||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
|
||
{getLovartSidebarItem("toolbox1").label}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{toolbox1Links.map((link) => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
|
||
onClick={() => openNavLink(link.url)}
|
||
>
|
||
<span className="line-clamp-1">{link.name}</span>
|
||
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("toolbox2") &&
|
||
siteSettingsData?.toolbox2Enabled === true &&
|
||
toolbox2Links.length > 0 ? (
|
||
<HoverCard openDelay={80} closeDelay={80}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
title={getLovartSidebarItem("toolbox2").label}
|
||
>
|
||
{renderLovartSidebarIcon("toolbox2")}
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
side="right"
|
||
align="start"
|
||
className={`w-[248px] ${sidebarPopupBaseClass}`}
|
||
>
|
||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/40">
|
||
{getLovartSidebarItem("toolbox2").label}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{toolbox2Links.map((link) => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04]"
|
||
onClick={() => openNavLink(link.url)}
|
||
>
|
||
<span className="line-clamp-1">{link.name}</span>
|
||
<ArrowRight className="h-3.5 w-3.5 text-black/30" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : null}
|
||
{isLovartSidebarItemEnabled("settings") &&
|
||
(user?.type === "admin" || user?.type === "agent_admin") ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={lovartSidebarButtonClass()}
|
||
onClick={() =>
|
||
router.push(user?.type === "agent_admin" ? "/settings/agent-workflow" : "/settings")
|
||
}
|
||
title={getLovartSidebarItem("settings").label}
|
||
>
|
||
{renderLovartSidebarIcon("settings")}
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{getLovartSidebarItem("settings").label}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
) : null}
|
||
</FloatingSidebarRail>
|
||
</TooltipProvider>
|
||
</aside>
|
||
|
||
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
|
||
{!isProjectsOnlyView && (
|
||
<section className="mt-8 text-center">
|
||
{isMembershipEnabled ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]"
|
||
>
|
||
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||
</button>
|
||
) : (
|
||
<div className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80 dark:bg-[#3f3013] dark:text-[#f6dfaa]">
|
||
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-8 flex items-center justify-center gap-2">
|
||
{displayLogo ? (
|
||
<img
|
||
src={displayLogo}
|
||
alt={siteName}
|
||
className={`h-10 w-10 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||
/>
|
||
) : (
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
<h1 className="text-[34px] font-semibold tracking-tight dark:text-white">
|
||
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
|
||
</h1>
|
||
</div>
|
||
<p className="mt-2 text-[13px] text-black/35 dark:text-white/45">
|
||
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
|
||
</p>
|
||
|
||
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
|
||
<Textarea
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
placeholder={composerPlaceholder}
|
||
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0 dark:text-white dark:placeholder:text-white/35"
|
||
/>
|
||
|
||
{files.length > 0 && (
|
||
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
||
{files.map((item) => (
|
||
<div key={item.id} className="group relative h-12 w-12 overflow-hidden rounded-lg border border-black/10 dark:border-white/10">
|
||
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
|
||
<button
|
||
type="button"
|
||
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-xs text-white group-hover:flex"
|
||
onClick={() => removeFile(item.id)}
|
||
>
|
||
x
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between border-t border-black/8 pt-3 dark:border-white/8">
|
||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5 dark:border-white/12 dark:text-white/60 dark:hover:bg-white/8">
|
||
<Upload className="h-4 w-4" />
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
handleAddFiles(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
<div className="inline-flex h-8 min-w-[130px] items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600 dark:border-sky-500/20 dark:bg-sky-500/12 dark:text-sky-200">
|
||
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white dark:border-sky-500/30 dark:bg-[#0d1117]">
|
||
{triggerComposerModel?.avatarUrl ? (
|
||
<img
|
||
src={triggerComposerModel.avatarUrl}
|
||
alt={triggerComposerModel.displayName || "模型头像"}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
|
||
{(triggerComposerModel?.displayName || "A").slice(0, 1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="max-w-[95px] truncate">
|
||
{triggerComposerModel?.displayName || "Agent模式"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1 overflow-hidden">
|
||
<ImageParamsBar
|
||
modelName={triggerComposerModel?.displayName}
|
||
initialParams={imageParams}
|
||
onParamsChange={handleImageParamsChange}
|
||
inline
|
||
providerType={triggerComposerModel?.providerType}
|
||
imageGenConfig={triggerComposerModel?.imageGenConfig || undefined}
|
||
hideImageCount
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 pl-2">
|
||
<Button
|
||
onClick={createCanvasFromComposer}
|
||
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
|
||
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black dark:bg-white/16 dark:hover:bg-white/26"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
|
||
{visiblePromptTags.map((tag) => (
|
||
<button
|
||
key={tag.label}
|
||
type="button"
|
||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] ${
|
||
tag.highlighted
|
||
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f] dark:border-[#8b6330] dark:bg-[#38270f] dark:text-[#ffbe6b]"
|
||
: "border-black/15 bg-white text-black/70 dark:border-white/10 dark:bg-[#141416] dark:text-white/70"
|
||
}`}
|
||
>
|
||
<tag.icon className="h-3 w-3" />
|
||
{tag.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
|
||
<div className="mt-6 w-full">
|
||
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
)}
|
||
|
||
<section
|
||
ref={projectSectionRef}
|
||
className={isProjectsOnlyView ? "mt-6" : "mt-20"}
|
||
>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-[18px] font-semibold tracking-tight">
|
||
{isProjectsOnlyView ? "所有项目" : "最近项目"}
|
||
</h2>
|
||
{!isProjectsOnlyView && hasMoreProjects ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 text-[13px] text-black/45 hover:text-black dark:text-white/45 dark:hover:text-white"
|
||
onClick={() => setShowAllProjects((value) => !value)}
|
||
>
|
||
{showAllProjects ? "收起" : "查看更多"}
|
||
<ArrowRight className={`h-3.5 w-3.5 transition-transform ${showAllProjects ? "rotate-90" : ""}`} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<button
|
||
type="button"
|
||
onClick={openNewCanvas}
|
||
className="cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
|
||
>
|
||
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-3xl">+</div>
|
||
<div className="text-[14px]">新建项目</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
{recentProjects.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="group relative rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push(`/canvas/${item.id}`)}
|
||
className="block w-full cursor-pointer text-left"
|
||
>
|
||
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef] dark:bg-[#1b1c20]">
|
||
{item.thumbnail ? (
|
||
<img
|
||
src={item.thumbnail}
|
||
alt={item.title}
|
||
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.045]"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-black/35 dark:text-white/35">Untitled</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 text-[14px] font-medium">{item.title || "Untitled"}</div>
|
||
<div className="mt-1 text-xs text-black/35 dark:text-white/35">{formatRelativeTime(item.updatedAt || item.createdAt)}</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteProject(item.id, item.title)}
|
||
disabled={deletingProjectId === item.id}
|
||
className="absolute right-5 top-5 z-20 inline-flex h-7 w-7 items-center justify-center rounded-full border border-black/10 bg-white/90 text-black/45 shadow-sm transition hover:border-[#ffb4b4] hover:text-[#ef4444] disabled:cursor-not-allowed disabled:opacity-80 dark:border-white/10 dark:bg-[#18181c]/90 dark:text-white/45"
|
||
aria-label={`删除${item.title || "未命名画布"}`}
|
||
>
|
||
{deletingProjectId === item.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{recentProjects.length === 0 && (
|
||
<div className="rounded-[22px] border border-dashed border-black/15 bg-white p-6 text-center text-black/40 md:col-span-3 dark:border-white/12 dark:bg-[#141416] dark:text-white/40">
|
||
还没有项目,点击“新建项目”开始。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{!isProjectsOnlyView && (
|
||
<section ref={featuredSectionRef} className="mt-16">
|
||
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{discoveryTabs.map((category) => (
|
||
<button
|
||
key={category}
|
||
type="button"
|
||
onClick={() => setActiveCategory(category)}
|
||
className={`rounded-xl px-3 py-1.5 text-[12px] ${
|
||
activeCategory === category
|
||
? "bg-[#ececec] text-black dark:bg-[#24242a] dark:text-white"
|
||
: "text-black/55 hover:bg-white dark:text-white/55 dark:hover:bg-[#17171b]"
|
||
}`}
|
||
>
|
||
{category}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{featuredItems.length === 0 ? (
|
||
<div className="mt-5 rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
|
||
</div>
|
||
) : (
|
||
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
|
||
{featuredItems.map((item, index) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
|
||
className="group mb-4 block w-full cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5 dark:border-white/10 dark:bg-[#141416]"
|
||
>
|
||
<div
|
||
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8] dark:bg-[#1b1c20]"
|
||
style={{ height: `${MASONRY_HEIGHTS[index % MASONRY_HEIGHTS.length]}px` }}
|
||
>
|
||
<img
|
||
src={item.thumbnailUrl || item.originalUrl}
|
||
alt={item.prompt || "灵感图"}
|
||
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.04]"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60 dark:text-white/55">
|
||
<div className="line-clamp-1 font-medium text-black/85 dark:text-white/85">
|
||
{item.user?.name || "创作者"}
|
||
</div>
|
||
<div className="inline-flex items-center gap-2 text-[11px]">
|
||
<span className="inline-flex items-center gap-1">
|
||
<Sparkles className="h-2.5 w-2.5" />
|
||
{(1200 + index * 37).toLocaleString()}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<Heart className="h-2.5 w-2.5" />
|
||
{(20 + (index % 9) * 11).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
|
||
{isLoadingMoreFeatured ? (
|
||
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
正在加载更多灵感...
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</main>
|
||
|
||
<button
|
||
type="button"
|
||
title="回到顶部"
|
||
className="fixed bottom-8 right-8 z-20 flex h-11 w-11 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow hover:text-black 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 ? (
|
||
<ImageAnnotatorLazy
|
||
imageUrl={editorImageUrl}
|
||
onConfirm={handleImageEditorConfirm}
|
||
submitMode="publish-new-canvas"
|
||
onCancel={handleImageEditorCancel}
|
||
onHomeClick={handleImageEditorCancel}
|
||
/>
|
||
) : showImageEditor ? (
|
||
<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>
|
||
) : 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-[460px] rounded-[24px] bg-white p-6 shadow-2xl dark:bg-[#141416] sm:p-7"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{displayLogo ? (
|
||
<img
|
||
src={displayLogo}
|
||
alt={siteName}
|
||
className={`h-7 w-7 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||
/>
|
||
) : (
|
||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
</div>
|
||
)}
|
||
<span className="text-[18px] font-semibold leading-none tracking-tight 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-5 text-center">
|
||
<h3 className="text-[18px] font-semibold text-black dark:text-white">欢迎来到 {siteName}</h3>
|
||
<p className="mt-2 text-[13px] 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-3"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
handleEmailLogin();
|
||
}}
|
||
>
|
||
<Input
|
||
value={loginEmail}
|
||
onChange={(event) => setLoginEmail(event.target.value)}
|
||
placeholder="电子邮箱"
|
||
type="email"
|
||
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px] 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-12 rounded-xl border-black/20 px-3.5 text-[14px] dark:border-white/12 dark:bg-[#101014] dark:text-white dark:placeholder:text-white/35"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
className="mt-1 h-12 w-full rounded-xl bg-black text-[15px] text-white hover:bg-black/90"
|
||
disabled={isLoggingIn}
|
||
>
|
||
{isLoggingIn ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
登录中...
|
||
</span>
|
||
) : (
|
||
"使用邮箱继续"
|
||
)}
|
||
</Button>
|
||
</form>
|
||
|
||
{canUseRegistration ? (
|
||
<div className="mt-3 text-center text-[13px] text-black/55 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>
|
||
);
|
||
}
|