From 560a1999a09ab701b457c81e3731546aa8f8193c Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 12:58:33 +0800 Subject: [PATCH] feat: improve lovart home and 3d canvas flows --- .../settings/admin-settings-client.tsx | 349 ++++++++ app/(auth)/login/page.tsx | 11 +- app/(chat)/page.tsx | 3 +- app/api/site-settings/route.ts | 53 ++ app/globals.css | 8 + components/canvas/model3d/Model3DViewer.tsx | 44 + components/canvas/nodes/ImageNode.tsx | 251 +++++- components/canvas/nodes/Model3DNode.tsx | 80 +- components/canvas/nodes/World3DNode.tsx | 183 +++- components/canvas/world3d/World3DViewer.tsx | 54 +- components/home/floating-sidebar-shell.tsx | 10 +- components/home/lovart-home.tsx | 827 +++++++++++------- components/message.tsx | 8 +- components/top-navbar.tsx | 6 +- lib/home/lovart-sidebar-config.ts | 156 ++++ 15 files changed, 1697 insertions(+), 346 deletions(-) create mode 100644 lib/home/lovart-sidebar-config.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index a67682771..428cbe03b 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -81,6 +81,7 @@ import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; import { Trash2, Save, + Home, User, Settings, Wrench, @@ -202,6 +203,14 @@ import { type CanvasToolbarConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; +import { + DEFAULT_LOVART_HOME_SIDEBAR_ITEMS, + defaultLovartHomeSidebarConfig, + getDefaultLovartHomeSidebarItem, + normalizeLovartHomeSidebarConfig, + type LovartHomeSidebarConfig, + type LovartHomeSidebarItemKey, +} from "@/lib/home/lovart-sidebar-config"; import { defaultAgentWorkflowUiConfig, normalizeAgentWorkflowUiConfig, @@ -902,6 +911,27 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record = chatStartGeneration: Sparkles, }; +const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record< + LovartHomeSidebarItemKey, + LucideIcon +> = { + newProject: Plus, + home: Home, + projects: FolderOpen, + profile: User, + gallery: ImageIcon, + tutorial: Play, + imageEditor: Edit, + aiCad: Box, + agent: Wand2, + videoCanvas: Video, + flowBoard: GitBranch, + productPlanning: BarChart3, + toolbox1: Wrench, + toolbox2: Wrench, + settings: Settings, +}; + type SiteSettings = { siteName: string; siteLogo: string | null; @@ -944,6 +974,7 @@ type SiteSettings = { realtimeCanvasEnabled: boolean; realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig; canvasToolbarConfig: CanvasToolbarConfig; + lovartHomeSidebarConfig: LovartHomeSidebarConfig; aiDetailGenerationEnabled: boolean; canvasDefaultModelId: string | null; canvasAutoModeDefaultModelId: string | null; @@ -1521,6 +1552,7 @@ export function AdminSettingsClient() { realtimeCanvasEnabled: true, realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig, canvasToolbarConfig: defaultCanvasToolbarConfig, + lovartHomeSidebarConfig: defaultLovartHomeSidebarConfig, aiDetailGenerationEnabled: false, canvasDefaultModelId: null, canvasAutoModeDefaultModelId: null, @@ -1668,6 +1700,9 @@ export function AdminSettingsClient() { canvasToolbarConfig: normalizeCanvasToolbarConfig( (siteSettingsData as any).canvasToolbarConfig ), + lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig( + (siteSettingsData as any).lovartHomeSidebarConfig + ), aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false, canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null, @@ -2672,6 +2707,8 @@ export function AdminSettingsClient() { useState(null); const [canvasToolbarIconUploading, setCanvasToolbarIconUploading] = useState(null); + const [lovartHomeSidebarIconUploading, setLovartHomeSidebarIconUploading] = + useState(null); const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{ enabled?: boolean; title?: string; @@ -2911,6 +2948,69 @@ export function AdminSettingsClient() { } }; + const updateLovartHomeSidebarItem = ( + key: LovartHomeSidebarItemKey, + updates: Partial<{ label: string; iconUrl: string; enabled: boolean }> + ) => { + setSiteSettings((prev) => { + const normalized = normalizeLovartHomeSidebarConfig( + prev.lovartHomeSidebarConfig + ); + return { + ...prev, + lovartHomeSidebarConfig: { + items: { + ...normalized.items, + [key]: { + ...normalized.items[key], + ...updates, + }, + }, + }, + }; + }); + }; + + const handleLovartHomeSidebarIconUpload = async ( + key: LovartHomeSidebarItemKey, + file: File + ) => { + const isSvg = + file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg"); + if (!isSvg) { + toast.error("请上传 SVG 图标文件"); + return; + } + + if (file.size > 512 * 1024) { + toast.error("SVG 图标不能超过 512KB"); + return; + } + + try { + setLovartHomeSidebarIconUploading(key); + const formData = new FormData(); + formData.append("file", file); + formData.append("purpose", "admin-svg-icon"); + + const response = await fetchUploadWithRetry(formData); + const result = await response.json(); + + if (!response.ok || !result?.url) { + throw new Error(result?.error || "上传失败"); + } + + updateLovartHomeSidebarItem(key, { + iconUrl: String(result.url || "").trim(), + }); + toast.success("首页侧边栏图标上传成功,请保存配置后生效"); + } catch (error: any) { + toast.error(error?.message || "首页侧边栏图标上传失败"); + } finally { + setLovartHomeSidebarIconUploading(null); + } + }; + const handleAddCanvasSkillTag = () => { setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]); }; @@ -10807,6 +10907,255 @@ export function AdminSettingsClient() { )} +
+
+
+
+ 首页侧边栏名称、SVG 图标与显隐 +
+
+ 管理 LovartHome 首页左侧悬浮侧边栏的按钮名称、SVG 图标和显示状态。上传 SVG 后,前端会根据明暗模式自动切换黑白色。 +
+
+ +
+
+ {DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.map((item) => { + const normalizedSidebarConfig = + normalizeLovartHomeSidebarConfig( + siteSettings.lovartHomeSidebarConfig + ); + const itemConfig = + normalizedSidebarConfig.items[item.key]; + const defaultItem = + getDefaultLovartHomeSidebarItem(item.key); + const FallbackIcon = + LOVART_HOME_SIDEBAR_FALLBACK_ICONS[item.key]; + const uploadId = `lovart-home-sidebar-icon-${item.key}`; + const isUploading = + lovartHomeSidebarIconUploading === item.key; + const isNewProject = item.key === "newProject"; + + return ( +
+
+
+
+ {itemConfig.iconUrl ? ( + {`${itemConfig.label}图标预览`} + ) : ( + + )} +
+
+
+ {itemConfig.label} +
+
+ {item.description} +
+
+
+ +
+
+
+
+ 显示该按钮 +
+
+ 关闭后首页侧边栏隐藏此功能入口 +
+
+ + updateLovartHomeSidebarItem(item.key, { + enabled: checked, + }) + } + /> +
+
+ + + updateLovartHomeSidebarItem(item.key, { + label: event.target.value.slice(0, 32), + }) + } + placeholder={defaultItem.label} + value={itemConfig.label} + /> +
+
+ + updateLovartHomeSidebarItem(item.key, { + iconUrl: event.target.value, + }) + } + placeholder="SVG 图标 URL(可选)" + value={itemConfig.iconUrl} + /> + + {itemConfig.iconUrl ? ( + + ) : null} + { + const file = event.target.files?.[0]; + if (file) { + await handleLovartHomeSidebarIconUpload( + item.key, + file + ); + } + event.currentTarget.value = ""; + }} + /> +
+
+ {isNewProject ? ( + <> +
+
+ {itemConfig.iconUrl ? ( + {`${itemConfig.label}浅色预览`} + ) : ( + + )} +
+
+
+
+ {itemConfig.iconUrl ? ( + {`${itemConfig.label}暗色预览`} + ) : ( + + )} +
+
+ + ) : ( + <> +
+ {itemConfig.iconUrl ? ( + {`${itemConfig.label}浅色预览`} + ) : ( + + )} +
+
+ {itemConfig.iconUrl ? ( + {`${itemConfig.label}暗色预览`} + ) : ( + + )} +
+ + )} +
+
+ ); + })} +
+
+
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 822376f1d..0e36d960a 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -470,6 +470,11 @@ export default function Page() { const displayLogo = mounted ? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo) : siteLogo; + const shouldInvertLogo = + mounted && + resolvedTheme === "dark" && + !siteLogoDark?.trim() && + Boolean(siteLogo?.trim()); const hasThirdPartyLogin = (isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) || (isGoogleEnabled && isGoogleConfigured); @@ -506,7 +511,11 @@ export default function Page() {
{displayLogo ? ( // eslint-disable-next-line @next/next/no-img-element - {siteName} + {siteName} ) : null}

登录 {siteName}

diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index c8df373d6..baa95192b 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -74,7 +74,8 @@ async function NewChatPage() { } isAuthenticated={Boolean(session?.user)} siteName={siteSettings.siteName || "AI Chatbot"} - siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""} + siteLogo={siteSettings.siteLogo || ""} + siteLogoDark={siteSettings.siteLogoDark || ""} surpriseCategories={surpriseCategories} heroPromoText={siteSettings.lovartPromoText} heroPromoActionText={siteSettings.lovartPromoActionText} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index f3fb3e021..fbe44ffa5 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -88,6 +88,11 @@ import { defaultCanvasToolbarConfig, normalizeCanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; +import { + LOVART_HOME_SIDEBAR_CONFIG_KEY, + defaultLovartHomeSidebarConfig, + normalizeLovartHomeSidebarConfig, +} from "@/lib/home/lovart-sidebar-config"; import { defaultOssImageMigrationConfig, normalizeOssImageMigrationConfig, @@ -459,6 +464,7 @@ export async function GET() { realtimeCanvasConfigSetting, realtimeCanvasModeIconConfigSetting, canvasToolbarConfigSetting, + lovartHomeSidebarConfigSetting, layerSplitConfigSetting, canvasAgentModeEnabledSetting, videoCanvasEnabledSetting, @@ -575,6 +581,12 @@ export async function GET() { .where(eq(systemSettings.key, CANVAS_TOOLBAR_CONFIG_SETTING_KEY)) .limit(1) .then(rows => rows[0]), + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, LOVART_HOME_SIDEBAR_CONFIG_KEY)) + .limit(1) + .then(rows => rows[0]), db .select() .from(systemSettings) @@ -713,6 +725,9 @@ export async function GET() { const canvasToolbarConfig = normalizeCanvasToolbarConfig( canvasToolbarConfigSetting?.value ); + const lovartHomeSidebarConfig = normalizeLovartHomeSidebarConfig( + lovartHomeSidebarConfigSetting?.value + ); const layerSplitEnabled = normalizeLayerSplitEnabled( layerSplitConfigSetting?.value ); @@ -822,6 +837,7 @@ export async function GET() { realtimeCanvasEnabled: true, realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig, canvasToolbarConfig: defaultCanvasToolbarConfig, + lovartHomeSidebarConfig: defaultLovartHomeSidebarConfig, realtimeCanvasServiceEnabled: true, layerSplitEnabled, storageFailoverConfig, @@ -884,6 +900,7 @@ export async function GET() { realtimeCanvasServiceEnabled, realtimeCanvasModeIconConfig, canvasToolbarConfig, + lovartHomeSidebarConfig, layerSplitEnabled, canvasAgentModeEnabled, videoCanvasEnabled, @@ -1255,6 +1272,19 @@ const updateSchema = z.object({ .optional(), }) .optional(), + lovartHomeSidebarConfig: z + .object({ + items: z + .record( + z.object({ + label: z.string().trim().max(32).optional(), + iconUrl: z.string().trim().max(2000).optional(), + enabled: z.boolean().optional(), + }) + ) + .optional(), + }) + .optional(), aiDetailGenerationEnabled: z.boolean().optional(), canvasDefaultModelId: z.string().nullable().optional(), canvasAutoModeDefaultModelId: z.string().nullable().optional(), @@ -1383,6 +1413,7 @@ export async function PUT(request: NextRequest) { ossImageMigrationConfig, realtimeCanvasModeIconConfig, canvasToolbarConfig, + lovartHomeSidebarConfig, canvasAgentModeEnabled, videoCanvasEnabled, videoCanvasSidebarEnabled, @@ -2059,6 +2090,13 @@ export async function PUT(request: NextRequest) { ); } + if (lovartHomeSidebarConfig !== undefined) { + await persistSystemSetting( + LOVART_HOME_SIDEBAR_CONFIG_KEY, + normalizeLovartHomeSidebarConfig(lovartHomeSidebarConfig) + ); + } + await persistHomeAnimationConfig(); return NextResponse.json({ @@ -2167,6 +2205,10 @@ export async function PUT(request: NextRequest) { canvasToolbarConfig !== undefined ? normalizeCanvasToolbarConfig(canvasToolbarConfig) : defaultCanvasToolbarConfig, + lovartHomeSidebarConfig: + lovartHomeSidebarConfig !== undefined + ? normalizeLovartHomeSidebarConfig(lovartHomeSidebarConfig) + : defaultLovartHomeSidebarConfig, canvasAgentModeEnabled: canvasAgentModeEnabled !== undefined ? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled) @@ -2677,6 +2719,13 @@ export async function PUT(request: NextRequest) { ); } + if (lovartHomeSidebarConfig !== undefined) { + await persistSystemSetting( + LOVART_HOME_SIDEBAR_CONFIG_KEY, + normalizeLovartHomeSidebarConfig(lovartHomeSidebarConfig) + ); + } + if (videoCanvasEnabled !== undefined) { const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled); await db @@ -2803,6 +2852,10 @@ export async function PUT(request: NextRequest) { canvasToolbarConfig !== undefined ? normalizeCanvasToolbarConfig(canvasToolbarConfig) : defaultCanvasToolbarConfig, + lovartHomeSidebarConfig: + lovartHomeSidebarConfig !== undefined + ? normalizeLovartHomeSidebarConfig(lovartHomeSidebarConfig) + : defaultLovartHomeSidebarConfig, canvasAgentModeEnabled: canvasAgentModeEnabled !== undefined ? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled) diff --git a/app/globals.css b/app/globals.css index 4a5eef878..6c866e127 100644 --- a/app/globals.css +++ b/app/globals.css @@ -13,6 +13,7 @@ /* define design tokens (light mode) */ :root { + color-scheme: light; --font-geist: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Helvetica Neue", Arial, sans-serif; --font-geist-mono: "SFMono-Regular", "SF Mono", Menlo, Monaco, Consolas, @@ -52,10 +53,14 @@ /* border radius unit */ --radius: 0.5rem; --sidebar: hsl(0 0% 98%); + --chat-user-bubble-bg: hsl(214 100% 50%); + --chat-user-bubble-foreground: hsl(0 0% 100%); + --chat-user-bubble-border: hsl(214 100% 46%); } /* define design tokens (dark mode) */ .dark { + color-scheme: dark; --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); --card: hsl(240 10% 3.9%); @@ -89,6 +94,9 @@ --sidebar-border: hsl(240 3.7% 15.9%); --sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar: hsl(240 5.9% 10%); + --chat-user-bubble-bg: hsl(217 92% 58%); + --chat-user-bubble-foreground: hsl(0 0% 100%); + --chat-user-bubble-border: hsl(217 92% 66% / 0.42); } /* define theme */ diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index e18ee88c5..4bdfbe856 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -58,6 +58,7 @@ type Model3DViewerProps = { onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; resetViewToken?: number; + onCaptureReady?: (capture: (() => Promise) | null) => void; }; const CAMERA_PRESET_POSITIONS: Record< @@ -233,6 +234,46 @@ function ModelScene({ return ; } +function ViewerCaptureBridge({ + onCaptureReady, +}: { + onCaptureReady?: (capture: (() => Promise) | null) => void; +}) { + const { camera, gl, scene } = useThree(); + + useEffect(() => { + if (!onCaptureReady) return; + + onCaptureReady(async () => { + const canvas = gl.domElement; + gl.render(scene, camera); + + const blob = await new Promise((resolve) => { + if (typeof canvas.toBlob === "function") { + canvas.toBlob(resolve, "image/png"); + return; + } + + try { + const dataUrl = canvas.toDataURL("image/png"); + fetch(dataUrl) + .then((response) => response.blob()) + .then((result) => resolve(result)) + .catch(() => resolve(null)); + } catch { + resolve(null); + } + }); + + return blob; + }); + + return () => onCaptureReady(null); + }, [camera, gl, onCaptureReady, scene]); + + return null; +} + function isGlbOrGltf(url?: string | null) { const value = String(url || "").toLowerCase(); return /\.(gltf|glb)(\?|#|$)/.test(value); @@ -279,6 +320,7 @@ export function Model3DViewer({ onLayersChange, onStatsChange, resetViewToken, + onCaptureReady, }: Model3DViewerProps) { const controlsRef = useRef(null); const resolvedModelUrl = useMemo(() => { @@ -348,7 +390,9 @@ export function Model3DViewer({ fov: 45, }} className="bg-gradient-to-b from-zinc-900 to-zinc-950" + gl={{ preserveDrawingBuffer: true }} > + state.selectedNodeIds); + const { data: modelAliasConfigResponse } = useSWR<{ + success?: boolean; + data?: PublicThreeDModelAliasConfig; + }>("/api/canvas/model-aliases", fetcher); + const publicModelAliasConfig = + modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; + const tripoModelOptions = useMemo(() => { + const enabledEntries = publicModelAliasConfig.tripoModels.filter( + (entry) => entry.enabled !== false + ); + return enabledEntries.length > 0 + ? enabledEntries + : publicModelAliasConfig.tripoModels; + }, [publicModelAliasConfig.tripoModels]); + const worldModelOptions = useMemo(() => { + const enabledEntries = publicModelAliasConfig.worldModels.filter( + (entry) => entry.enabled !== false + ); + return enabledEntries.length > 0 + ? enabledEntries + : publicModelAliasConfig.worldModels; + }, [publicModelAliasConfig.worldModels]); const [isHovered, setIsHovered] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [angleDialogOpen, setAngleDialogOpen] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); + const [threedGenerationDialogMode, setThreedGenerationDialogMode] = useState< + "model" | "world" | null + >(null); + const [selectedTripoModelVersion, setSelectedTripoModelVersion] = useState( + DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard" + ); + const [selectedWorldModelVersion, setSelectedWorldModelVersion] = useState( + DEFAULT_CANVAS_WORLD_MODEL || "world-standard" + ); const [regeneratePrompt, setRegeneratePrompt] = useState(""); const [isUploading, setIsUploading] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false); @@ -532,6 +572,32 @@ export function ImageNode({ setSceneFusionDialogOpen(false); } }, [batchProcessingEnabled]); + useEffect(() => { + const nextDefaultTripoModelId = + getDefaultThreeDModelAliasId(publicModelAliasConfig.tripoModels) || + DEFAULT_CANVAS_TRIPO_MODEL || + "tripo-standard"; + if (!tripoModelOptions.some((entry) => entry.id === selectedTripoModelVersion)) { + setSelectedTripoModelVersion(nextDefaultTripoModelId); + } + }, [ + publicModelAliasConfig.tripoModels, + selectedTripoModelVersion, + tripoModelOptions, + ]); + useEffect(() => { + const nextDefaultWorldModelId = + getDefaultThreeDModelAliasId(publicModelAliasConfig.worldModels) || + DEFAULT_CANVAS_WORLD_MODEL || + "world-standard"; + if (!worldModelOptions.some((entry) => entry.id === selectedWorldModelVersion)) { + setSelectedWorldModelVersion(nextDefaultWorldModelId); + } + }, [ + publicModelAliasConfig.worldModels, + selectedWorldModelVersion, + worldModelOptions, + ]); const [boxCutoutMetrics, setBoxCutoutMetrics] = useState(null); @@ -560,6 +626,20 @@ export function ImageNode({ (state) => state.clearImageNodeActionRequest ); const allNodes = useCanvasStore((state) => state.nodes); + const selectedTripoModelEntry = useMemo( + () => + tripoModelOptions.find((entry) => entry.id === selectedTripoModelVersion) || + tripoModelOptions[0] || + null, + [selectedTripoModelVersion, tripoModelOptions] + ); + const selectedWorldModelEntry = useMemo( + () => + worldModelOptions.find((entry) => entry.id === selectedWorldModelVersion) || + worldModelOptions[0] || + null, + [selectedWorldModelVersion, worldModelOptions] + ); // 🆕 物品拾取相关状态 const isPickerMode = useCanvasStore((state) => state.isPickerMode); @@ -2377,6 +2457,55 @@ export function ImageNode({ return names[action] || action; }; + const openThreedGenerationDialog = useCallback( + (mode: "model" | "world") => { + if (!data.src) { + toast.error("请先上传图片"); + return; + } + if (!isHttpImageUrl(data.src)) { + toast.error("请选择一张可访问的 URL 图片"); + return; + } + + if (mode === "model") { + if (tripoModelOptions.length === 0) { + toast.error("当前没有可用的 3D 模型"); + return; + } + } else if (worldModelOptions.length === 0) { + toast.error("当前没有可用的 3D 世界模型"); + return; + } + + setThreedGenerationDialogMode(mode); + }, + [data.src, tripoModelOptions.length, worldModelOptions.length] + ); + + async function handleConfirmThreedGeneration() { + if (threedGenerationDialogMode === "model") { + const targetModelVersion = + selectedTripoModelEntry?.id || + selectedTripoModelVersion || + DEFAULT_CANVAS_TRIPO_MODEL || + "tripo-standard"; + setThreedGenerationDialogMode(null); + await handleGenerateCanvas3DModel(targetModelVersion); + return; + } + + if (threedGenerationDialogMode === "world") { + const targetModelVersion = + selectedWorldModelEntry?.id || + selectedWorldModelVersion || + DEFAULT_CANVAS_WORLD_MODEL || + "world-standard"; + setThreedGenerationDialogMode(null); + await handleGenerateCanvas3DWorld(targetModelVersion); + } + } + // 处理图片编辑操作 - 集成我们现有的 API const handleAction = async (action: string) => { if (!data.src) { @@ -2460,11 +2589,11 @@ export function ImageNode({ return; } if (action === "generate-3d-model") { - await handleGenerateCanvas3DModel(); + openThreedGenerationDialog("model"); return; } if (action === "generate-3d-world") { - await handleGenerateCanvas3DWorld(); + openThreedGenerationDialog("world"); return; } @@ -3386,7 +3515,7 @@ export function ImageNode({ [persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode] ); - const handleGenerateCanvas3DModel = useCallback(async () => { + const handleGenerateCanvas3DModel = useCallback(async (requestedModelVersion?: string) => { if (!data.src || !isHttpImageUrl(data.src)) { toast.error("请选择一张可访问的 URL 图片"); return; @@ -3397,7 +3526,8 @@ export function ImageNode({ } const promptText = (data.prompt || displayTitle || "图生 3D 模型").trim(); - const modelVersion = DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard"; + const modelVersion = + requestedModelVersion || DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard"; const sourceImageUrl = data.src; const newNodeId = addNode({ type: "model3d", @@ -3535,7 +3665,7 @@ export function ImageNode({ updateNode, ]); - const handleGenerateCanvas3DWorld = useCallback(async () => { + const handleGenerateCanvas3DWorld = useCallback(async (requestedModelVersion?: string) => { if (!data.src || !isHttpImageUrl(data.src)) { toast.error("请选择一张可访问的 URL 图片"); return; @@ -3546,7 +3676,8 @@ export function ImageNode({ } const promptText = (data.prompt || displayTitle || "图生 3D 世界").trim(); - const modelVersion = DEFAULT_CANVAS_WORLD_MODEL || "world-standard"; + const modelVersion = + requestedModelVersion || DEFAULT_CANVAS_WORLD_MODEL || "world-standard"; const sourceImageUrl = data.src; const newNodeId = addNode({ type: "world3d", @@ -4456,6 +4587,111 @@ export function ImageNode({ +

{ + if (!open) { + setThreedGenerationDialogMode(null); + } + }} + open={threedGenerationDialogMode !== null} + > + + + + {threedGenerationDialogMode === "world" ? ( + + ) : ( + + )} + {threedGenerationDialogMode === "world" + ? "选择 3D 世界模型" + : "选择 3D 模型"} + + +
+

+ {threedGenerationDialogMode === "world" + ? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。" + : "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"} +

+
+ + +
+
+
+ {threedGenerationDialogMode === "world" + ? selectedWorldModelEntry?.displayName || "未选择模型" + : selectedTripoModelEntry?.displayName || "未选择模型"} +
+
+ {threedGenerationDialogMode === "world" + ? selectedWorldModelEntry?.description || "当前模型暂无说明。" + : selectedTripoModelEntry?.description || "当前模型暂无说明。"} +
+
+
+ + + + +
+
+ {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {isSelected && !isMultiSelected && @@ -4463,6 +4699,7 @@ export function ImageNode({ !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && + !threedGenerationDialogMode && !notePanelOpen && !boxCutoutMode && typeof document !== "undefined" && diff --git a/components/canvas/nodes/Model3DNode.tsx b/components/canvas/nodes/Model3DNode.tsx index c6f8efd45..152464b99 100644 --- a/components/canvas/nodes/Model3DNode.tsx +++ b/components/canvas/nodes/Model3DNode.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/dropdown-menu"; import useSWR from "swr"; import { toast } from "sonner"; +import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, resolveThreeDModelDisplayName, @@ -129,6 +130,7 @@ export function Model3DNode({ canvasToolbarConfig, "model3dDelete" ); + const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; @@ -157,6 +159,32 @@ export function Model3DNode({ const primaryOpenLabel = primaryOpenItem.label; const shouldRenderLiveViewer = isSelected && data.status === "completed" && Boolean(data.src); + const placeholderSizeScale = useMemo( + () => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76), + [node.size.height, node.size.width] + ); + const placeholderViewportScale = useMemo( + () => + Math.min( + 1.55, + Math.max(0.72, placeholderSizeScale / Math.max(viewportZoom, 0.42)) + ), + [placeholderSizeScale, viewportZoom] + ); + const placeholderCenterStyle = useMemo( + () => ({ + transform: `scale(${placeholderViewportScale})`, + transformOrigin: "center center", + }), + [placeholderViewportScale] + ); + const placeholderBadgeStyle = useMemo( + () => ({ + transform: `scale(${placeholderViewportScale})`, + transformOrigin: "top left", + }), + [placeholderViewportScale] + ); const handleOpen = () => { if (!primaryOpenUrl) { @@ -216,22 +244,46 @@ export function Model3DNode({ onMouseLeave={() => setIsHovered(false)} > {data.status === "generating" ? ( -
- - - {data.metadata?.provider === "worldlabs-3d" - ? "3D 世界生成中..." - : "3D 生成中..."} - +
+
+ + + +
+
+ + + + + {data.metadata?.provider === "worldlabs-3d" + ? "3D 世界生成中..." + : "3D 模型生成中..."} + + + {data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel} + +
) : data.status === "failed" ? ( -
- - - {data.metadata?.provider === "worldlabs-3d" - ? "3D 世界生成失败" - : "3D 生成失败"} - +
+
+ + + +
+
+ + + + + {data.metadata?.provider === "worldlabs-3d" + ? "3D 世界生成失败" + : "3D 模型生成失败"} + + + 可以重新选择模型后再试一次 + +
) : (
diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 246bd393e..a281c787a 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { AlertCircle, Box, + Camera, Copy, Crosshair, Download, @@ -32,12 +33,14 @@ import { resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; +import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { getCanvasToolbarItem, type CanvasToolbarConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { toast } from "sonner"; +import { imageGenerationService } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; @@ -125,6 +128,10 @@ function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) { } } +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + export function World3DNode({ node, isSelected, @@ -133,8 +140,15 @@ export function World3DNode({ const data = node.data as World3DNodeData; const [isHovered, setIsHovered] = useState(false); const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate"); + const [viewerCapture, setViewerCapture] = useState<(() => Promise) | null>( + null + ); + const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); + const addNode = useCanvasStore((state) => state.addNode); + const selectNode = useCanvasStore((state) => state.selectNode); + const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const world3dFirstPersonItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dFirstPerson" @@ -182,6 +196,34 @@ export function World3DNode({ const primaryUrl = resolvePrimaryUrl(data, activeView); const downloadTarget = inferDownloadTarget(data, activeView); const hotspotCount = data.hotspots?.length || 0; + const placeholderSizeScale = useMemo( + () => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76), + [node.size.height, node.size.width] + ); + const placeholderViewportScale = useMemo( + () => + clampNumber( + placeholderSizeScale / Math.max(viewportZoom, 0.42), + 0.72, + 1.55 + ), + [placeholderSizeScale, viewportZoom] + ); + const placeholderCenterStyle = useMemo( + () => ({ + transform: `scale(${placeholderViewportScale})`, + transformOrigin: "center center", + }), + [placeholderViewportScale] + ); + const placeholderBadgeStyle = useMemo( + () => ({ + transform: `scale(${placeholderViewportScale})`, + transformOrigin: "top left", + }), + [placeholderViewportScale] + ); + const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); const handleSetView = (view: World3DViewMode) => { updateNode(node.id, { @@ -266,6 +308,84 @@ export function World3DNode({ link.click(); }; + const handleViewerCaptureReady = useCallback( + (capture: (() => Promise) | null) => { + setViewerCapture(() => capture); + }, + [] + ); + + const handleCaptureSnapshot = useCallback(async () => { + if (!viewerCapture) { + toast.error("当前视图暂不支持拍照"); + return; + } + + setIsCapturingSnapshot(true); + try { + const blob = await viewerCapture(); + if (!blob) { + throw new Error("当前世界画面抓取失败"); + } + + const file = new File([blob], `world-snapshot-${Date.now()}.png`, { + type: blob.type || "image/png", + }); + const uploadResult = await imageGenerationService.uploadImage(file); + if (!uploadResult.success || !uploadResult.data?.url) { + throw new Error(uploadResult.error || "上传拍照结果失败"); + } + + const snapshotWidth = Math.max(320, Math.min(640, Math.round(node.size.width * 0.9))); + const snapshotHeight = Math.max( + 220, + Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1))) + ); + + const newNodeId = addNode({ + type: "image", + position: { + x: node.position.x + node.size.width + 24, + y: node.position.y + 12, + }, + size: { width: snapshotWidth, height: snapshotHeight }, + data: { + src: uploadResult.data.url, + assetId: uploadResult.data.assetId, + title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`, + prompt: data.prompt || "3D 世界拍照", + status: "completed", + originalSrc: uploadResult.data.url, + metadata: { + sourceWorldNodeId: node.id, + sourceWorldView: activeView, + sourceWorldPrompt: data.prompt || "", + captureMode: "world3d", + }, + }, + connections: [node.id], + }); + selectNode(newNodeId); + toast.success("已将当前世界视角添加到画布"); + } catch (error) { + const message = error instanceof Error ? error.message : "世界拍照失败"; + toast.error(message); + } finally { + setIsCapturingSnapshot(false); + } + }, [ + activeView, + addNode, + data.prompt, + node.id, + node.position.x, + node.position.y, + node.size.height, + node.size.width, + selectNode, + viewerCapture, + ]); + return (
setIsHovered(false)} > {data.status === "generating" ? ( -
- - 3D 世界生成中... +
+
+ + + +
+
+ + + + + 3D 世界生成中... + + + {worldModelLabel || "世界资产正在构建中"} + +
) : data.status === "failed" ? ( -
- - 3D 世界生成失败 +
+
+ + + +
+
+ + + + + 3D 世界生成失败 + + + 可以重新选择模型后再试一次 + +
) : ( <> @@ -305,6 +453,7 @@ export function World3DNode({ semanticsMetadata={data.semanticsMetadata} splatUrl={data.splatUrl} worldUrl={data.worldUrl} + onCaptureReady={handleViewerCaptureReady} />
@@ -397,6 +546,26 @@ export function World3DNode({
) : null} + + {captureEnabled ? ( +
+ +
+ ) : null}
{isHovered && data.status === "completed" && ( diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 678cbc480..b24436750 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -74,6 +74,7 @@ type World3DViewerProps = { semanticsMetadata?: Record | null; className?: string; resetViewToken?: number; + onCaptureReady?: (capture: (() => Promise) | null) => void; }; const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { @@ -975,6 +976,46 @@ function WorldSplatScene({ ); } +function WorldSplatCaptureBridge({ + onCaptureReady, +}: { + onCaptureReady?: (capture: (() => Promise) | null) => void; +}) { + const { camera, gl, scene } = useThree(); + + useEffect(() => { + if (!onCaptureReady) return; + + onCaptureReady(async () => { + const canvas = gl.domElement; + gl.render(scene, camera); + + const blob = await new Promise((resolve) => { + if (typeof canvas.toBlob === "function") { + canvas.toBlob(resolve, "image/png"); + return; + } + + try { + const dataUrl = canvas.toDataURL("image/png"); + fetch(dataUrl) + .then((response) => response.blob()) + .then((result) => resolve(result)) + .catch(() => resolve(null)); + } catch { + resolve(null); + } + }); + + return blob; + }); + + return () => onCaptureReady(null); + }, [camera, gl, onCaptureReady, scene]); + + return null; +} + function WorldSplatViewer({ splatUrl, previewImageUrl, @@ -985,6 +1026,7 @@ function WorldSplatViewer({ onHotspotSelect, semanticsMetadata, resetViewToken, + onCaptureReady, }: { splatUrl?: string | null; previewImageUrl?: string | null; @@ -995,6 +1037,7 @@ function WorldSplatViewer({ onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; resetViewToken?: number; + onCaptureReady?: (capture: (() => Promise) | null) => void; }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); @@ -1046,8 +1089,9 @@ function WorldSplatViewer({ camera={{ position: [0, 1.6, 4], fov: 55 }} className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]" frameloop="always" - gl={{ antialias: false }} + gl={{ antialias: false, preserveDrawingBuffer: true }} > + { + if (mode === "splat" || mode === "mesh") return; + onCaptureReady?.(null); + }, [mode, onCaptureReady]); + if (mode === "mesh") { return (
@@ -1101,6 +1151,7 @@ export function World3DViewer({ modelUrl={meshUrl} onLayersChange={onMeshLayersChange} onStatsChange={onMeshStatsChange} + onCaptureReady={onCaptureReady} previewImageUrl={previewImageUrl} resetViewToken={resetViewToken} settings={meshSettings || DEFAULT_MESH_SETTINGS} @@ -1165,6 +1216,7 @@ export function World3DViewer({ firstPerson={firstPerson} hotspots={hotspots} interactionMode={interactionMode} + onCaptureReady={onCaptureReady} onHotspotSelect={onHotspotSelect} onPick={onPick} previewImageUrl={previewImageUrl} diff --git a/components/home/floating-sidebar-shell.tsx b/components/home/floating-sidebar-shell.tsx index 91361aa03..28d9a1d4d 100644 --- a/components/home/floating-sidebar-shell.tsx +++ b/components/home/floating-sidebar-shell.tsx @@ -57,7 +57,7 @@ export function FloatingLogoPill({ "pointer-events-auto flex items-center gap-2 text-left transition", isPlain ? "h-8 hover:opacity-70" - : "h-[52px] w-[52px] justify-center rounded-full border border-black/10 bg-white px-2.5 shadow hover:bg-black/[0.03] hover:shadow-md lg:w-[164px] lg:justify-start lg:px-4" + : "h-[52px] w-[52px] justify-center rounded-full border border-black/10 bg-white px-2.5 shadow hover:bg-black/[0.03] hover:shadow-md dark:border-white/10 dark:bg-[#141416] dark:hover:bg-white/[0.04] lg:w-[164px] lg:justify-start lg:px-4" )} onClick={onClick} type="button" @@ -69,13 +69,13 @@ export function FloatingLogoPill({ src={siteLogo} /> ) : ( -
+
{siteName.slice(0, 1).toUpperCase()}
)} @@ -113,7 +113,7 @@ export function FloatingSidebarRail({ return (
- @@ -1258,12 +1398,12 @@ export function LovartHome({
-
+
{currentPlan?.name || "Free"}
@@ -1275,16 +1415,16 @@ export function LovartHome({ 升级
-
+
- + 积分 {membershipData?.summary?.currentCredits ?? 0}
- + 每日免费积分 @@ -1293,7 +1433,7 @@ export function LovartHome({
- - 新建项目 - + {isLovartSidebarItemEnabled("newProject") ? ( + + + + + + {getLovartSidebarItem("newProject").label} + + + ) : null} - - - - - 首页 - - - - - - 项目列表 - - - - - - 个人主页 - - - - - - 个人相册 - - {siteSettingsData?.tutorialEnabled ? ( + {isLovartSidebarItemEnabled("home") ? ( + + + + + + {getLovartSidebarItem("home").label} + + + ) : null} + {isLovartSidebarItemEnabled("projects") ? ( + + + + + + {getLovartSidebarItem("projects").label} + + + ) : null} + {isLovartSidebarItemEnabled("profile") ? ( + + + + + + {getLovartSidebarItem("profile").label} + + + ) : null} + {isLovartSidebarItemEnabled("gallery") ? ( + + + + + + {getLovartSidebarItem("gallery").label} + + + ) : null} + {isLovartSidebarItemEnabled("tutorial") && + siteSettingsData?.tutorialEnabled ? ( -
视频教程
+
+ {getLovartSidebarItem("tutorial").label} +
{tutorialVideos.length > 0 ? ( tutorialVideos.map((video) => ( )) ) : ( -
暂无教程视频
+
暂无教程视频
)}
) : null} - - - - - 图片编辑 - - - - - - AI-CAD - - {siteSettingsData?.agentEnabled !== false ? ( + {isLovartSidebarItemEnabled("imageEditor") ? ( + + + {getLovartSidebarItem("imageEditor").label} + + + ) : null} + {isLovartSidebarItemEnabled("aiCad") ? ( + + + + + + {getLovartSidebarItem("aiCad").label} + + + ) : null} + {isLovartSidebarItemEnabled("agent") && + siteSettingsData?.agentEnabled !== false ? ( + + + - 风格化 + + {getLovartSidebarItem("agent").label} + ) : null} - {siteSettingsData?.videoCanvasEnabled !== false ? ( + {isLovartSidebarItemEnabled("videoCanvas") && + siteSettingsData?.videoCanvasEnabled !== false ? ( - 视频画布 + + {getLovartSidebarItem("videoCanvas").label} + ) : null} - - - - - 流程白板 - - {siteSettingsData?.productPlanningEnabled !== false ? ( + {isLovartSidebarItemEnabled("flowBoard") ? ( - 商品策划 + + {getLovartSidebarItem("flowBoard").label} + ) : null} - {siteSettingsData?.toolbox1Enabled !== false && toolbox1Links.length > 0 ? ( + {isLovartSidebarItemEnabled("productPlanning") && + siteSettingsData?.productPlanningEnabled !== false ? ( + + + + + + {getLovartSidebarItem("productPlanning").label} + + + ) : null} + {isLovartSidebarItemEnabled("toolbox1") && + siteSettingsData?.toolbox1Enabled !== false && + toolbox1Links.length > 0 ? ( -
- {siteSettingsData?.toolbox1Name || "工具箱"} +
+ {getLovartSidebarItem("toolbox1").label}
{toolbox1Links.map((link) => ( ))}
) : null} - {siteSettingsData?.toolbox2Enabled === true && toolbox2Links.length > 0 ? ( + {isLovartSidebarItemEnabled("toolbox2") && + siteSettingsData?.toolbox2Enabled === true && + toolbox2Links.length > 0 ? ( -
- {siteSettingsData?.toolbox2Name || "工具箱2"} +
+ {getLovartSidebarItem("toolbox2").label}
{toolbox2Links.map((link) => ( ))}
) : null} - {user?.type === "admin" || user?.type === "agent_admin" ? ( + {isLovartSidebarItemEnabled("settings") && + (user?.type === "admin" || user?.type === "agent_admin") ? ( - 系统设置 + + {getLovartSidebarItem("settings").label} + ) : null} @@ -1726,47 +1935,51 @@ export function LovartHome({ ) : ( -
+
NEW {heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
)}
- {siteLogo ? ( - {siteName} + {displayLogo ? ( + {siteName} ) : ( -
+
)} -

+

{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}

-

+

{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}

-
+