2045 lines
80 KiB
TypeScript
2045 lines
80 KiB
TypeScript
"use client";
|
||
|
||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { signIn, signOut } from "next-auth/react";
|
||
import useSWR from "swr";
|
||
import useSWRInfinite from "swr/infinite";
|
||
import {
|
||
ArrowRight,
|
||
ArrowUp,
|
||
Bell,
|
||
Briefcase,
|
||
ChevronDown,
|
||
Clapperboard,
|
||
Folder,
|
||
Heart,
|
||
Home,
|
||
ImageIcon,
|
||
Info,
|
||
Loader2,
|
||
LogOut,
|
||
Mail,
|
||
Monitor,
|
||
Palette,
|
||
Plus,
|
||
Send,
|
||
Settings,
|
||
Sparkles,
|
||
Star,
|
||
Upload,
|
||
User,
|
||
Users,
|
||
X,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { fetcher } from "@/lib/utils";
|
||
import {
|
||
type CanvasLaunchAttachment,
|
||
getCanvasLaunchStorageKey,
|
||
} from "@/lib/canvas-launch";
|
||
import type { MembershipConfig } from "@/lib/membership/config";
|
||
|
||
type HomeUser = {
|
||
id: string;
|
||
email?: string | null;
|
||
name?: string | null;
|
||
image?: string | null;
|
||
type?: string | null;
|
||
};
|
||
|
||
type CanvasProject = {
|
||
id: string;
|
||
title: string;
|
||
thumbnail?: string | null;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
};
|
||
|
||
type FeaturedImage = {
|
||
id: string;
|
||
thumbnailUrl: string;
|
||
originalUrl: string;
|
||
prompt?: string | null;
|
||
featuredCategory?: string | null;
|
||
user?: {
|
||
name?: string;
|
||
department?: string;
|
||
};
|
||
};
|
||
|
||
type FeaturedResponse = {
|
||
images: FeaturedImage[];
|
||
pagination?: {
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
};
|
||
};
|
||
|
||
type ModelOption = {
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
avatarUrl?: string | null;
|
||
providerType: string;
|
||
supportsImageGen?: boolean;
|
||
};
|
||
|
||
type NavLink = {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
order: number;
|
||
isEnabled: boolean;
|
||
toolboxGroup: number;
|
||
};
|
||
|
||
type PublicAuthSettings = {
|
||
isDingTalkEnabled?: boolean;
|
||
isGoogleEnabled?: boolean;
|
||
isGoogleConfigured?: boolean;
|
||
};
|
||
|
||
type MembershipSummaryResponse = {
|
||
config: MembershipConfig;
|
||
currentPlanId: string;
|
||
currentPlan: {
|
||
id: string;
|
||
name: string;
|
||
monthlyPrice: number;
|
||
annualPrice: number;
|
||
monthlyCredits: number;
|
||
dailyFreeCredits: number;
|
||
features: string[];
|
||
badge?: string;
|
||
highlighted?: boolean;
|
||
};
|
||
summary: {
|
||
currentCredits: number;
|
||
dailyFreeCredits: number;
|
||
usedThisMonth: number;
|
||
monthlyLimit: number | null;
|
||
remainingThisMonth: number | null;
|
||
totalImages: number;
|
||
};
|
||
};
|
||
|
||
type PointsPackage = {
|
||
id: string;
|
||
name: string;
|
||
points: number;
|
||
price: number;
|
||
currency: string;
|
||
isEnabled: boolean;
|
||
};
|
||
|
||
type AccountCenterResponse = {
|
||
profile: {
|
||
name: string;
|
||
email: string;
|
||
currentPlanName: string;
|
||
currentPlanId: string;
|
||
availablePoints: number;
|
||
monthlyCredits: number;
|
||
dailyFreeCredits: number;
|
||
};
|
||
usageRecords: Array<{
|
||
id: string;
|
||
detail: string;
|
||
status: "consumed" | "earned";
|
||
amount: number;
|
||
createdAt: string;
|
||
}>;
|
||
billingRecords: Array<{
|
||
id: string;
|
||
date: string;
|
||
category: string;
|
||
amount: number;
|
||
status: "completed" | "pending" | "failed";
|
||
invoice: string;
|
||
}>;
|
||
};
|
||
|
||
type LovartHomeProps = {
|
||
user: HomeUser | null;
|
||
isAuthenticated: boolean;
|
||
siteName: string;
|
||
siteLogo?: string | null;
|
||
surpriseCategories: string[];
|
||
heroPromoText?: string | null;
|
||
heroPromoActionText?: string | null;
|
||
heroTitleText?: string | null;
|
||
heroSubtitleText?: string | null;
|
||
};
|
||
|
||
type UploadFileItem = {
|
||
id: string;
|
||
file: File;
|
||
previewUrl: string;
|
||
};
|
||
|
||
const DISCOVERY_DEFAULT_TABS = [
|
||
"品牌设计",
|
||
"海报与广告",
|
||
"插画",
|
||
"UI设计",
|
||
"角色设计",
|
||
"影片与分镜",
|
||
"产品设计",
|
||
"建筑设计",
|
||
];
|
||
|
||
const PROMPT_TAGS = [
|
||
{ label: "Nano Banana Pro", icon: Sparkles, highlighted: true },
|
||
{ label: "Design", icon: ImageIcon, highlighted: false },
|
||
{ label: "Branding", icon: Star, highlighted: false },
|
||
{ label: "Illustration", icon: Palette, highlighted: false },
|
||
{ label: "E-Commerce", icon: Monitor, highlighted: false },
|
||
{ label: "Video", icon: Clapperboard, highlighted: false },
|
||
];
|
||
|
||
const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
|
||
const FEATURED_PAGE_SIZE = 24;
|
||
const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
|
||
|
||
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return "";
|
||
|
||
const sanitizedPrefix = fixedPrefix.trim();
|
||
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
|
||
return trimmed.slice(sanitizedPrefix.length).trimStart();
|
||
}
|
||
|
||
const legacyPrefixRemoved = trimmed
|
||
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
|
||
.replace(/^让\s*lovart\s*/i, "")
|
||
.trimStart();
|
||
if (legacyPrefixRemoved !== trimmed) {
|
||
return legacyPrefixRemoved;
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
function getUserInitials(user: HomeUser | null) {
|
||
const source = (user?.name || user?.email || "mu").trim();
|
||
if (!source) return "mu";
|
||
return source.slice(0, 2).toLowerCase();
|
||
}
|
||
|
||
function formatRelativeTime(value?: string) {
|
||
if (!value) return "更新于今天";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "更新于今天";
|
||
const diffMs = Date.now() - date.getTime();
|
||
const minute = 60 * 1000;
|
||
const hour = 60 * minute;
|
||
const day = 24 * hour;
|
||
if (diffMs < hour) return `更新于 ${Math.max(1, Math.round(diffMs / minute))} 分钟前`;
|
||
if (diffMs < day) return `更新于 ${Math.round(diffMs / hour)} 小时前`;
|
||
return `更新于 ${Math.round(diffMs / day)} 天前`;
|
||
}
|
||
|
||
function formatPrice(currency: string, value: number) {
|
||
if (value <= 0) return "免费";
|
||
return `${currency}${value}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "-";
|
||
return date.toLocaleString("zh-CN", { hour12: false });
|
||
}
|
||
|
||
function AccountMenuItem({
|
||
icon: Icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
icon: typeof User;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5"
|
||
>
|
||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70">
|
||
<Icon className="h-[16px] w-[16px]" />
|
||
</span>
|
||
<span className="flex-1">{label}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function LovartHome({
|
||
user,
|
||
isAuthenticated,
|
||
siteName,
|
||
siteLogo,
|
||
surpriseCategories,
|
||
heroPromoText,
|
||
heroPromoActionText,
|
||
heroTitleText,
|
||
heroSubtitleText,
|
||
}: LovartHomeProps) {
|
||
const router = useRouter();
|
||
const [prompt, setPrompt] = useState("");
|
||
const [files, setFiles] = useState<UploadFileItem[]>([]);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [activeCategory, setActiveCategory] = useState("全部");
|
||
const [showAllProjects, setShowAllProjects] = useState(false);
|
||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
|
||
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
|
||
"account"
|
||
);
|
||
const [usageFilter, setUsageFilter] = useState<"all" | "consumed" | "earned">("all");
|
||
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
|
||
const [creatingPaymentPackageId, setCreatingPaymentPackageId] = useState<string | null>(null);
|
||
const [selectedModelId, setSelectedModelId] = useState<string>("");
|
||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
});
|
||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||
const [loginEmail, setLoginEmail] = useState("");
|
||
const [loginPassword, setLoginPassword] = useState("");
|
||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||
const [typedPreviewText, setTypedPreviewText] = useState("");
|
||
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
|
||
const projectSectionRef = useRef<HTMLDivElement | null>(null);
|
||
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||
const filesRef = useRef<UploadFileItem[]>([]);
|
||
|
||
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
|
||
success: boolean;
|
||
data: CanvasProject[];
|
||
}>(isAuthenticated ? "/api/canvas" : null, fetcher);
|
||
|
||
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
|
||
useSWRInfinite<FeaturedResponse>(
|
||
(pageIndex, previousPageData) => {
|
||
if (
|
||
previousPageData?.pagination &&
|
||
previousPageData.pagination.page >= previousPageData.pagination.totalPages
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return `/api/featured?page=${pageIndex + 1}&pageSize=${FEATURED_PAGE_SIZE}`;
|
||
},
|
||
fetcher,
|
||
{
|
||
revalidateFirstPage: false,
|
||
parallel: false,
|
||
}
|
||
);
|
||
|
||
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
|
||
isAuthenticated ? "/api/user/membership-summary" : null,
|
||
fetcher
|
||
);
|
||
|
||
const { data: accountCenterData } = useSWR<AccountCenterResponse>(
|
||
isAuthenticated && accountCenterOpen ? "/api/user/account-center" : null,
|
||
fetcher
|
||
);
|
||
|
||
const { data: modelsData } = useSWR<{
|
||
models: ModelOption[];
|
||
}>(isAuthenticated ? "/api/models" : null, fetcher);
|
||
|
||
const { data: navLinksData } = useSWR<{ links: NavLink[] }>(
|
||
"/api/nav-links",
|
||
fetcher
|
||
);
|
||
|
||
const { data: siteSettingsData } = useSWR<{
|
||
canvasDefaultModelId?: string | null;
|
||
lovartComposerPrefix?: string | null;
|
||
lovartComposerPreviewTexts?: string[];
|
||
toolbox1Name?: string | null;
|
||
toolbox1Enabled?: boolean;
|
||
toolbox2Name?: string | null;
|
||
toolbox2Enabled?: boolean;
|
||
pointsPackages?: PointsPackage[];
|
||
dulupayConfig?: {
|
||
enabled?: boolean;
|
||
defaultType?: string;
|
||
};
|
||
}>("/api/site-settings", fetcher);
|
||
|
||
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
||
"/api/auth-settings",
|
||
fetcher
|
||
);
|
||
|
||
const membershipConfig = membershipData?.config;
|
||
const membershipPlans = membershipConfig?.plans || [];
|
||
const currentPlan = membershipData?.currentPlan;
|
||
|
||
const groupedModelRows = useMemo(() => {
|
||
const rows = membershipConfig?.modelRows || [];
|
||
return rows.reduce<Record<string, typeof rows>>((acc, row) => {
|
||
if (!acc[row.category]) {
|
||
acc[row.category] = [];
|
||
}
|
||
acc[row.category].push(row);
|
||
return acc;
|
||
}, {});
|
||
}, [membershipConfig?.modelRows]);
|
||
|
||
const imageModels = useMemo(
|
||
() => (modelsData?.models || []).filter((model) => model.supportsImageGen),
|
||
[modelsData?.models]
|
||
);
|
||
|
||
const allComposerModels = useMemo<ModelOption[]>(
|
||
() => [
|
||
{
|
||
id: "auto",
|
||
modelId: "auto",
|
||
displayName: "Agent模式",
|
||
providerType: "auto",
|
||
supportsImageGen: true,
|
||
},
|
||
...imageModels,
|
||
],
|
||
[imageModels]
|
||
);
|
||
|
||
const currentComposerModel = useMemo(
|
||
() => allComposerModels.find((model) => model.id === selectedModelId),
|
||
[allComposerModels, selectedModelId]
|
||
);
|
||
|
||
const discoveryTabs = useMemo(() => {
|
||
const configured = (surpriseCategories || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
// legacy typo compatibility: treat "惊喜" as "精选"
|
||
.map((item) => (item === "惊喜" ? "精选" : item));
|
||
|
||
const uniqueConfigured = Array.from(new Set(configured));
|
||
const hasRealConfig =
|
||
uniqueConfigured.length > 0 &&
|
||
!(uniqueConfigured.length === 1 && uniqueConfigured[0] === "全部");
|
||
|
||
const fallback = ["全部", "精选", ...DISCOVERY_DEFAULT_TABS];
|
||
const base = hasRealConfig ? uniqueConfigured : fallback;
|
||
|
||
const withoutAll = base.filter((item) => item !== "全部");
|
||
const merged = ["全部", ...withoutAll];
|
||
|
||
return Array.from(new Set(merged)).slice(0, 12);
|
||
}, [surpriseCategories]);
|
||
|
||
const composerPreviewTexts = useMemo(() => {
|
||
const configured = (siteSettingsData?.lovartComposerPreviewTexts || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
|
||
if (configured.length > 0) {
|
||
return configured;
|
||
}
|
||
|
||
return [
|
||
"打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
];
|
||
}, [siteSettingsData?.lovartComposerPreviewTexts]);
|
||
|
||
const composerFixedPrefix = useMemo(() => {
|
||
return (
|
||
String(siteSettingsData?.lovartComposerPrefix || "").trim() ||
|
||
COMPOSER_PREFIX_DEFAULT
|
||
);
|
||
}, [siteSettingsData?.lovartComposerPrefix]);
|
||
|
||
const composerPreviewTails = useMemo(() => {
|
||
return composerPreviewTexts
|
||
.map((text) => normalizeComposerPreviewTail(text, composerFixedPrefix))
|
||
.filter(Boolean);
|
||
}, [composerFixedPrefix, composerPreviewTexts]);
|
||
|
||
const loadedFeaturedImages = useMemo(() => {
|
||
return (featuredPages || []).flatMap((page) => page.images || []);
|
||
}, [featuredPages]);
|
||
|
||
const featuredPagination = featuredPages?.[featuredPages.length - 1]?.pagination;
|
||
const hasMoreFeatured = Boolean(
|
||
featuredPagination && featuredPagination.page < featuredPagination.totalPages
|
||
);
|
||
const isLoadingFeatured = !featuredPages && isFeaturedValidating;
|
||
const isLoadingMoreFeatured =
|
||
isFeaturedValidating &&
|
||
!!featuredPages &&
|
||
featuredPageCount > 0 &&
|
||
featuredPageCount > featuredPages.length;
|
||
|
||
const featuredItems = useMemo(() => {
|
||
const images = loadedFeaturedImages;
|
||
if (activeCategory === "全部") {
|
||
return images;
|
||
}
|
||
return images.filter((item) =>
|
||
item.featuredCategory
|
||
? String(item.featuredCategory) === activeCategory
|
||
: String(item.prompt || "").toLowerCase().includes(activeCategory.toLowerCase())
|
||
);
|
||
}, [loadedFeaturedImages, activeCategory]);
|
||
|
||
const recentProjects = useMemo(() => {
|
||
const projects = canvasData?.data || [];
|
||
return showAllProjects ? projects : projects.slice(0, 4);
|
||
}, [canvasData?.data, showAllProjects]);
|
||
|
||
const hasMoreProjects = useMemo(() => {
|
||
return (canvasData?.data || []).length > 4;
|
||
}, [canvasData?.data]);
|
||
|
||
const filteredUsageRecords = useMemo(() => {
|
||
const records = accountCenterData?.usageRecords || [];
|
||
if (usageFilter === "all") return records;
|
||
return records.filter((item) => item.status === usageFilter);
|
||
}, [accountCenterData?.usageRecords, usageFilter]);
|
||
|
||
const enabledPointsPackages = useMemo(() => {
|
||
return (siteSettingsData?.pointsPackages || []).filter(
|
||
(item) => item?.isEnabled !== false && item.price > 0 && item.points > 0
|
||
);
|
||
}, [siteSettingsData?.pointsPackages]);
|
||
|
||
const toolbox1Links = useMemo(
|
||
() =>
|
||
(navLinksData?.links || [])
|
||
.filter((link) => (link.toolboxGroup || 1) === 1 && link.isEnabled !== false)
|
||
.sort((a, b) => a.order - b.order),
|
||
[navLinksData?.links]
|
||
);
|
||
|
||
const toolbox2Links = useMemo(
|
||
() =>
|
||
(navLinksData?.links || [])
|
||
.filter((link) => link.toolboxGroup === 2 && link.isEnabled !== false)
|
||
.sort((a, b) => a.order - b.order),
|
||
[navLinksData?.links]
|
||
);
|
||
|
||
const canUseGoogleLogin = Boolean(
|
||
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
|
||
);
|
||
const canUseDingTalkLogin = Boolean(authSettingsData?.isDingTalkEnabled);
|
||
const hasThirdPartyLoginOptions = canUseGoogleLogin || canUseDingTalkLogin;
|
||
|
||
useEffect(() => {
|
||
if (selectedModelId) return;
|
||
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
|
||
setSelectedModelId(defaultModelId);
|
||
}, [selectedModelId, siteSettingsData?.canvasDefaultModelId]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedModelId) return;
|
||
if (!modelsData) return;
|
||
if (allComposerModels.length === 0) return;
|
||
if (allComposerModels.some((model) => model.id === selectedModelId)) return;
|
||
setSelectedModelId("auto");
|
||
}, [allComposerModels, selectedModelId, modelsData]);
|
||
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return;
|
||
setLoginDialogOpen(false);
|
||
}, [isAuthenticated]);
|
||
|
||
useEffect(() => {
|
||
if (!loginDialogOpen) {
|
||
setIsLoggingIn(false);
|
||
setLoginPassword("");
|
||
}
|
||
}, [loginDialogOpen]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
const saved = localStorage.getItem("lovart-home-image-params");
|
||
if (!saved) return;
|
||
const parsed = JSON.parse(saved) as Partial<ImageGenerationParams>;
|
||
setImageParams((prev) => ({ ...prev, ...parsed }));
|
||
} catch (error) {
|
||
console.error("[LovartHome] Failed to load image params:", error);
|
||
}
|
||
}, []);
|
||
|
||
const handleImageParamsChange = (nextParams: ImageGenerationParams) => {
|
||
setImageParams(nextParams);
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
localStorage.setItem("lovart-home-image-params", JSON.stringify(nextParams));
|
||
} catch (error) {
|
||
console.error("[LovartHome] Failed to save image params:", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!featuredLoadMoreRef.current || !hasMoreFeatured) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (!entries[0]?.isIntersecting || isLoadingMoreFeatured) return;
|
||
setFeaturedPageCount((current) => current + 1);
|
||
},
|
||
{ rootMargin: "1000px 0px 1200px 0px" }
|
||
);
|
||
|
||
observer.observe(featuredLoadMoreRef.current);
|
||
|
||
return () => observer.disconnect();
|
||
}, [hasMoreFeatured, isLoadingMoreFeatured, setFeaturedPageCount]);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
if (composerPreviewTails.length === 0) {
|
||
setTypedPreviewText("");
|
||
return () => {
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}
|
||
|
||
let textIndex = 0;
|
||
let charIndex = 0;
|
||
|
||
const typeForward = () => {
|
||
if (disposed) return;
|
||
const currentText = composerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex < currentText.length) {
|
||
charIndex += 1;
|
||
setTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeForward, 55);
|
||
return;
|
||
}
|
||
|
||
if (composerPreviewTails.length === 1) {
|
||
setTypedPreviewText(currentText);
|
||
return;
|
||
}
|
||
|
||
timer = setTimeout(typeBackward, 1200);
|
||
};
|
||
|
||
const typeBackward = () => {
|
||
if (disposed) return;
|
||
const currentText = composerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex > 0) {
|
||
charIndex -= 1;
|
||
setTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeBackward, 35);
|
||
return;
|
||
}
|
||
|
||
textIndex = (textIndex + 1) % composerPreviewTails.length;
|
||
timer = setTimeout(typeForward, 260);
|
||
};
|
||
|
||
setTypedPreviewText("");
|
||
timer = setTimeout(typeForward, 260);
|
||
|
||
return () => {
|
||
disposed = true;
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}, [composerPreviewTails]);
|
||
|
||
useEffect(() => {
|
||
const blinkTimer = setInterval(() => {
|
||
setPreviewCursorVisible((value) => !value);
|
||
}, 450);
|
||
|
||
return () => clearInterval(blinkTimer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
filesRef.current = files;
|
||
}, [files]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
filesRef.current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||
};
|
||
}, []);
|
||
|
||
const handleAddFiles = (targetFiles: FileList | null) => {
|
||
if (!targetFiles || targetFiles.length === 0) return;
|
||
|
||
const accepted = Array.from(targetFiles)
|
||
.filter((file) => file.type.startsWith("image/"))
|
||
.slice(0, 6);
|
||
|
||
if (accepted.length === 0) {
|
||
toast.error("请上传图片文件");
|
||
return;
|
||
}
|
||
|
||
const next = accepted.map((file) => ({
|
||
id: `${file.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
file,
|
||
previewUrl: URL.createObjectURL(file),
|
||
}));
|
||
|
||
setFiles((prev) => [...prev, ...next].slice(0, 6));
|
||
};
|
||
|
||
const removeFile = (id: string) => {
|
||
setFiles((prev) => {
|
||
const target = prev.find((item) => item.id === id);
|
||
if (target) {
|
||
URL.revokeObjectURL(target.previewUrl);
|
||
}
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
};
|
||
|
||
const uploadSelectedFiles = async (): Promise<CanvasLaunchAttachment[]> => {
|
||
const result: CanvasLaunchAttachment[] = [];
|
||
|
||
for (const item of files) {
|
||
const formData = new FormData();
|
||
formData.append("file", item.file);
|
||
|
||
const response = await fetch("/api/files/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!response.ok || !data?.url) {
|
||
throw new Error(data?.error || "上传失败");
|
||
}
|
||
|
||
result.push({
|
||
url: data.url,
|
||
name: item.file.name,
|
||
contentType: item.file.type || "image/jpeg",
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
const openLoginDialog = () => {
|
||
setLoginDialogOpen(true);
|
||
};
|
||
|
||
const handleEmailLogin = async () => {
|
||
const email = loginEmail.trim().toLowerCase();
|
||
if (!email || !loginPassword) {
|
||
toast.error("请输入邮箱和密码");
|
||
return;
|
||
}
|
||
|
||
setIsLoggingIn(true);
|
||
try {
|
||
const result = await signIn("credentials", {
|
||
email,
|
||
password: loginPassword,
|
||
redirect: false,
|
||
});
|
||
|
||
if (!result || result.error || result.ok === false) {
|
||
toast.error("登录失败,请检查账号密码");
|
||
return;
|
||
}
|
||
|
||
window.location.href = "/";
|
||
} catch (_error) {
|
||
toast.error("登录失败,请稍后重试");
|
||
} finally {
|
||
setIsLoggingIn(false);
|
||
}
|
||
};
|
||
|
||
const handleGoogleLogin = async () => {
|
||
if (!canUseGoogleLogin) {
|
||
toast.error("Google 登录未启用");
|
||
return;
|
||
}
|
||
await signIn("google", { callbackUrl: "/" });
|
||
};
|
||
|
||
const handleDingTalkLogin = () => {
|
||
window.location.href = "/login";
|
||
};
|
||
|
||
const handleCreateDulupayOrder = async (packageId: string) => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
|
||
if (creatingPaymentPackageId) return;
|
||
|
||
setCreatingPaymentPackageId(packageId);
|
||
try {
|
||
const response = await fetch("/api/payment/dulupay/create", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
packageId,
|
||
payType: siteSettingsData?.dulupayConfig?.defaultType || "alipay",
|
||
}),
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (!response.ok || !result?.paymentUrl) {
|
||
throw new Error(result?.error || "创建支付订单失败");
|
||
}
|
||
|
||
window.location.href = result.paymentUrl;
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建支付订单失败");
|
||
} finally {
|
||
setCreatingPaymentPackageId(null);
|
||
}
|
||
};
|
||
|
||
const createCanvasFromComposer = async () => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
|
||
if (!prompt.trim() && files.length === 0) return;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const uploadedAttachments = await uploadSelectedFiles();
|
||
const title = prompt.trim()
|
||
? prompt.trim().slice(0, 24)
|
||
: `新建项目 ${new Date().toLocaleDateString("zh-CN")}`;
|
||
|
||
const canvasResponse = await fetch("/api/canvas", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
const canvasResult = await canvasResponse.json();
|
||
|
||
if (!canvasResponse.ok || !canvasResult?.data?.id) {
|
||
throw new Error(canvasResult?.error || "创建项目失败");
|
||
}
|
||
|
||
const canvasId: string = canvasResult.data.id;
|
||
|
||
if (typeof window !== "undefined") {
|
||
sessionStorage.setItem(
|
||
getCanvasLaunchStorageKey(canvasId),
|
||
JSON.stringify({
|
||
prompt:
|
||
prompt.trim() ||
|
||
(uploadedAttachments.length > 0
|
||
? "请基于这些图片继续生成设计方案"
|
||
: ""),
|
||
attachments: uploadedAttachments,
|
||
autoSend: true,
|
||
createdAt: Date.now(),
|
||
selectedModelId: selectedModelId || "auto",
|
||
imageParams,
|
||
})
|
||
);
|
||
}
|
||
|
||
files.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||
setFiles([]);
|
||
setPrompt("");
|
||
await mutateCanvas();
|
||
router.push(`/canvas/${canvasId}`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建项目失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const openNewCanvas = () => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
router.push("/canvas?new=1");
|
||
};
|
||
|
||
const openAllProjectsInPage = () => {
|
||
if (!isAuthenticated) {
|
||
openLoginDialog();
|
||
return;
|
||
}
|
||
setShowAllProjects(true);
|
||
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
};
|
||
|
||
const openNavLink = (url: string) => {
|
||
const value = String(url || "").trim();
|
||
if (!value) return;
|
||
|
||
if (/^https?:\/\//i.test(value)) {
|
||
window.open(value, "_blank", "noopener,noreferrer");
|
||
return;
|
||
}
|
||
|
||
if (value.startsWith("/")) {
|
||
router.push(value);
|
||
return;
|
||
}
|
||
|
||
window.open(`https://${value}`, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
const composerPlaceholder = `${composerFixedPrefix}${typedPreviewText}${
|
||
previewCursorVisible ? " |" : ""
|
||
}`;
|
||
|
||
const userInitials = useMemo(() => getUserInitials(user), [user]);
|
||
const userDisplayName = useMemo(() => {
|
||
if (!user) return "";
|
||
return (user.name || user.email || "用户").toString();
|
||
}, [user]);
|
||
|
||
const accountProfile = useMemo(() => {
|
||
return {
|
||
name: accountCenterData?.profile?.name || userDisplayName || "用户",
|
||
email: accountCenterData?.profile?.email || user?.email || "",
|
||
planName: accountCenterData?.profile?.currentPlanName || currentPlan?.name || "Free",
|
||
availablePoints:
|
||
accountCenterData?.profile?.availablePoints ?? membershipData?.summary?.currentCredits ?? 0,
|
||
monthlyCredits:
|
||
accountCenterData?.profile?.monthlyCredits ?? currentPlan?.monthlyCredits ?? 0,
|
||
dailyFreeCredits:
|
||
accountCenterData?.profile?.dailyFreeCredits ?? membershipData?.summary?.dailyFreeCredits ?? 0,
|
||
};
|
||
}, [accountCenterData?.profile, currentPlan?.monthlyCredits, currentPlan?.name, membershipData?.summary?.currentCredits, membershipData?.summary?.dailyFreeCredits, user?.email, userDisplayName]);
|
||
|
||
return (
|
||
<div className="min-h-svh bg-[#f4f4f4] text-[#171717]">
|
||
<header className="fixed left-0 top-0 z-40 h-[68px] w-full bg-[#f4f4f4]/90 backdrop-blur">
|
||
<div className="flex h-full items-center justify-between px-7">
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2"
|
||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||
>
|
||
{siteLogo ? (
|
||
<img src={siteLogo} alt={siteName} className="h-8 w-8 rounded-full object-cover" />
|
||
) : (
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
<span className="text-[20px] font-semibold leading-none tracking-tight">
|
||
{siteName || "Lovart"}
|
||
</span>
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10">
|
||
<Bell className="h-3.5 w-3.5" />
|
||
</button>
|
||
|
||
<HoverCard openDelay={100} closeDelay={120}>
|
||
<HoverCardTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2.5 text-[14px] text-white hover:bg-black hover:text-white"
|
||
>
|
||
<span className="mr-1.5">升级</span>
|
||
<div className="inline-flex items-center gap-1 rounded-[9px] bg-white/10 px-1.5 py-0.5 text-xs text-white">
|
||
<Zap className="h-3 w-3 text-amber-300" />
|
||
{membershipData?.summary?.currentCredits ?? 0}
|
||
</div>
|
||
</Button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
align="end"
|
||
sideOffset={10}
|
||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl"
|
||
>
|
||
<div className="p-4">
|
||
<div className="mb-4 flex items-start justify-between">
|
||
<div>
|
||
<div className="text-[22px] font-semibold leading-none text-black">
|
||
{currentPlan?.name || "Free"}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
>
|
||
升级
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px]">
|
||
<div className="flex items-center justify-between">
|
||
<span className="inline-flex items-center gap-2 text-black/80">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
积分
|
||
</span>
|
||
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="inline-flex items-center gap-2 text-black/80">
|
||
<Upload className="h-3.5 w-3.5" />
|
||
每日免费积分
|
||
</span>
|
||
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
>
|
||
使用详情
|
||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||
</button>
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
|
||
{isAuthenticated ? (
|
||
<HoverCard openDelay={80} closeDelay={120}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
|
||
aria-label="Account menu"
|
||
>
|
||
{userInitials}
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent
|
||
align="end"
|
||
sideOffset={12}
|
||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl"
|
||
>
|
||
<div className="p-3.5">
|
||
<div className="rounded-3xl border border-black/10 bg-white p-3.5">
|
||
<div className="flex justify-center">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||
{userInitials}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[15px] font-semibold text-black">
|
||
{userDisplayName}
|
||
</div>
|
||
<div className="truncate text-[12px] text-black/45">
|
||
{user?.email || ""}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
>
|
||
升级
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="mt-4 border-t border-black/10 pt-3">
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
>
|
||
<span>积分</span>
|
||
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60">
|
||
{membershipData?.summary?.currentCredits ?? 0}
|
||
<ArrowRight className="h-4 w-4" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-1">
|
||
<AccountMenuItem
|
||
icon={Users}
|
||
label="创建团队"
|
||
onClick={() => toast.info("团队功能后续开放")}
|
||
/>
|
||
<AccountMenuItem
|
||
icon={User}
|
||
label="账户管理"
|
||
onClick={() => {
|
||
setAccountCenterTab("account");
|
||
setUsageFilter("all");
|
||
setAccountCenterOpen(true);
|
||
}}
|
||
/>
|
||
<AccountMenuItem
|
||
icon={Info}
|
||
label="使用指南"
|
||
onClick={() => toast.info("使用指南后续开放")}
|
||
/>
|
||
<AccountMenuItem
|
||
icon={Mail}
|
||
label="联系我们"
|
||
onClick={() => toast.info("联系我们后续开放")}
|
||
/>
|
||
<AccountMenuItem
|
||
icon={LogOut}
|
||
label="退出登录"
|
||
onClick={() => {
|
||
void signOut({ redirect: true, callbackUrl: "/" });
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5"
|
||
onClick={openLoginDialog}
|
||
>
|
||
登录
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<aside className="fixed left-6 top-1/2 z-30 hidden -translate-y-1/2 flex-col items-center gap-2 lg:flex">
|
||
<button
|
||
type="button"
|
||
onClick={openNewCanvas}
|
||
className="flex h-[52px] w-[52px] items-center justify-center rounded-full border border-black/10 bg-white shadow"
|
||
>
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
|
||
<Plus className="h-4 w-4" />
|
||
</div>
|
||
</button>
|
||
|
||
<div className="flex w-[56px] flex-col items-center gap-1 rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow">
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full bg-black/5"
|
||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||
>
|
||
<Home className="h-[18px] w-[18px]" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
onClick={openAllProjectsInPage}
|
||
>
|
||
<Folder className="h-[18px] w-[18px]" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
onClick={() => toast.info("个人主页模块后续开放")}
|
||
>
|
||
<User className="h-[18px] w-[18px]" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
onClick={() => toast.info("帮助中心后续开放")}
|
||
>
|
||
<Info className="h-[18px] w-[18px]" />
|
||
</button>
|
||
{siteSettingsData?.toolbox1Enabled !== false && toolbox1Links.length > 0 ? (
|
||
<HoverCard openDelay={80} closeDelay={80}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
title={siteSettingsData?.toolbox1Name || "工具箱"}
|
||
>
|
||
<Briefcase className="h-[18px] w-[18px]" />
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
|
||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
|
||
{siteSettingsData?.toolbox1Name || "工具箱"}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{toolbox1Links.map((link) => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
|
||
onClick={() => openNavLink(link.url)}
|
||
>
|
||
<span className="line-clamp-1">{link.name}</span>
|
||
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : null}
|
||
{siteSettingsData?.toolbox2Enabled === true && toolbox2Links.length > 0 ? (
|
||
<HoverCard openDelay={80} closeDelay={80}>
|
||
<HoverCardTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
title={siteSettingsData?.toolbox2Name || "工具箱2"}
|
||
>
|
||
<Briefcase className="h-[18px] w-[18px]" />
|
||
</button>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
|
||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
|
||
{siteSettingsData?.toolbox2Name || "工具箱2"}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{toolbox2Links.map((link) => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
|
||
onClick={() => openNavLink(link.url)}
|
||
>
|
||
<span className="line-clamp-1">{link.name}</span>
|
||
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
) : null}
|
||
{user?.type === "admin" ? (
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
|
||
onClick={() => router.push("/settings")}
|
||
>
|
||
<Settings className="h-[18px] w-[18px]" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="mx-auto max-w-[1240px] px-6 pb-20 pt-[84px]">
|
||
<section className="mt-8 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => setUpgradeDialogOpen(true)}
|
||
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80"
|
||
>
|
||
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||
</button>
|
||
|
||
<div className="mt-8 flex items-center justify-center gap-2">
|
||
{siteLogo ? (
|
||
<img src={siteLogo} alt={siteName} className="h-10 w-10 rounded-full object-cover" />
|
||
) : (
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
<h1 className="text-[34px] font-semibold tracking-tight">
|
||
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
|
||
</h1>
|
||
</div>
|
||
<p className="mt-2 text-[13px] text-black/35">
|
||
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
|
||
</p>
|
||
|
||
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)]">
|
||
<Textarea
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
placeholder={composerPlaceholder}
|
||
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0"
|
||
/>
|
||
|
||
{files.length > 0 && (
|
||
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
||
{files.map((item) => (
|
||
<div key={item.id} className="group relative h-12 w-12 overflow-hidden rounded-lg border border-black/10">
|
||
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
|
||
<button
|
||
type="button"
|
||
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-xs text-white group-hover:flex"
|
||
onClick={() => removeFile(item.id)}
|
||
>
|
||
x
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between border-t border-black/8 pt-3">
|
||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5">
|
||
<Upload className="h-4 w-4" />
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
handleAddFiles(event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
<Select value={selectedModelId || "auto"} onValueChange={setSelectedModelId}>
|
||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full border border-blue-100 bg-blue-50 px-2 text-xs text-blue-600">
|
||
<span className="flex min-w-0 items-center gap-1.5">
|
||
<span className="h-5 w-5 overflow-hidden rounded-full border border-blue-200 bg-white">
|
||
{currentComposerModel?.avatarUrl ? (
|
||
<img
|
||
src={currentComposerModel.avatarUrl}
|
||
alt={currentComposerModel.displayName || "模型头像"}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-blue-500">
|
||
{(currentComposerModel?.displayName || "A").slice(0, 1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="max-w-[95px] truncate">
|
||
{currentComposerModel?.displayName || "Agent模式"}
|
||
</span>
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
|
||
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
|
||
{[{ id: "auto", displayName: "Agent模式", avatarUrl: null }, ...imageModels].map((model) => (
|
||
<SelectItem
|
||
key={model.id}
|
||
value={model.id}
|
||
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
|
||
>
|
||
<span className="flex w-full items-center justify-between gap-2">
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
|
||
{model.avatarUrl ? (
|
||
<img src={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
|
||
) : (
|
||
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
|
||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
|
||
</span>
|
||
{(selectedModelId || "auto") === model.id ? (
|
||
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
|
||
) : null}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</div>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<div className="min-w-0 flex-1 overflow-hidden">
|
||
<ImageParamsBar
|
||
modelName={currentComposerModel?.displayName}
|
||
initialParams={imageParams}
|
||
onParamsChange={handleImageParamsChange}
|
||
inline
|
||
providerType={currentComposerModel?.providerType}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 pl-2">
|
||
<Button
|
||
onClick={createCanvasFromComposer}
|
||
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
|
||
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
|
||
{PROMPT_TAGS.map((tag) => (
|
||
<button
|
||
key={tag.label}
|
||
type="button"
|
||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] ${
|
||
tag.highlighted
|
||
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f]"
|
||
: "border-black/15 bg-white text-black/70"
|
||
}`}
|
||
>
|
||
<tag.icon className="h-3 w-3" />
|
||
{tag.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section ref={projectSectionRef} className="mt-20">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-[18px] font-semibold tracking-tight">最近项目</h2>
|
||
{hasMoreProjects ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 text-[13px] text-black/45 hover:text-black"
|
||
onClick={() => setShowAllProjects((value) => !value)}
|
||
>
|
||
{showAllProjects ? "收起" : "查看更多"}
|
||
<ArrowRight className={`h-3.5 w-3.5 transition-transform ${showAllProjects ? "rotate-90" : ""}`} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
<button
|
||
type="button"
|
||
onClick={openNewCanvas}
|
||
className="cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
|
||
>
|
||
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef]">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-3xl">+</div>
|
||
<div className="text-[14px]">新建项目</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
{recentProjects.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => router.push(`/canvas/${item.id}`)}
|
||
className="group cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
|
||
>
|
||
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef]">
|
||
{item.thumbnail ? (
|
||
<img
|
||
src={item.thumbnail}
|
||
alt={item.title}
|
||
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.045]"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-black/35">Untitled</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 text-[14px] font-medium">{item.title || "Untitled"}</div>
|
||
<div className="mt-1 text-xs text-black/35">{formatRelativeTime(item.updatedAt || item.createdAt)}</div>
|
||
</button>
|
||
))}
|
||
|
||
{recentProjects.length === 0 && (
|
||
<div className="rounded-[22px] border border-dashed border-black/15 bg-white p-6 text-center text-black/40 md:col-span-3">
|
||
还没有项目,点击“新建项目”开始。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mt-16">
|
||
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{discoveryTabs.map((category) => (
|
||
<button
|
||
key={category}
|
||
type="button"
|
||
onClick={() => setActiveCategory(category)}
|
||
className={`rounded-xl px-3 py-1.5 text-[12px] ${
|
||
activeCategory === category
|
||
? "bg-[#ececec] text-black"
|
||
: "text-black/55 hover:bg-white"
|
||
}`}
|
||
>
|
||
{category}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{featuredItems.length === 0 ? (
|
||
<div className="mt-5 rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35">
|
||
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
|
||
</div>
|
||
) : (
|
||
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
|
||
{featuredItems.map((item, index) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
|
||
className="group mb-4 block w-full cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5"
|
||
>
|
||
<div
|
||
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8]"
|
||
style={{ height: `${MASONRY_HEIGHTS[index % MASONRY_HEIGHTS.length]}px` }}
|
||
>
|
||
<img
|
||
src={item.thumbnailUrl || item.originalUrl}
|
||
alt={item.prompt || "灵感图"}
|
||
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.04]"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60">
|
||
<div className="line-clamp-1 font-medium text-black/85">
|
||
{item.user?.name || "创作者"}
|
||
</div>
|
||
<div className="inline-flex items-center gap-2 text-[11px]">
|
||
<span className="inline-flex items-center gap-1">
|
||
<Sparkles className="h-2.5 w-2.5" />
|
||
{(1200 + index * 37).toLocaleString()}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<Heart className="h-2.5 w-2.5" />
|
||
{(20 + (index % 9) * 11).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
|
||
{isLoadingMoreFeatured ? (
|
||
<div className="inline-flex items-center gap-2 text-sm text-black/45">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
正在加载更多灵感...
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<button
|
||
type="button"
|
||
className="fixed bottom-8 right-8 z-20 flex h-11 w-11 items-center justify-center rounded-full border border-black/10 bg-white text-black/50 shadow hover:text-black"
|
||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||
>
|
||
<Send className="h-4 w-4 -rotate-90" />
|
||
</button>
|
||
|
||
{loginDialogOpen ? (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||
onClick={() => setLoginDialogOpen(false)}
|
||
>
|
||
<div
|
||
className="w-full max-w-[460px] rounded-[24px] bg-white p-6 shadow-2xl sm:p-7"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{siteLogo ? (
|
||
<img
|
||
src={siteLogo}
|
||
alt={siteName}
|
||
className="h-7 w-7 rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-black text-white">
|
||
<Sparkles className="h-3.5 w-3.5" />
|
||
</div>
|
||
)}
|
||
<span className="text-[18px] font-semibold leading-none tracking-tight text-black">
|
||
{siteName}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="rounded-full p-1 text-black/45 hover:bg-black/5 hover:text-black/70"
|
||
onClick={() => setLoginDialogOpen(false)}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mb-5 text-center">
|
||
<h3 className="text-[18px] font-semibold text-black">欢迎来到 {siteName}</h3>
|
||
<p className="mt-2 text-[13px] text-black/45">登录或注册以继续</p>
|
||
</div>
|
||
|
||
{hasThirdPartyLoginOptions ? (
|
||
<div className="space-y-2.5">
|
||
{canUseGoogleLogin ? (
|
||
<button
|
||
type="button"
|
||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[15px] font-medium text-black hover:bg-black/[0.03]"
|
||
onClick={handleGoogleLogin}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path
|
||
fill="#EA4335"
|
||
d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z"
|
||
/>
|
||
</svg>
|
||
使用 Google
|
||
</button>
|
||
) : null}
|
||
{canUseDingTalkLogin ? (
|
||
<button
|
||
type="button"
|
||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-black/10 text-[15px] font-medium text-black hover:bg-black/[0.03]"
|
||
onClick={handleDingTalkLogin}
|
||
>
|
||
使用钉钉
|
||
</button>
|
||
) : null}
|
||
<div className="my-2 border-t border-black/10" />
|
||
</div>
|
||
) : null}
|
||
|
||
<form
|
||
className="space-y-3"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
handleEmailLogin();
|
||
}}
|
||
>
|
||
<Input
|
||
value={loginEmail}
|
||
onChange={(event) => setLoginEmail(event.target.value)}
|
||
placeholder="电子邮箱"
|
||
type="email"
|
||
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px]"
|
||
/>
|
||
<Input
|
||
value={loginPassword}
|
||
onChange={(event) => setLoginPassword(event.target.value)}
|
||
placeholder="密码"
|
||
type="password"
|
||
className="h-12 rounded-xl border-black/20 px-3.5 text-[14px]"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
className="mt-1 h-12 w-full rounded-xl bg-black text-[15px] text-white hover:bg-black/90"
|
||
disabled={isLoggingIn}
|
||
>
|
||
{isLoggingIn ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
登录中...
|
||
</span>
|
||
) : (
|
||
"使用邮箱继续"
|
||
)}
|
||
</Button>
|
||
</form>
|
||
|
||
<p className="mt-4 text-center text-xs text-black/40">
|
||
继续即表示您同意使用条款和隐私政策
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Dialog open={accountCenterOpen} onOpenChange={setAccountCenterOpen}>
|
||
<DialogContent className="max-h-[88vh] w-[92vw] max-w-[980px] overflow-hidden rounded-[22px] border-black/10 bg-[#f6f6f7] p-0">
|
||
<DialogHeader className="sr-only">
|
||
<DialogTitle>账户管理</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex h-full max-h-[88vh] flex-col p-4">
|
||
<div className="mb-2.5 flex items-center gap-5 border-b border-black/8 pb-2">
|
||
<button
|
||
type="button"
|
||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "account" ? "text-black" : "text-black/35"}`}
|
||
onClick={() => setAccountCenterTab("account")}
|
||
>
|
||
账户管理
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "usage" ? "text-black" : "text-black/35"}`}
|
||
onClick={() => setAccountCenterTab("usage")}
|
||
>
|
||
用量
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`text-[13px] font-semibold leading-tight ${accountCenterTab === "billing" ? "text-black" : "text-black/35"}`}
|
||
onClick={() => setAccountCenterTab("billing")}
|
||
>
|
||
账单
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto pr-1">
|
||
{accountCenterTab === "account" ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||
{userInitials}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[14px] font-semibold text-black">
|
||
{accountProfile.name}
|
||
</div>
|
||
<div className="truncate text-[12px] text-black/45">{accountProfile.email}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
className="h-8 rounded-xl bg-[#2f3542] px-3 text-[12px] text-white hover:bg-[#242a36] hover:text-white"
|
||
onClick={() => toast.info("编辑资料功能后续开放")}
|
||
>
|
||
编辑资料
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="h-8 rounded-xl border border-black/10 bg-white px-3 text-[12px] text-black/75 hover:bg-black/5"
|
||
onClick={() => {
|
||
void signOut({ redirect: true, callbackUrl: "/" });
|
||
}}
|
||
>
|
||
退出登录
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[16px] border border-black/10 bg-white px-3.5 py-3.5">
|
||
<div className="mb-2 flex items-center gap-4 text-[13px] font-semibold">
|
||
<span className="text-black">账户信息</span>
|
||
<span className="text-black/30">设备管理</span>
|
||
</div>
|
||
<div className="grid grid-cols-[110px_1fr] gap-y-2 text-[12px]">
|
||
<div className="text-black/55">当前订阅</div>
|
||
<div className="text-black">{accountProfile.planName}</div>
|
||
<div className="text-black/55">可用积分</div>
|
||
<div className="text-black">{accountProfile.availablePoints.toLocaleString()}</div>
|
||
<div className="text-black/55">每月更新积分</div>
|
||
<div className="text-black">{accountProfile.monthlyCredits.toLocaleString()}</div>
|
||
<div className="text-black/55">每日免费积分</div>
|
||
<div className="text-black">{accountProfile.dailyFreeCredits.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{accountCenterTab === "usage" ? (
|
||
<div className="rounded-[16px] border border-black/10 bg-white">
|
||
<div className="grid grid-cols-[2fr_1fr_2fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
|
||
<span>明细</span>
|
||
<Select value={usageFilter} onValueChange={(value: "all" | "consumed" | "earned") => setUsageFilter(value)}>
|
||
<SelectTrigger className="h-7 w-[104px] border-black/10 bg-white text-[12px]">
|
||
<span>{usageFilter === "all" ? "全部" : usageFilter === "consumed" ? "已消耗" : "已获取"}</span>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
<SelectItem value="consumed">已消耗</SelectItem>
|
||
<SelectItem value="earned">已获取</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<span>日期</span>
|
||
<span>积分消耗</span>
|
||
</div>
|
||
{filteredUsageRecords.length > 0 ? (
|
||
<div>
|
||
{filteredUsageRecords.map((record, idx) => (
|
||
<div
|
||
key={record.id}
|
||
className={`grid grid-cols-[2fr_1fr_2fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||
>
|
||
<div className="truncate pr-3 text-black/80">{record.detail}</div>
|
||
<div className="text-black/55">{record.status === "consumed" ? "已消耗" : "已获取"}</div>
|
||
<div className="text-black/65">{formatDateTime(record.createdAt)}</div>
|
||
<div className={record.amount < 0 ? "text-black" : "text-[#0f8a3c]"}>
|
||
{record.amount > 0 ? `+${record.amount}` : record.amount}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{accountCenterTab === "billing" ? (
|
||
<div className="rounded-[16px] border border-black/10 bg-white">
|
||
<div className="grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center border-b border-black/8 px-3.5 py-2 text-[12px] font-semibold text-black/80">
|
||
<span>日期</span>
|
||
<span>类别</span>
|
||
<span>金额</span>
|
||
<span>状态</span>
|
||
<span>发票</span>
|
||
</div>
|
||
{accountCenterData?.billingRecords && accountCenterData.billingRecords.length > 0 ? (
|
||
<div>
|
||
{accountCenterData.billingRecords.map((record, idx) => (
|
||
<div
|
||
key={record.id}
|
||
className={`grid grid-cols-[2fr_2fr_1.5fr_1.5fr_1fr] items-center px-3.5 py-2 text-[12px] ${idx % 2 === 1 ? "bg-black/[0.02]" : ""}`}
|
||
>
|
||
<div className="text-black/65">{formatDateTime(record.date)}</div>
|
||
<div className="text-black/80">{record.category}</div>
|
||
<div className="text-black/80">{record.amount > 0 ? `+${record.amount}` : record.amount}</div>
|
||
<div className="text-black/55">{record.status === "completed" ? "完成" : record.status === "pending" ? "处理中" : "失败"}</div>
|
||
<div className="text-black/35">{record.invoice || "-"}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-[340px] items-center justify-center text-black/35">暂无数据</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen}>
|
||
<DialogContent className="max-h-[90vh] w-[94vw] max-w-[980px] overflow-hidden rounded-2xl border-black/10 bg-[#f7f7f7] p-0">
|
||
<div className="flex h-full max-h-[90vh] flex-col">
|
||
<DialogHeader className="border-b border-black/6 px-5 py-4">
|
||
<DialogTitle className="text-center text-3xl font-bold text-black">升级您的套餐</DialogTitle>
|
||
<div className="mt-2.5 flex items-center justify-center gap-2">
|
||
<Button
|
||
variant={billingCycle === "monthly" ? "default" : "outline"}
|
||
className="h-7 rounded-full px-3.5 text-[11px]"
|
||
onClick={() => setBillingCycle("monthly")}
|
||
>
|
||
月付
|
||
</Button>
|
||
<Button
|
||
variant={billingCycle === "annual" ? "default" : "outline"}
|
||
className="h-7 rounded-full px-3.5 text-[11px]"
|
||
onClick={() => setBillingCycle("annual")}
|
||
>
|
||
年付
|
||
</Button>
|
||
<span className="rounded-full bg-[#ffe68f] px-2 py-0.5 text-[11px] text-[#8b5e00]">
|
||
{membershipConfig?.annualDiscountLabel || "限时优惠"}
|
||
</span>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 pb-6 pt-3.5">
|
||
{siteSettingsData?.dulupayConfig?.enabled === true &&
|
||
enabledPointsPackages.length > 0 ? (
|
||
<section className="mb-5 rounded-2xl border border-black/10 bg-white p-3.5">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h4 className="text-base font-semibold text-black">积分充值</h4>
|
||
<span className="text-[11px] text-black/45">支持 DuluPay 即时到账</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-2.5 md:grid-cols-3">
|
||
{enabledPointsPackages.map((pkg) => {
|
||
const isPaying = creatingPaymentPackageId === pkg.id;
|
||
return (
|
||
<div
|
||
key={pkg.id}
|
||
className="rounded-xl border border-black/10 bg-[#fafafa] p-3"
|
||
>
|
||
<div className="text-sm font-semibold text-black">{pkg.name}</div>
|
||
<div className="mt-1 text-xs text-black/55">
|
||
{pkg.points.toLocaleString()} 积分
|
||
</div>
|
||
<div className="mt-2 text-xl font-bold text-black">
|
||
{formatPrice(
|
||
pkg.currency === "CNY" ? "¥" : `${pkg.currency} `,
|
||
pkg.price
|
||
)}
|
||
</div>
|
||
<Button
|
||
className="mt-2.5 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
|
||
disabled={isPaying}
|
||
onClick={() => handleCreateDulupayOrder(pkg.id)}
|
||
>
|
||
{isPaying ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
跳转中
|
||
</span>
|
||
) : (
|
||
"立即充值"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{membershipPlans.length > 0 ? (
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||
{membershipPlans.map((plan) => {
|
||
const isCurrent = currentPlan?.id === plan.id;
|
||
const annualMonthlyPrice =
|
||
plan.annualPrice > 0 ? Number((plan.annualPrice / 12).toFixed(2)) : 0;
|
||
return (
|
||
<div
|
||
key={plan.id}
|
||
className={`rounded-xl border bg-white p-3 ${
|
||
plan.highlighted
|
||
? "border-[#7d87ff] shadow-[0_0_0_2px_rgba(125,135,255,0.25)]"
|
||
: "border-black/10"
|
||
}`}
|
||
>
|
||
<div className="mb-2.5 flex items-center justify-between">
|
||
<h3 className="text-lg font-semibold text-black">{plan.name}</h3>
|
||
{plan.badge ? (
|
||
<span className="rounded-full bg-[#eef0ff] px-2 py-0.5 text-[10px] text-[#5b65db]">
|
||
{plan.badge}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="mb-2.5">
|
||
{plan.monthlyPrice <= 0 ? (
|
||
<div className="text-3xl font-bold text-black">免费</div>
|
||
) : billingCycle === "monthly" ? (
|
||
<div className="text-3xl font-bold text-black">
|
||
{formatPrice(membershipConfig?.currency || "$", plan.monthlyPrice)}
|
||
<span className="ml-1 text-xs font-normal text-black/45">/月</span>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<div className="text-3xl font-bold text-black">
|
||
{formatPrice(membershipConfig?.currency || "$", plan.annualPrice)}
|
||
<span className="ml-1 text-xs font-normal text-black/45">/年</span>
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-black/45">
|
||
折合 {formatPrice(membershipConfig?.currency || "$", annualMonthlyPrice)}/月
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mb-3 text-xs text-black/60">
|
||
每月 {plan.monthlyCredits.toLocaleString()} 快速积分
|
||
</div>
|
||
<Button
|
||
className="mb-3 h-8 w-full rounded-lg bg-black text-xs text-white hover:bg-black/90"
|
||
disabled={isCurrent}
|
||
onClick={() => {
|
||
if (isCurrent) return;
|
||
toast.info("支付能力后续接入,已预留套餐配置接口");
|
||
}}
|
||
>
|
||
{isCurrent ? "当前套餐" : "升级"}
|
||
</Button>
|
||
<ul className="space-y-1 text-[11px] text-black/65">
|
||
{plan.features.map((feature) => (
|
||
<li key={feature} className="flex items-start gap-2">
|
||
<span className="mt-1 h-1 w-1 rounded-full bg-black/40" />
|
||
<span>{feature}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-dashed border-black/15 bg-white p-8 text-center text-sm text-black/40">
|
||
会员配置暂未启用,请在系统设置中完善会员 JSON 配置。
|
||
</div>
|
||
)}
|
||
|
||
<section className="mt-7">
|
||
<h4 className="mb-3 text-center text-2xl font-semibold text-black">常见问题</h4>
|
||
<div className="space-y-2">
|
||
{(membershipConfig?.faqs || []).map((faq) => (
|
||
<details
|
||
key={faq.question}
|
||
className="rounded-xl border border-black/8 bg-white px-3.5 py-2.5 text-xs text-black/70"
|
||
>
|
||
<summary className="cursor-pointer list-none font-medium">{faq.question}</summary>
|
||
<p className="mt-2 text-black/60">{faq.answer}</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mt-7">
|
||
<h4 className="mb-2 text-center text-2xl font-semibold text-black">
|
||
模型积分消耗 & 每月生成数量
|
||
</h4>
|
||
<p className="mb-3 text-center text-[11px] text-black/45">
|
||
以下表格基于系统设置中的会员配置,可直接在后台调整。
|
||
</p>
|
||
<div className="overflow-x-auto rounded-xl border border-black/10 bg-white">
|
||
<table className="w-full min-w-[820px] text-xs">
|
||
<thead>
|
||
<tr className="bg-black/[0.03]">
|
||
<th className="px-2.5 py-2.5 text-left font-semibold text-black/75">模型</th>
|
||
{membershipPlans.map((plan) => (
|
||
<th key={plan.id} className="px-2.5 py-2.5 text-left font-semibold text-black/75">
|
||
{plan.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Object.entries(groupedModelRows).map(([category, rows]) => (
|
||
<Fragment key={category}>
|
||
<tr className="bg-black/[0.045]">
|
||
<td
|
||
colSpan={membershipPlans.length + 1}
|
||
className="px-2.5 py-2 text-[11px] font-semibold text-black/60"
|
||
>
|
||
{category}
|
||
</td>
|
||
</tr>
|
||
{rows.map((row) => (
|
||
<tr key={`${category}-${row.model}`} className="border-t border-black/[0.06]">
|
||
<td className="px-2.5 py-2 text-black/70">{row.model}</td>
|
||
{membershipPlans.map((plan) => (
|
||
<td key={`${row.model}-${plan.id}`} className="px-2.5 py-2 text-black/60">
|
||
{row.values[plan.id] ?? "-"}
|
||
{row.values[plan.id] && row.values[plan.id] !== "-" ? row.unit : ""}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|