feat: improve lovart home and 3d canvas flows
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-24 12:58:33 +08:00
parent d6d8bf021f
commit 560a1999a0
15 changed files with 1697 additions and 346 deletions

View File

@ -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<CanvasToolbarItemKey, LucideIcon> =
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<RealtimeCanvasModeIconKey | null>(null);
const [canvasToolbarIconUploading, setCanvasToolbarIconUploading] =
useState<CanvasToolbarItemKey | null>(null);
const [lovartHomeSidebarIconUploading, setLovartHomeSidebarIconUploading] =
useState<LovartHomeSidebarItemKey | null>(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() {
</div>
)}
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">
首页侧边栏名称、SVG 图标与显隐
</div>
<div className="text-xs text-muted-foreground">
管理 LovartHome 首页左侧悬浮侧边栏的按钮名称、SVG 图标和显示状态。上传 SVG 后,前端会根据明暗模式自动切换黑白色。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setSiteSettings((prev) => ({
...prev,
lovartHomeSidebarConfig:
defaultLovartHomeSidebarConfig,
}))
}
>
恢复全部默认
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{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 (
<div
key={item.key}
className="space-y-3 rounded-xl border bg-muted/10 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-background">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}图标预览`}
className="h-4 w-4 object-contain brightness-0 opacity-75 dark:invert"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 space-y-0.5">
<div className="truncate text-sm font-medium">
{itemConfig.label}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{item.description}
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground"
onClick={() =>
updateLovartHomeSidebarItem(item.key, {
label: defaultItem.label,
iconUrl: "",
enabled: true,
})
}
>
默认
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<div className="text-xs font-medium">
显示该按钮
</div>
<div className="text-[11px] text-muted-foreground">
关闭后首页侧边栏隐藏此功能入口
</div>
</div>
<Switch
checked={itemConfig.enabled}
onCheckedChange={(checked) =>
updateLovartHomeSidebarItem(item.key, {
enabled: checked,
})
}
/>
</div>
<div className="space-y-1.5">
<Label
className="text-xs text-muted-foreground"
htmlFor={`${uploadId}-label`}
>
功能显示名称
</Label>
<Input
id={`${uploadId}-label`}
maxLength={32}
onChange={(event) =>
updateLovartHomeSidebarItem(item.key, {
label: event.target.value.slice(0, 32),
})
}
placeholder={defaultItem.label}
value={itemConfig.label}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-[180px] flex-1"
onChange={(event) =>
updateLovartHomeSidebarItem(item.key, {
iconUrl: event.target.value,
})
}
placeholder="SVG 图标 URL(可选)"
value={itemConfig.iconUrl}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9"
disabled={isUploading}
onClick={() =>
document
.getElementById(uploadId)
?.click()
}
>
{isUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
"上传 SVG"
)}
</Button>
{itemConfig.iconUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 px-2 text-muted-foreground"
onClick={() =>
updateLovartHomeSidebarItem(item.key, {
iconUrl: "",
})
}
>
清除
</Button>
) : null}
<input
id={uploadId}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleLovartHomeSidebarIconUpload(
item.key,
file
);
}
event.currentTarget.value = "";
}}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{isNewProject ? (
<>
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-black/10 bg-white">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}浅色预览`}
className="h-4 w-4 object-contain brightness-0 invert"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-white" />
)}
</div>
</div>
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-[#141416]">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white text-black">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}暗色预览`}
className="h-4 w-4 object-contain brightness-0"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-black" />
)}
</div>
</div>
</>
) : (
<>
<div className="inline-flex h-10 w-10 items-center justify-center rounded-full border bg-white text-[#111827]">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}浅色预览`}
className="h-[18px] w-[18px] object-contain brightness-0 opacity-80"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-[18px] w-[18px] text-[#111827]" />
)}
</div>
<div className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}暗色预览`}
className="h-[18px] w-[18px] object-contain brightness-0 invert opacity-85"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-[18px] w-[18px] text-white" />
)}
</div>
</>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="space-y-4 rounded-lg border p-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">

View File

@ -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() {
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16 backdrop-blur-sm bg-background/80 rounded-2xl p-6 pointer-events-auto">
{displayLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={displayLogo} alt={siteName} className="h-12 w-auto mb-2 object-contain" />
<img
src={displayLogo}
alt={siteName}
className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
/>
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">

View File

@ -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}

View File

@ -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)

View File

@ -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 */

View File

@ -58,6 +58,7 @@ type Model3DViewerProps = {
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
};
const CAMERA_PRESET_POSITIONS: Record<
@ -233,6 +234,46 @@ function ModelScene({
return <primitive object={scene} scale={1} />;
}
function ViewerCaptureBridge({
onCaptureReady,
}: {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | 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<Blob | null>((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<any>(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 }}
>
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
<CameraPresetController
controlsRef={controlsRef}
preset={settings.cameraPreset}

View File

@ -30,6 +30,7 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
@ -54,6 +55,13 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
@ -74,6 +82,7 @@ import { inferOriginalImageUrl } from "@/lib/image-url";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils";
import {
@ -84,7 +93,7 @@ import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
import { cn, fetcher } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
NormalizedCropRect,
@ -485,12 +494,43 @@ export function ImageNode({
? data.metadata.detailProgressLabel.trim()
: "";
const selectedNodeIds = useCanvasStore((state) => 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<ImageDisplayMetrics | null>(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({
</DialogContent>
</Dialog>
<Dialog
onOpenChange={(open) => {
if (!open) {
setThreedGenerationDialogMode(null);
}
}}
open={threedGenerationDialogMode !== null}
>
<DialogContent
className="border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none sm:max-w-[560px] dark:border-slate-700 dark:bg-slate-950"
overlayClassName="bg-black"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{threedGenerationDialogMode === "world" ? (
<Globe2 className="h-5 w-5" />
) : (
<Box className="h-5 w-5" />
)}
{threedGenerationDialogMode === "world"
? "选择 3D 世界模型"
: "选择 3D 模型"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-muted-foreground text-sm">
{threedGenerationDialogMode === "world"
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
</p>
<div className="space-y-2">
<Label>
{threedGenerationDialogMode === "world" ? "世界模型" : "模型版本"}
</Label>
<Select
onValueChange={(value) => {
if (threedGenerationDialogMode === "world") {
setSelectedWorldModelVersion(value);
return;
}
setSelectedTripoModelVersion(value);
}}
value={
threedGenerationDialogMode === "world"
? selectedWorldModelVersion
: selectedTripoModelVersion
}
>
<SelectTrigger className="border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-950">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950">
{(threedGenerationDialogMode === "world"
? worldModelOptions
: tripoModelOptions
).map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
<div className="font-medium text-slate-900 dark:text-slate-100">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.displayName || "未选择模型"
: selectedTripoModelEntry?.displayName || "未选择模型"}
</div>
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
{threedGenerationDialogMode === "world"
? selectedWorldModelEntry?.description || "当前模型暂无说明。"
: selectedTripoModelEntry?.description || "当前模型暂无说明。"}
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setThreedGenerationDialogMode(null)}
variant="outline"
>
取消
</Button>
<Button
disabled={
isProcessing ||
(threedGenerationDialogMode === "world"
? !selectedWorldModelEntry
: !selectedTripoModelEntry)
}
onClick={() => void handleConfirmThreedGeneration()}
>
{isProcessing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : threedGenerationDialogMode === "world" ? (
<Globe2 className="mr-2 h-4 w-4" />
) : (
<Box className="mr-2 h-4 w-4" />
)}
开始生成
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected &&
!isMultiSelected &&
@ -4463,6 +4699,7 @@ export function ImageNode({
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
typeof document !== "undefined" &&

View File

@ -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<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "center center",
}),
[placeholderViewportScale]
);
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "top left",
}),
[placeholderViewportScale]
);
const handleOpen = () => {
if (!primaryOpenUrl) {
@ -216,22 +244,46 @@ export function Model3DNode({
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成中..."
: "3D 生成中..."}
</span>
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<Box className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成中..."
: "3D 模型生成中..."}
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel}
</span>
</div>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
<span className="text-xs text-destructive">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成失败"
: "3D 生成失败"}
</span>
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成失败"
: "3D 模型生成失败"}
</span>
<span className="text-xs text-[#b77480] dark:text-red-300/80">
可以重新选择模型后再试一次
</span>
</div>
</div>
) : (
<div className="flex h-full w-full flex-col">

View File

@ -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<Blob | null>) | 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<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "center center",
}),
[placeholderViewportScale]
);
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
() => ({
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<Blob | null>) | 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 (
<div
className={cn(
@ -276,14 +396,42 @@ export function World3DNode({
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">3D 世界生成中...</span>
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<Sparkles className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
3D 世界生成中...
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{worldModelLabel || "世界资产正在构建中"}
</span>
</div>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
<span className="text-xs text-destructive">3D 世界生成失败</span>
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
3D 世界生成失败
</span>
<span className="text-xs text-[#b77480] dark:text-red-300/80">
可以重新选择模型后再试一次
</span>
</div>
</div>
) : (
<>
@ -305,6 +453,7 @@ export function World3DNode({
semanticsMetadata={data.semanticsMetadata}
splatUrl={data.splatUrl}
worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady}
/>
</div>
@ -397,6 +546,26 @@ export function World3DNode({
</Button>
</div>
) : null}
{captureEnabled ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
<Button
className="h-7 rounded-full px-2"
disabled={!viewerCapture || isCapturingSnapshot}
onClick={() => void handleCaptureSnapshot()}
size="sm"
title="拍照"
type="button"
variant="ghost"
>
{isCapturingSnapshot ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-white" />
) : (
<Camera className="h-3.5 w-3.5 text-white" />
)}
</Button>
</div>
) : null}
</div>
{isHovered && data.status === "completed" && (

View File

@ -74,6 +74,7 @@ type World3DViewerProps = {
semanticsMetadata?: Record<string, unknown> | null;
className?: string;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
@ -975,6 +976,46 @@ function WorldSplatScene({
);
}
function WorldSplatCaptureBridge({
onCaptureReady,
}: {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | 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<Blob | null>((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<string, unknown> | null;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("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 }}
>
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
<WorldSplatScene
firstPerson={firstPerson}
hotspots={hotspots}
@ -1089,9 +1133,15 @@ export function World3DViewer({
semanticsMetadata,
className,
resetViewToken,
onCaptureReady,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
useEffect(() => {
if (mode === "splat" || mode === "mesh") return;
onCaptureReady?.(null);
}, [mode, onCaptureReady]);
if (mode === "mesh") {
return (
<div className={cn("h-full w-full", className)}>
@ -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}

View File

@ -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}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black font-bold text-white text-sm">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black font-bold text-white text-sm dark:bg-white dark:text-black">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<span
className={cn(
"min-w-0 truncate font-semibold text-[#111827] leading-none tracking-tight",
"min-w-0 truncate font-semibold text-[#111827] leading-none tracking-tight dark:text-white",
isPlain ? "text-[20px]" : "hidden text-[15px] lg:block"
)}
>
@ -113,7 +113,7 @@ export function FloatingSidebarRail({
return (
<div
className={cn(
"pointer-events-auto flex items-center gap-1 border border-black/10 bg-white shadow",
"pointer-events-auto flex items-center gap-1 border border-black/10 bg-white shadow dark:border-white/10 dark:bg-[#141416]",
isHorizontal
? "h-[52px] w-auto flex-row overflow-hidden rounded-full px-2.5 py-1.5"
: "w-[56px] flex-col overflow-y-auto rounded-[28px] px-1.5 py-2.5",
@ -147,7 +147,7 @@ export function FloatingSidebarAction({
<TooltipTrigger asChild>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full text-[#4f566b] transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-45",
"flex h-10 w-10 items-center justify-center rounded-full text-[#4f566b] transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-45 dark:text-white/60 dark:hover:bg-white/8 dark:hover:text-white",
active &&
"bg-[#111827] text-white shadow-sm hover:bg-[#1f2937] hover:text-white"
)}

File diff suppressed because it is too large Load Diff

View File

@ -505,7 +505,7 @@ const PurePreviewMessage = ({
) : null}
<MessageContent
className={cn({
"w-fit break-words rounded-2xl px-3 py-2 text-right text-white":
"w-fit break-words rounded-2xl border px-3 py-2 text-right shadow-sm":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
@ -513,7 +513,11 @@ const PurePreviewMessage = ({
data-testid="message-content"
style={
message.role === "user"
? { backgroundColor: "#006cff" }
? {
backgroundColor: "var(--chat-user-bubble-bg)",
color: "var(--chat-user-bubble-foreground)",
borderColor: "var(--chat-user-bubble-border)",
}
: undefined
}
>

View File

@ -159,6 +159,10 @@ export function TopNavbar({
: siteLogo && siteLogo.trim() !== ""
? siteLogo
: siteLogoDark;
const shouldInvertLogo =
resolvedTheme === "dark" &&
(!siteLogoDark || siteLogoDark.trim() === "") &&
Boolean(siteLogo && siteLogo.trim() !== "");
const getShortLabel = (label: string) => {
const trimmed = label.trim();
@ -280,7 +284,7 @@ export function TopNavbar({
{displayLogo && (
<img
alt={siteName}
className="h-7 w-7 object-contain"
className={shouldInvertLogo ? "h-7 w-7 object-contain brightness-0 invert" : "h-7 w-7 object-contain"}
src={displayLogo}
/>
)}

View File

@ -0,0 +1,156 @@
export const LOVART_HOME_SIDEBAR_CONFIG_KEY = "lovart_home_sidebar_config";
export const DEFAULT_LOVART_HOME_SIDEBAR_ITEMS = [
{
key: "newProject",
label: "新建项目",
description: "首页左侧顶部的新建项目按钮",
},
{
key: "home",
label: "首页",
description: "回到首页主视图",
},
{
key: "projects",
label: "项目列表",
description: "打开项目列表视图",
},
{
key: "profile",
label: "个人主页",
description: "首页侧边栏个人主页入口",
},
{
key: "gallery",
label: "个人相册",
description: "跳转到个人相册",
},
{
key: "tutorial",
label: "视频教程",
description: "展示视频教程悬浮面板",
},
{
key: "imageEditor",
label: "图片编辑",
description: "打开图片编辑功能",
},
{
key: "aiCad",
label: "AI-CAD",
description: "跳转 AI-CAD 页面",
},
{
key: "agent",
label: "风格化",
description: "跳转风格化工作区",
},
{
key: "videoCanvas",
label: "视频画布",
description: "跳转视频画布页面",
},
{
key: "flowBoard",
label: "流程白板",
description: "跳转流程白板页面",
},
{
key: "productPlanning",
label: "商品策划",
description: "跳转商品策划页面",
},
{
key: "toolbox1",
label: "工具箱",
description: "展示工具箱一链接列表",
},
{
key: "toolbox2",
label: "工具箱2",
description: "展示工具箱二链接列表",
},
{
key: "settings",
label: "系统设置",
description: "管理员系统设置入口",
},
] as const;
export type LovartHomeSidebarItemKey =
(typeof DEFAULT_LOVART_HOME_SIDEBAR_ITEMS)[number]["key"];
export type LovartHomeSidebarItemConfig = {
label: string;
iconUrl: string;
enabled: boolean;
};
export type LovartHomeSidebarConfig = {
items: Record<LovartHomeSidebarItemKey, LovartHomeSidebarItemConfig>;
};
const DEFAULT_ITEMS = DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.reduce(
(accumulator, item) => {
accumulator[item.key] = {
label: item.label,
iconUrl: "",
enabled: true,
};
return accumulator;
},
{} as Record<LovartHomeSidebarItemKey, LovartHomeSidebarItemConfig>
);
export const defaultLovartHomeSidebarConfig: LovartHomeSidebarConfig = {
items: DEFAULT_ITEMS,
};
export function getDefaultLovartHomeSidebarItem(
key: LovartHomeSidebarItemKey
): LovartHomeSidebarItemConfig {
return defaultLovartHomeSidebarConfig.items[key];
}
export function normalizeLovartHomeSidebarConfig(
value: unknown
): LovartHomeSidebarConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const rawItems =
raw.items && typeof raw.items === "object" && !Array.isArray(raw.items)
? (raw.items as Record<string, unknown>)
: {};
const items = DEFAULT_LOVART_HOME_SIDEBAR_ITEMS.reduce(
(accumulator, item) => {
const itemRaw =
rawItems[item.key] &&
typeof rawItems[item.key] === "object" &&
!Array.isArray(rawItems[item.key])
? (rawItems[item.key] as Record<string, unknown>)
: {};
const label = String(itemRaw.label || "")
.trim()
.slice(0, 32);
const iconUrl = String(itemRaw.iconUrl || "")
.trim()
.slice(0, 2000);
accumulator[item.key] = {
label: label || item.label,
iconUrl,
enabled:
typeof itemRaw.enabled === "boolean" ? itemRaw.enabled : true,
};
return accumulator;
},
{} as Record<LovartHomeSidebarItemKey, LovartHomeSidebarItemConfig>
);
return { items };
}