1056 lines
40 KiB
TypeScript
1056 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Database,
|
||
Edit,
|
||
Loader2,
|
||
Plus,
|
||
RefreshCw,
|
||
Save,
|
||
Search,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import type { ThreeDModelAliasEntry } from "@/lib/model3d/model-alias-config";
|
||
import { cn, formatBeijingTime } from "@/lib/utils";
|
||
import type {
|
||
AdminAiProvider,
|
||
LocalAiModel3dSectionsProps,
|
||
} from "../../_lib/types";
|
||
import {
|
||
LocalAiGroupHeading,
|
||
LocalAiSectionShell,
|
||
LocalAiSectionSummary,
|
||
} from "../local-ai-section-shell";
|
||
|
||
export function LocalAiModel3dSections({
|
||
tripo3dConfig,
|
||
setTripo3dConfig,
|
||
tripoProviders,
|
||
filteredTripoProviders,
|
||
enabledTripoProviderCount,
|
||
tripoProviderSearch,
|
||
setTripoProviderSearch,
|
||
tripoProviderBusyId,
|
||
worldlabs3dConfig,
|
||
setWorldlabs3dConfig,
|
||
worldlabsKeyPoolConfig,
|
||
setWorldlabsKeyPoolConfig,
|
||
worldlabsApiKeysDraft,
|
||
setWorldlabsApiKeysDraft,
|
||
worldlabsProviders,
|
||
filteredWorldlabsProviders,
|
||
enabledWorldlabsProviderCount,
|
||
worldlabsProviderSearch,
|
||
setWorldlabsProviderSearch,
|
||
worldlabsProviderBusyId,
|
||
threeDModelAliasConfig,
|
||
setThreeDModelAliasConfig,
|
||
kittycadCadConfig,
|
||
setKittycadCadConfig,
|
||
isTestingKittycadCad,
|
||
isLocalAiSectionExpanded,
|
||
toggleLocalAiSection,
|
||
getProviderDefaultBaseUrl,
|
||
handleEditTripoPoolProvider,
|
||
handleToggleTripoPoolProvider,
|
||
handleDeleteProvider,
|
||
handleSaveTripo3dConfig,
|
||
handleAddTripo3dPoolKey,
|
||
handleImportWorldlabs3dPoolKeys,
|
||
handleAddWorldlabs3dPoolKey,
|
||
handleEditWorldlabsPoolProvider,
|
||
handleToggleWorldlabsPoolProvider,
|
||
handleSaveWorldlabs3dConfig,
|
||
handleAddThreeDModelAliasEntry,
|
||
handleThreeDModelAliasEntryChange,
|
||
handleRemoveThreeDModelAliasEntry,
|
||
handleResetThreeDModelAliasConfig,
|
||
handleSaveThreeDModelAliasConfig,
|
||
handleTestKittycadCadConfig,
|
||
handleSaveKittycadCadConfig,
|
||
}: LocalAiModel3dSectionsProps) {
|
||
return (
|
||
<>
|
||
<LocalAiGroupHeading
|
||
description="集中管理 Tripo3D、World Labs、KittyCAD 以及前端显示名映射,方便核对 3D 链路是否完整。"
|
||
eyebrow="3D / CAD"
|
||
title="3D 生成、世界模型与 CAD"
|
||
/>
|
||
|
||
<LocalAiSectionShell
|
||
description={
|
||
<>
|
||
配置无限画布 3D 生成功能使用的 Tripo3D 接口,保存后写入数据库 AI
|
||
提供商配置。
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("tripo3d")}
|
||
id="local-ai-section-tripo3d"
|
||
onToggle={() => toggleLocalAiSection("tripo3d")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label: tripo3dConfig.isEnabled ? "服务已启用" : "服务已关闭",
|
||
tone: tripo3dConfig.isEnabled ? "enabled" : "disabled",
|
||
},
|
||
{
|
||
label: `${tripoProviders.length} 个 Key`,
|
||
tone: tripoProviders.length > 0 ? "enabled" : "warning",
|
||
},
|
||
{
|
||
label: `${enabledTripoProviderCount} 个启用中`,
|
||
tone: enabledTripoProviderCount > 0 ? "enabled" : "warning",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title="Tripo3D API 配置"
|
||
>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tripo3dName">提供商名称</Label>
|
||
<Input
|
||
id="tripo3dName"
|
||
onChange={(e) =>
|
||
setTripo3dConfig({ ...tripo3dConfig, name: e.target.value })
|
||
}
|
||
placeholder="Tripo3D"
|
||
value={tripo3dConfig.name}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tripo3dBaseUrl">API 地址</Label>
|
||
<Input
|
||
id="tripo3dBaseUrl"
|
||
onChange={(e) =>
|
||
setTripo3dConfig({
|
||
...tripo3dConfig,
|
||
baseUrl: e.target.value,
|
||
})
|
||
}
|
||
placeholder={getProviderDefaultBaseUrl("tripo-3d")}
|
||
value={tripo3dConfig.baseUrl}
|
||
/>
|
||
<div className="text-muted-foreground text-xs">
|
||
默认:{getProviderDefaultBaseUrl("tripo-3d")}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="tripo3dApiKey">
|
||
API Key {tripo3dConfig.providerId ? "(留空则不修改)" : "*"}
|
||
</Label>
|
||
<Input
|
||
id="tripo3dApiKey"
|
||
onChange={(e) =>
|
||
setTripo3dConfig({
|
||
...tripo3dConfig,
|
||
apiKey: e.target.value,
|
||
})
|
||
}
|
||
placeholder={
|
||
tripo3dConfig.providerId
|
||
? "留空则不修改当前 Key"
|
||
: "请输入 Tripo3D API Key"
|
||
}
|
||
type="password"
|
||
value={tripo3dConfig.apiKey}
|
||
/>
|
||
</div>
|
||
<div className="space-y-4 rounded-xl border p-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="font-medium text-sm">Tripo3D Key 池</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
当前共 {tripoProviders.length} 个 Key,已启用{" "}
|
||
{enabledTripoProviderCount} 个。生成任务会轮询使用可用 Key。
|
||
</div>
|
||
</div>
|
||
<div className="relative w-full md:max-w-xs">
|
||
<Search className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
className="pl-9"
|
||
onChange={(e) => setTripoProviderSearch(e.target.value)}
|
||
placeholder="搜索名称 / Key / API 地址"
|
||
value={tripoProviderSearch}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{filteredTripoProviders.length ? (
|
||
filteredTripoProviders.map((provider: AdminAiProvider) => (
|
||
<div
|
||
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
|
||
key={provider.id}
|
||
>
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="font-medium">{provider.name}</div>
|
||
<span
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 text-xs",
|
||
provider.isEnabled
|
||
? "bg-emerald-500/10 text-emerald-600"
|
||
: "bg-zinc-200 text-zinc-600"
|
||
)}
|
||
>
|
||
{provider.isEnabled ? "已启用" : "已禁用"}
|
||
</span>
|
||
</div>
|
||
<div className="font-mono text-muted-foreground text-xs">
|
||
Key: {provider.apiKey}
|
||
</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
API: {provider.baseUrl}
|
||
</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
创建时间: {formatBeijingTime(provider.createdAt)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
onClick={() => handleEditTripoPoolProvider(provider)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Edit className="mr-1 h-4 w-4" />
|
||
编辑
|
||
</Button>
|
||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||
<Switch
|
||
checked={provider.isEnabled}
|
||
disabled={tripoProviderBusyId === provider.id}
|
||
onCheckedChange={(checked) =>
|
||
void handleToggleTripoPoolProvider(provider, checked)
|
||
}
|
||
/>
|
||
<span className="text-muted-foreground text-xs">
|
||
{provider.isEnabled ? "启用中" : "已停用"}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
onClick={() => handleDeleteProvider(provider.id)}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
<Trash2 className="mr-1 h-4 w-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
|
||
{tripoProviders.length
|
||
? "没有匹配到符合筛选条件的 Tripo3D Key。"
|
||
: "当前还没有导入任何 Tripo3D Key。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||
<div className="space-y-0.5">
|
||
<Label className="text-sm">启用 Tripo3D</Label>
|
||
<div className="text-muted-foreground text-xs">
|
||
关闭后 3D 生成接口将不可用
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={tripo3dConfig.isEnabled}
|
||
onCheckedChange={(checked) =>
|
||
setTripo3dConfig({ ...tripo3dConfig, isEnabled: checked })
|
||
}
|
||
/>
|
||
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
|
||
{tripo3dConfig.isEnabled ? "已开启" : "已关闭"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button onClick={handleSaveTripo3dConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存 Tripo3D 配置
|
||
</Button>
|
||
<Button onClick={handleAddTripo3dPoolKey} variant="outline">
|
||
<Plus className="mr-2 h-4 w-4" /> 添加为新 Key
|
||
</Button>
|
||
</div>
|
||
</LocalAiSectionShell>
|
||
|
||
<LocalAiSectionShell
|
||
description={
|
||
<>
|
||
配置无限画布 3D 世界模式使用的 World Labs 接口,保存后写入数据库 AI
|
||
提供商配置。
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("worldlabs3d")}
|
||
id="local-ai-section-worldlabs3d"
|
||
onToggle={() => toggleLocalAiSection("worldlabs3d")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label: worldlabs3dConfig.isEnabled
|
||
? "服务已启用"
|
||
: "服务已关闭",
|
||
tone: worldlabs3dConfig.isEnabled ? "enabled" : "disabled",
|
||
},
|
||
{
|
||
label: `${worldlabsProviders.length} 个 Key`,
|
||
tone: worldlabsProviders.length > 0 ? "enabled" : "warning",
|
||
},
|
||
{
|
||
label: `${enabledWorldlabsProviderCount} 个启用中`,
|
||
tone: enabledWorldlabsProviderCount > 0 ? "enabled" : "warning",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title="World Labs API 配置"
|
||
>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="worldlabs3dName">提供商名称</Label>
|
||
<Input
|
||
id="worldlabs3dName"
|
||
onChange={(e) =>
|
||
setWorldlabs3dConfig({
|
||
...worldlabs3dConfig,
|
||
name: e.target.value,
|
||
})
|
||
}
|
||
placeholder="World Labs"
|
||
value={worldlabs3dConfig.name}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="worldlabs3dBaseUrl">API 地址</Label>
|
||
<Input
|
||
id="worldlabs3dBaseUrl"
|
||
onChange={(e) =>
|
||
setWorldlabs3dConfig({
|
||
...worldlabs3dConfig,
|
||
baseUrl: e.target.value,
|
||
})
|
||
}
|
||
placeholder={getProviderDefaultBaseUrl("worldlabs-3d")}
|
||
value={worldlabs3dConfig.baseUrl}
|
||
/>
|
||
<div className="text-muted-foreground text-xs">
|
||
默认:{getProviderDefaultBaseUrl("worldlabs-3d")}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="worldlabs3dApiKey">
|
||
API Key {worldlabs3dConfig.providerId ? "(留空则不修改)" : "*"}
|
||
</Label>
|
||
<Input
|
||
id="worldlabs3dApiKey"
|
||
onChange={(e) =>
|
||
setWorldlabs3dConfig({
|
||
...worldlabs3dConfig,
|
||
apiKey: e.target.value,
|
||
})
|
||
}
|
||
placeholder={
|
||
worldlabs3dConfig.providerId
|
||
? "留空则不修改当前 Key"
|
||
: "请输入 World Labs API Key"
|
||
}
|
||
type="password"
|
||
value={worldlabs3dConfig.apiKey}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="worldlabsKeyPoolImportToken">Key 池导入令牌</Label>
|
||
<Input
|
||
id="worldlabsKeyPoolImportToken"
|
||
onChange={(e) =>
|
||
setWorldlabsKeyPoolConfig((prev) => ({
|
||
...prev,
|
||
importToken: e.target.value,
|
||
}))
|
||
}
|
||
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
|
||
type="password"
|
||
value={worldlabsKeyPoolConfig.importToken}
|
||
/>
|
||
<div className="text-muted-foreground text-xs">
|
||
用于 Python 脚本通过
|
||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
|
||
/api/worldlabs/key-pool/import
|
||
</code>
|
||
批量导入新 Key;后台管理员在本页面导入时不需要额外填写令牌。
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 rounded-xl border border-dashed p-4">
|
||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<Label htmlFor="worldlabs3dApiKeysDraft">批量导入 Key</Label>
|
||
<div className="text-muted-foreground text-xs">
|
||
每行一个 World Labs API Key,导入时会自动跳过已存在或重复的
|
||
Key。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={handleImportWorldlabs3dPoolKeys}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
批量导入
|
||
</Button>
|
||
</div>
|
||
<Textarea
|
||
id="worldlabs3dApiKeysDraft"
|
||
onChange={(e) => setWorldlabsApiKeysDraft(e.target.value)}
|
||
placeholder={"wlt_xxxxxxxxx\nwlt_yyyyyyyyy\nwlt_zzzzzzzzz"}
|
||
rows={5}
|
||
value={worldlabsApiKeysDraft}
|
||
/>
|
||
</div>
|
||
<div className="space-y-4 rounded-xl border p-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="font-medium text-sm">Key 池明细</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
当前共 {worldlabsProviders.length} 个 Key,已启用{" "}
|
||
{enabledWorldlabsProviderCount} 个。
|
||
</div>
|
||
</div>
|
||
<div className="relative w-full md:max-w-xs">
|
||
<Search className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
className="pl-9"
|
||
onChange={(e) => setWorldlabsProviderSearch(e.target.value)}
|
||
placeholder="搜索名称 / Key / API 地址"
|
||
value={worldlabsProviderSearch}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{filteredWorldlabsProviders.length ? (
|
||
filteredWorldlabsProviders.map((provider: AdminAiProvider) => (
|
||
<div
|
||
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
|
||
key={provider.id}
|
||
>
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="font-medium">{provider.name}</div>
|
||
<span
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 text-xs",
|
||
provider.isEnabled
|
||
? "bg-emerald-500/10 text-emerald-600"
|
||
: "bg-zinc-200 text-zinc-600"
|
||
)}
|
||
>
|
||
{provider.isEnabled ? "已启用" : "已禁用"}
|
||
</span>
|
||
</div>
|
||
<div className="font-mono text-muted-foreground text-xs">
|
||
Key: {provider.apiKey}
|
||
</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
API: {provider.baseUrl}
|
||
</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
创建时间: {formatBeijingTime(provider.createdAt)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
onClick={() => handleEditWorldlabsPoolProvider(provider)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Edit className="mr-1 h-4 w-4" />
|
||
编辑
|
||
</Button>
|
||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||
<Switch
|
||
checked={provider.isEnabled}
|
||
disabled={worldlabsProviderBusyId === provider.id}
|
||
onCheckedChange={(checked) =>
|
||
void handleToggleWorldlabsPoolProvider(
|
||
provider,
|
||
checked
|
||
)
|
||
}
|
||
/>
|
||
<span className="text-muted-foreground text-xs">
|
||
{provider.isEnabled ? "启用中" : "已停用"}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
onClick={() => handleDeleteProvider(provider.id)}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
<Trash2 className="mr-1 h-4 w-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
|
||
{worldlabsProviders.length
|
||
? "没有匹配到符合筛选条件的 World Labs Key。"
|
||
: "当前还没有导入任何 World Labs Key。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||
<div className="space-y-0.5">
|
||
<Label className="text-sm">启用 World Labs</Label>
|
||
<div className="text-muted-foreground text-xs">
|
||
关闭后 3D 世界模式接口将不可用
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={worldlabs3dConfig.isEnabled}
|
||
onCheckedChange={(checked) =>
|
||
setWorldlabs3dConfig({
|
||
...worldlabs3dConfig,
|
||
isEnabled: checked,
|
||
})
|
||
}
|
||
/>
|
||
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
|
||
{worldlabs3dConfig.isEnabled ? "已开启" : "已关闭"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button onClick={handleSaveWorldlabs3dConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存 World Labs 配置
|
||
</Button>
|
||
<Button onClick={handleAddWorldlabs3dPoolKey} variant="outline">
|
||
<Plus className="mr-2 h-4 w-4" /> 添加为新 Key
|
||
</Button>
|
||
</div>
|
||
</LocalAiSectionShell>
|
||
|
||
<LocalAiSectionShell
|
||
description={
|
||
<>
|
||
配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D
|
||
生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("3d-model-alias")}
|
||
id="local-ai-section-3d-model-alias"
|
||
onToggle={() => toggleLocalAiSection("3d-model-alias")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{ label: "前台展示名", tone: "neutral" },
|
||
{ label: "附加选项显隐", tone: "neutral" },
|
||
]}
|
||
/>
|
||
}
|
||
title="3D / 世界模型显示名映射"
|
||
>
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-4 text-muted-foreground text-sm">
|
||
前端提交任务时只会发送系统内部别名,真实模型 ID
|
||
只在服务端使用。你可以在这里自由改“展示名”和“真实模型
|
||
ID”的对应关系,避免用户看到上游模型名。
|
||
</div>
|
||
|
||
<div className="space-y-3 rounded-xl border p-4">
|
||
<div className="space-y-1">
|
||
<div className="font-medium text-base">3D 生成附加选项显隐</div>
|
||
<div className="text-muted-foreground text-sm">
|
||
关闭后,用户侧 3D
|
||
生成面板会隐藏对应开关;服务端也会忽略该附加选项,避免隐藏功能继续产生额外扣分。
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-3">
|
||
{(
|
||
[
|
||
{
|
||
key: "pbr",
|
||
label: "PBR 材质",
|
||
description: "控制 3D 模型生成里的 PBR 材质开关。",
|
||
},
|
||
{
|
||
key: "quad",
|
||
label: "四边面优化",
|
||
description: "控制 3D 模型生成里的四边面优化开关。",
|
||
},
|
||
{
|
||
key: "smartLowPoly",
|
||
label: "智能低模",
|
||
description: "控制 3D 模型生成里的智能低模开关。",
|
||
},
|
||
] as const
|
||
).map((item) => (
|
||
<div
|
||
className="flex items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3"
|
||
key={item.key}
|
||
>
|
||
<div className="space-y-0.5">
|
||
<div className="font-medium text-sm">{item.label}</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
{item.description}
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={
|
||
threeDModelAliasConfig.featureVisibility?.[item.key] !==
|
||
false
|
||
}
|
||
onCheckedChange={(checked) =>
|
||
setThreeDModelAliasConfig((prev) => ({
|
||
...prev,
|
||
featureVisibility: {
|
||
...prev.featureVisibility,
|
||
[item.key]: checked,
|
||
},
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 xl:grid-cols-2">
|
||
<div className="space-y-4 rounded-xl border p-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<div className="font-medium text-base">Tripo3D 模型映射</div>
|
||
<div className="text-muted-foreground text-sm">
|
||
控制 3D 生成页面与模型节点显示的模型名称。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={() => handleAddThreeDModelAliasEntry("tripoModels")}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加映射
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{threeDModelAliasConfig.tripoModels.map(
|
||
(entry: ThreeDModelAliasEntry, index: number) => (
|
||
<div
|
||
className="space-y-4 rounded-xl border p-4"
|
||
key={entry.id}
|
||
>
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="font-medium text-sm">
|
||
Tripo3D 映射 {index + 1}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
onClick={() =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"tripoModels",
|
||
index,
|
||
"isDefault",
|
||
true
|
||
)
|
||
}
|
||
size="sm"
|
||
type="button"
|
||
variant={entry.isDefault ? "default" : "outline"}
|
||
>
|
||
{entry.isDefault ? "默认映射" : "设为默认"}
|
||
</Button>
|
||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||
<Switch
|
||
checked={entry.enabled}
|
||
onCheckedChange={(checked) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"tripoModels",
|
||
index,
|
||
"enabled",
|
||
checked
|
||
)
|
||
}
|
||
/>
|
||
<span className="text-muted-foreground text-xs">
|
||
{entry.enabled ? "已启用" : "已停用"}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
disabled={
|
||
threeDModelAliasConfig.tripoModels.length <= 1
|
||
}
|
||
onClick={() =>
|
||
handleRemoveThreeDModelAliasEntry(
|
||
"tripoModels",
|
||
index
|
||
)
|
||
}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>前台显示名</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"tripoModels",
|
||
index,
|
||
"displayName",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="例如:标准模型"
|
||
value={entry.displayName}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>真实模型 ID</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"tripoModels",
|
||
index,
|
||
"realModelId",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="例如:Turbo-v1.0-20250506"
|
||
value={entry.realModelId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>说明</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"tripoModels",
|
||
index,
|
||
"description",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="可选,用于帮助管理员区分不同映射用途"
|
||
value={entry.description}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 rounded-xl border p-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<div className="font-medium text-base">World Labs 模型映射</div>
|
||
<div className="text-muted-foreground text-sm">
|
||
控制 3D 世界工作台与世界节点显示的质量名称。
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={() => handleAddThreeDModelAliasEntry("worldModels")}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加映射
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{threeDModelAliasConfig.worldModels.map(
|
||
(entry: ThreeDModelAliasEntry, index: number) => (
|
||
<div
|
||
className="space-y-4 rounded-xl border p-4"
|
||
key={entry.id}
|
||
>
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="font-medium text-sm">
|
||
World 映射 {index + 1}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
onClick={() =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"worldModels",
|
||
index,
|
||
"isDefault",
|
||
true
|
||
)
|
||
}
|
||
size="sm"
|
||
type="button"
|
||
variant={entry.isDefault ? "default" : "outline"}
|
||
>
|
||
{entry.isDefault ? "默认映射" : "设为默认"}
|
||
</Button>
|
||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||
<Switch
|
||
checked={entry.enabled}
|
||
onCheckedChange={(checked) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"worldModels",
|
||
index,
|
||
"enabled",
|
||
checked
|
||
)
|
||
}
|
||
/>
|
||
<span className="text-muted-foreground text-xs">
|
||
{entry.enabled ? "已启用" : "已停用"}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
disabled={
|
||
threeDModelAliasConfig.worldModels.length <= 1
|
||
}
|
||
onClick={() =>
|
||
handleRemoveThreeDModelAliasEntry(
|
||
"worldModels",
|
||
index
|
||
)
|
||
}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>前台显示名</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"worldModels",
|
||
index,
|
||
"displayName",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="例如:标准质量"
|
||
value={entry.displayName}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>真实模型 ID</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"worldModels",
|
||
index,
|
||
"realModelId",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="例如:marble-1.1"
|
||
value={entry.realModelId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>说明</Label>
|
||
<Input
|
||
onChange={(e) =>
|
||
handleThreeDModelAliasEntryChange(
|
||
"worldModels",
|
||
index,
|
||
"description",
|
||
e.target.value
|
||
)
|
||
}
|
||
placeholder="可选,用于帮助管理员区分不同映射用途"
|
||
value={entry.description}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
onClick={handleResetThreeDModelAliasConfig}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
恢复默认映射
|
||
</Button>
|
||
<Button onClick={handleSaveThreeDModelAliasConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存模型显示映射
|
||
</Button>
|
||
</div>
|
||
</LocalAiSectionShell>
|
||
|
||
<LocalAiSectionShell
|
||
description={
|
||
<>
|
||
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
||
提供商配置。
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||
id="local-ai-section-kittycad-cad"
|
||
onToggle={() => toggleLocalAiSection("kittycad-cad")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label: kittycadCadConfig.isEnabled
|
||
? "服务已启用"
|
||
: "服务已关闭",
|
||
tone: kittycadCadConfig.isEnabled ? "enabled" : "disabled",
|
||
},
|
||
{
|
||
label:
|
||
kittycadCadConfig.apiKey || kittycadCadConfig.providerId
|
||
? "Token 已配置"
|
||
: "Token 待配置",
|
||
tone:
|
||
kittycadCadConfig.apiKey || kittycadCadConfig.providerId
|
||
? "enabled"
|
||
: "warning",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title={<>KittyCAD CAD API 配置</>}
|
||
>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="kittycadCadName">提供商名称</Label>
|
||
<Input
|
||
id="kittycadCadName"
|
||
onChange={(e) =>
|
||
setKittycadCadConfig({
|
||
...kittycadCadConfig,
|
||
name: e.target.value,
|
||
})
|
||
}
|
||
placeholder="KittyCAD CAD"
|
||
value={kittycadCadConfig.name}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="kittycadCadBaseUrl">API 地址</Label>
|
||
<Input
|
||
id="kittycadCadBaseUrl"
|
||
onChange={(e) =>
|
||
setKittycadCadConfig({
|
||
...kittycadCadConfig,
|
||
baseUrl: e.target.value,
|
||
})
|
||
}
|
||
placeholder={getProviderDefaultBaseUrl("kittycad-cad")}
|
||
value={kittycadCadConfig.baseUrl}
|
||
/>
|
||
<div className="text-muted-foreground text-xs">
|
||
默认:{getProviderDefaultBaseUrl("kittycad-cad")}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="kittycadCadApiKey">
|
||
API Token {kittycadCadConfig.providerId ? "(留空则不修改)" : "*"}
|
||
</Label>
|
||
<Input
|
||
id="kittycadCadApiKey"
|
||
onChange={(e) =>
|
||
setKittycadCadConfig({
|
||
...kittycadCadConfig,
|
||
apiKey: e.target.value,
|
||
})
|
||
}
|
||
placeholder={
|
||
kittycadCadConfig.providerId
|
||
? "留空则不修改当前 Token"
|
||
: "请输入 KittyCAD / Zoo API Token"
|
||
}
|
||
type="password"
|
||
value={kittycadCadConfig.apiKey}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||
<div className="space-y-0.5">
|
||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||
<div className="text-muted-foreground text-xs">
|
||
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={kittycadCadConfig.isEnabled}
|
||
onCheckedChange={(checked) =>
|
||
setKittycadCadConfig({
|
||
...kittycadCadConfig,
|
||
isEnabled: checked,
|
||
})
|
||
}
|
||
/>
|
||
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
|
||
{kittycadCadConfig.isEnabled ? "已开启" : "已关闭"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1 rounded-lg border border-orange-200 bg-orange-50 p-4 text-orange-800 text-xs">
|
||
<p>• 默认接入地址为 `https://api.zoo.dev`。</p>
|
||
<p>
|
||
• 建议填写 Zoo 官方 API
|
||
Token;如果你有自建兼容服务,也可以改成自定义地址。
|
||
</p>
|
||
<p>
|
||
• 测试连接只会校验当前账号和 Token,不会创建 CAD
|
||
任务,也不会消耗生成额度。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap justify-end gap-3">
|
||
<Button
|
||
disabled={isTestingKittycadCad}
|
||
onClick={handleTestKittycadCadConfig}
|
||
variant="outline"
|
||
>
|
||
{isTestingKittycadCad ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Database className="mr-2 h-4 w-4" />
|
||
)}
|
||
测试连接
|
||
</Button>
|
||
<Button onClick={handleSaveKittycadCadConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存 KittyCAD CAD 配置
|
||
</Button>
|
||
</div>
|
||
</LocalAiSectionShell>
|
||
</>
|
||
);
|
||
}
|