feat: improve lovart home and 3d canvas flows
This commit is contained in:
parent
d6d8bf021f
commit
560a1999a0
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" &&
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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" && (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
156
lib/home/lovart-sidebar-config.ts
Normal file
156
lib/home/lovart-sidebar-config.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user