ai-chatbot/components/agents/AgentPresetDialog.tsx
Your Name 3249b19f56
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: trim public site settings payloads
2026-04-30 01:31:33 +08:00

1710 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { Loader2, Plus, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn, fetcher } from "@/lib/utils";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import {
defaultAgentPresetModeConfig,
normalizeAgentPresetModeConfig,
patchAgentDefaultParamsWithModeConfig,
} from "@/lib/agents/preset-mode-config";
import {
buildAgentPresetCategoryPath,
getEnabledAgentPresetLibraryNodes,
getEnabledAgentPresetLibrarySections,
normalizeAgentPresetLibraryConfig,
resolveAgentPresetCategoryPath,
} from "@/lib/agents/preset-library-config";
import { imageGenerationService } from "@/services/canvas";
import { agentService } from "@/services/agents";
import type {
AgentPresetLibraryConfig,
AgentCreatePayload,
AgentPresetModeType,
AgentPresetModeConfig,
AgentTaskDetailReferenceSource,
AgentPresetType,
AgentPreset,
AgentPresetSlot,
AgentUpdatePayload,
} from "@/types/agent";
type DialogSlotState = AgentPresetSlot & {
localReferenceAssets: Array<{
assetId: string;
url: string;
name?: string | null;
}>;
};
interface AgentPresetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialPreset?: AgentPreset | null;
onSaved?: (preset: AgentPreset) => void;
entityLabel?: string;
theme?: "light" | "dark";
}
function createDefaultDialogSlot(): DialogSlotState {
return {
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
};
}
const IMAGE_MODE_OPTIONS: Array<{
value: AgentPresetModeType;
label: string;
description: string;
}> = [
{
value: "scene_generation",
label: "场景图生成",
description: "输入白底图和模特图,生成多张场景图结果。",
},
{
value: "detail_replacement",
label: "详情替换",
description: "用输入图逐张替换 SPU 详情参考图,生成详情结果。",
},
{
value: "sku_replacement",
label: "SKU替换",
description: "让输入图与参考图一对一替换,批量完成 SKU 场景替换。",
},
];
const TASK_DETAIL_REFERENCE_SOURCE_OPTIONS: Array<{
value: AgentTaskDetailReferenceSource;
label: string;
description: string;
}> = [
{
value: "main",
label: "主图",
description: "获取任务整套里的主图作为参考图来源。",
},
{
value: "sku",
label: "SKU图",
description: "获取任务整套里的 SKU 图作为参考图来源。",
},
{
value: "detail",
label: "详情图",
description: "获取任务整套里的详情图作为参考图来源。",
},
];
export function AgentPresetDialog({
open,
onOpenChange,
initialPreset,
onSaved,
entityLabel = "风格化",
theme = "light",
}: AgentPresetDialogProps) {
const { data: modelsData } = useSWR<{
models?: Array<{
id: string;
displayName: string;
modelId?: string;
supportsImageGen?: boolean;
providerType?: string;
capabilities?: unknown;
isMultimodal?: boolean;
}>;
}>("/api/models", fetcher);
const { data: siteSettingsData } = useSWR<{
agentPresetLibraryConfig?: AgentPresetLibraryConfig;
}>("/api/site-settings?scope=agent-presets", fetcher);
const [name, setName] = useState("");
const [type, setType] = useState<"image" | "video" | "text">("image");
const [visibility, setVisibility] = useState<"public" | "private">("private");
const [defaultModelId, setDefaultModelId] = useState("");
const [description, setDescription] = useState("");
const [avatarAssetId, setAvatarAssetId] = useState<string>("");
const [avatarUrl, setAvatarUrl] = useState<string>("");
const [modeConfig, setModeConfig] = useState<AgentPresetModeConfig>({
...defaultAgentPresetModeConfig,
modeType: "scene_generation",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingSlotIndex, setUploadingSlotIndex] = useState<number | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [slots, setSlots] = useState<DialogSlotState[]>([createDefaultDialogSlot()]);
const [selectedPresetSectionId, setSelectedPresetSectionId] = useState<string>("");
const [selectedPresetLevel1Id, setSelectedPresetLevel1Id] = useState<string>("");
const [selectedPresetLevel2Id, setSelectedPresetLevel2Id] = useState<string>("");
const [selectedPresetLevel3Id, setSelectedPresetLevel3Id] = useState<string>("");
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const isDark = theme === "dark";
const entityNameLabel = `${entityLabel}名称`;
const dialogTitle = initialPreset ? `编辑${entityLabel}` : `新建${entityLabel}`;
const dialogDescription = `配置${entityLabel}名称、预览图、模型和槽位提示词。`;
const submitLabel = initialPreset ? `保存${entityLabel}` : `创建${entityLabel}`;
const darkSelectContentClass = isDark
? "border-white/10 bg-zinc-950 text-white shadow-2xl [--popover:hsl(240_10%_3.9%)] [--popover-foreground:hsl(0_0%_98%)] [--accent:rgba(255,255,255,0.08)] [--accent-foreground:hsl(0_0%_98%)] [--muted:rgba(255,255,255,0.04)] [--muted-foreground:rgba(255,255,255,0.56)]"
: "";
const isStructuredImageModeBuilder = type === "image" && modeConfig.modeType !== "legacy";
const isSceneModeBuilder = type === "image" && modeConfig.modeType === "scene_generation";
const isDetailReplacementModeBuilder =
type === "image" && modeConfig.modeType === "detail_replacement";
const isSkuReplacementModeBuilder =
type === "image" && modeConfig.modeType === "sku_replacement";
const selectedImageModeMeta =
IMAGE_MODE_OPTIONS.find((item) => item.value === modeConfig.modeType) || IMAGE_MODE_OPTIONS[0];
const imageModeSelectOptions =
modeConfig.modeType === "legacy"
? [
{
value: "legacy" as AgentPresetModeType,
label: "旧版兼容",
description: "保留历史模板的多槽位执行方式。",
},
...IMAGE_MODE_OPTIONS,
]
: IMAGE_MODE_OPTIONS;
const presetLibraryConfig = useMemo(
() => normalizeAgentPresetLibraryConfig(siteSettingsData?.agentPresetLibraryConfig),
[siteSettingsData?.agentPresetLibraryConfig]
);
const presetLibrarySections = useMemo(
() => getEnabledAgentPresetLibrarySections(presetLibraryConfig),
[presetLibraryConfig]
);
const selectedPresetSection = useMemo(
() =>
presetLibrarySections.find((section) => section.id === selectedPresetSectionId) ||
null,
[presetLibrarySections, selectedPresetSectionId]
);
const presetLevel1Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetSection?.categories || []),
[selectedPresetSection]
);
const selectedPresetLevel1 = useMemo(
() =>
presetLevel1Options.find((node) => node.id === selectedPresetLevel1Id) ||
null,
[presetLevel1Options, selectedPresetLevel1Id]
);
const presetLevel2Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetLevel1?.children || []),
[selectedPresetLevel1]
);
const selectedPresetLevel2 = useMemo(
() =>
presetLevel2Options.find((node) => node.id === selectedPresetLevel2Id) ||
null,
[presetLevel2Options, selectedPresetLevel2Id]
);
const presetLevel3Options = useMemo(
() => getEnabledAgentPresetLibraryNodes(selectedPresetLevel2?.children || []),
[selectedPresetLevel2]
);
const selectedPresetLevel3 = useMemo(
() =>
presetLevel3Options.find((node) => node.id === selectedPresetLevel3Id) ||
null,
[presetLevel3Options, selectedPresetLevel3Id]
);
const filteredModels = useMemo(() => {
const models = modelsData?.models || [];
if (type === "video") {
return models.filter((model) =>
isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
})
);
}
if (type === "text") {
return models.filter(
(model) =>
model.isMultimodal === true &&
!isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
})
);
}
return models.filter(
(model) =>
isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
}) &&
!isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
})
);
}, [modelsData?.models, type]);
useEffect(() => {
if (!open) return;
if (filteredModels.length === 0) {
setDefaultModelId("");
return;
}
if (!filteredModels.some((model) => model.id === defaultModelId)) {
setDefaultModelId(filteredModels[0]?.id || "");
}
}, [defaultModelId, filteredModels, open]);
useEffect(() => {
if (!open) return;
setSlots((current) =>
current.map((slot, index) => ({
...slot,
slotIndex: index,
title: slot.title || `第${index + 1}张`,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
}))
);
}, [open]);
const resetState = () => {
setName("");
setType("image");
setVisibility("private");
setDefaultModelId("");
setDescription("");
setAvatarAssetId("");
setAvatarUrl("");
setModeConfig({
...defaultAgentPresetModeConfig,
modeType: "scene_generation",
});
setSlots([createDefaultDialogSlot()]);
setSelectedPresetSectionId("");
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
setUploadingSlotIndex(null);
};
useEffect(() => {
if (!open) return;
if (!initialPreset) {
resetState();
return;
}
setName(initialPreset.name || "");
setType(initialPreset.type);
setVisibility(initialPreset.visibility);
setDefaultModelId(initialPreset.defaultModelId || "");
setDescription(initialPreset.description || "");
setAvatarAssetId(initialPreset.avatarAssetId || "");
setAvatarUrl(initialPreset.avatarUrl || "");
setModeConfig(normalizeAgentPresetModeConfig(initialPreset.defaultParams));
setSlots(
(initialPreset.slots || []).map((slot, index) => ({
slotIndex: index,
title: slot.title || `第${index + 1}张`,
promptTemplate: slot.promptTemplate || "",
requireUpload: slot.requireUpload !== false,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
defaultParams: slot.defaultParams || null,
referenceAssetIds: slot.referenceAssetIds || [],
localReferenceAssets: (slot.referenceAssets || []).map((asset) => ({
assetId: asset.assetId,
url: asset.thumbnailUrl || asset.previewUrl || asset.originalUrl,
name: asset.displayName,
})),
}))
);
const resolvedCategoryPath = resolveAgentPresetCategoryPath({
categoryPath: initialPreset.categoryPath || null,
config: presetLibraryConfig,
});
setSelectedPresetSectionId(resolvedCategoryPath.sectionId || "");
setSelectedPresetLevel1Id(resolvedCategoryPath.level1Id || "");
setSelectedPresetLevel2Id(resolvedCategoryPath.level2Id || "");
setSelectedPresetLevel3Id(resolvedCategoryPath.level3Id || "");
}, [initialPreset, open, presetLibraryConfig]);
useEffect(() => {
if (type !== "image" || modeConfig.modeType === "legacy") {
return;
}
setSlots((current) => {
const primary = current[0] || createDefaultDialogSlot();
return [
{
...primary,
slotIndex: 0,
title:
primary.title ||
(modeConfig.modeType === "detail_replacement"
? "详情替换"
: modeConfig.modeType === "sku_replacement"
? "SKU替换"
: "场景图生成"),
requireUpload: true,
uploadLabel:
primary.uploadLabel ||
(modeConfig.modeType === "detail_replacement"
? "详情替换输入图"
: modeConfig.modeType === "sku_replacement"
? "SKU替换输入图"
: "输入图片"),
localReferenceAssets: primary.localReferenceAssets || [],
referenceAssetIds: primary.referenceAssetIds || [],
},
];
});
}, [modeConfig.modeType, type]);
useEffect(() => {
if (
selectedPresetSectionId &&
!presetLibrarySections.some((section) => section.id === selectedPresetSectionId)
) {
setSelectedPresetSectionId("");
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLibrarySections, selectedPresetSectionId]);
useEffect(() => {
if (
selectedPresetLevel1Id &&
!presetLevel1Options.some((node) => node.id === selectedPresetLevel1Id)
) {
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLevel1Options, selectedPresetLevel1Id]);
useEffect(() => {
if (
selectedPresetLevel2Id &&
!presetLevel2Options.some((node) => node.id === selectedPresetLevel2Id)
) {
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}
}, [presetLevel2Options, selectedPresetLevel2Id]);
useEffect(() => {
if (
selectedPresetLevel3Id &&
!presetLevel3Options.some((node) => node.id === selectedPresetLevel3Id)
) {
setSelectedPresetLevel3Id("");
}
}, [presetLevel3Options, selectedPresetLevel3Id]);
const handleSlotChange = (slotIndex: number, updates: Partial<DialogSlotState>) => {
setSlots((current) =>
current.map((slot, index) =>
index === slotIndex ? { ...slot, ...updates, slotIndex: index } : slot
)
);
};
const handleAddSlot = () => {
setSlots((current) => [
...current,
{
slotIndex: current.length,
title: `第${current.length + 1}张`,
promptTemplate: "",
requireUpload: true,
uploadLabel: `第${current.length + 1}张`,
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
};
const handleRemoveSlot = (slotIndex: number) => {
setSlots((current) =>
current
.filter((_, index) => index !== slotIndex)
.map((slot, index) => ({
...slot,
slotIndex: index,
title: slot.title || `第${index + 1}张`,
uploadLabel: slot.uploadLabel || `第${index + 1}张`,
}))
);
};
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setAvatarUploading(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.assetId || !result.data.url) {
throw new Error(result.error || "预览图上传失败");
}
setAvatarAssetId(result.data.assetId);
setAvatarUrl(result.data.url);
toast.success("预览图上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "预览图上传失败");
} finally {
setAvatarUploading(false);
event.target.value = "";
}
};
const handleUploadReferenceImages = async (
slotIndex: number,
event: React.ChangeEvent<HTMLInputElement>
) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
setUploadingSlotIndex(slotIndex);
try {
const uploadedAssets: Array<{ assetId: string; url: string; name?: string | null }> = [];
for (const file of files) {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.assetId || !result.data.url) {
throw new Error(result.error || "参考图上传失败");
}
uploadedAssets.push({
assetId: result.data.assetId,
url: result.data.url,
name: file.name,
});
}
setSlots((current) =>
current.map((slot, index) => {
if (index !== slotIndex) return slot;
const nextAssets = [...slot.localReferenceAssets, ...uploadedAssets];
return {
...slot,
referenceAssetIds: nextAssets.map((item) => item.assetId),
localReferenceAssets: nextAssets,
};
})
);
toast.success(
uploadedAssets.length === 1
? "参考图上传成功"
: `已上传 ${uploadedAssets.length} 张参考图`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingSlotIndex(null);
event.target.value = "";
}
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error(`请输入${entityNameLabel}`);
return;
}
if (!defaultModelId) {
toast.error("请选择对应模型");
return;
}
const effectiveSlots = isStructuredImageModeBuilder
? [
{
...(slots[0] || createDefaultDialogSlot()),
slotIndex: 0,
title:
modeConfig.modeType === "detail_replacement"
? "详情替换"
: modeConfig.modeType === "sku_replacement"
? "SKU替换"
: "场景图生成",
requireUpload: true,
uploadLabel:
modeConfig.modeType === "detail_replacement"
? "详情替换输入图"
: modeConfig.modeType === "sku_replacement"
? "SKU替换输入图"
: "输入图片",
},
]
: slots;
if (effectiveSlots.some((slot) => !slot.promptTemplate.trim())) {
toast.error(
isStructuredImageModeBuilder
? `请输入${selectedImageModeMeta.label}提示词`
: "每个槽位都需要填写提示词"
);
return;
}
setIsSubmitting(true);
try {
const nextDefaultParams = isStructuredImageModeBuilder
? patchAgentDefaultParamsWithModeConfig(initialPreset?.defaultParams || null, modeConfig)
: undefined;
const nextCategoryPath = buildAgentPresetCategoryPath({
section: selectedPresetSection,
level1: selectedPresetLevel1,
level2: selectedPresetLevel2,
level3: selectedPresetLevel3,
});
const createCategoryPath = nextCategoryPath || undefined;
const basePayload: AgentCreatePayload = {
name: name.trim(),
type,
visibility,
categoryPath: createCategoryPath,
description: description.trim() || undefined,
defaultModelId,
defaultParams: nextDefaultParams || undefined,
slots: effectiveSlots.map((slot, index) => ({
slotIndex: index,
title: slot.title || null,
promptTemplate: slot.promptTemplate.trim(),
requireUpload: slot.requireUpload !== false,
uploadLabel: slot.uploadLabel || null,
defaultParams: slot.defaultParams || undefined,
referenceAssetIds: slot.referenceAssetIds || [],
})),
avatarAssetId: avatarAssetId || undefined,
};
const result = initialPreset
? await agentService.updatePreset(initialPreset.id, {
...basePayload,
categoryPath: nextCategoryPath,
secondaryType: null,
} satisfies AgentUpdatePayload)
: await agentService.createPreset(basePayload);
if (!result.success || !result.data) {
throw new Error(result.error || (initialPreset ? `更新${entityLabel}失败` : `创建${entityLabel}失败`));
}
toast.success(initialPreset ? `${entityLabel}更新成功` : `${entityLabel}创建成功`);
onSaved?.(result.data);
onOpenChange(false);
resetState();
} catch (error) {
toast.error(error instanceof Error ? error.message : `创建${entityLabel}失败`);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) resetState();
}}
>
<DialogContent
className={cn(
"max-w-4xl opacity-100 shadow-2xl",
isDark
? [
"border border-white/12 bg-[#0b0d0f] text-white shadow-[0_32px_80px_rgba(0,0,0,0.52)]",
"[&>button]:rounded-full [&>button]:border [&>button]:border-white/10 [&>button]:bg-white/[0.03]",
"[&>button]:text-white/58 [&>button]:opacity-100 [&>button:hover]:bg-white/[0.08] [&>button:hover]:text-white",
]
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-zinc-950"
)}
>
<DialogHeader>
<DialogTitle className={cn(isDark ? "text-white" : "")}>{dialogTitle}</DialogTitle>
<DialogDescription className={cn(isDark ? "text-white/48" : "")}>
{dialogDescription}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[72vh] pr-3">
<div className="space-y-4">
<div className={cn("grid grid-cols-1 gap-5 md:grid-cols-2")}>
<div className="space-y-2.5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>预览图</div>
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt={`${entityLabel}预览图`}
className={cn(
"h-20 w-20 rounded-xl border object-cover shadow-sm",
isDark ? "border-white/10" : "border-border"
)}
/>
) : (
<div
className={cn(
"flex h-20 w-20 items-center justify-center rounded-xl border text-xs shadow-sm",
isDark
? "border-white/10 bg-black/30 text-white/40"
: "bg-muted text-muted-foreground border-border"
)}
>
未上传
</div>
)}
<label className="inline-flex">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleAvatarUpload(event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-9 px-4 text-sm gap-1.5",
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
>
{avatarUploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
上传预览图
</Button>
</label>
</div>
</div>
<div className="space-y-2.5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
{entityNameLabel}
</div>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
className={cn(
"h-10",
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/25 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
</div>
<div className={cn("grid grid-cols-1 gap-4", type === "image" ? "md:grid-cols-4" : "md:grid-cols-3")}>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>可见性</div>
<Select
value={visibility}
onValueChange={(value: "public" | "private") => setVisibility(value)}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="private" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>私有</SelectItem>
<SelectItem value="public" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>公开</SelectItem>
</SelectContent>
</Select>
</div>
{type === "image" ? (
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>模式类型</div>
<Select
value={modeConfig.modeType}
onValueChange={(value: AgentPresetModeType) =>
setModeConfig((prev) => ({
...prev,
modeType: value,
}))
}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{imageModeSelectOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>类型</div>
<Select
value={type}
onValueChange={(value: AgentPresetType) => {
setType(value);
setDefaultModelId("");
}}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="image" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>生图</SelectItem>
<SelectItem value="video" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>视频</SelectItem>
<SelectItem value="text" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>生文</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>对应模型</div>
<Select value={defaultModelId} onValueChange={setDefaultModelId}>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{filteredModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{type === "image" ? (
<div
className={cn(
"rounded-xl border p-4",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className="mb-4">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
生图模式分类
</div>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
选择该生图模式显示在哪个分类页,以及对应的一级、二级、三级分类。
</div>
</div>
{presetLibrarySections.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>顶部分类页</div>
<Select
value={selectedPresetSectionId || "__none__"}
onValueChange={(value) => {
const nextSectionId = value === "__none__" ? "" : value;
setSelectedPresetSectionId(nextSectionId);
setSelectedPresetLevel1Id("");
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择顶部分类页" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不分类
</SelectItem>
{presetLibrarySections.map((section) => (
<SelectItem
key={section.id}
value={section.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{section.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>一级分类</div>
<Select
value={selectedPresetLevel1Id || "__none__"}
onValueChange={(value) => {
const nextValue = value === "__none__" ? "" : value;
setSelectedPresetLevel1Id(nextValue);
setSelectedPresetLevel2Id("");
setSelectedPresetLevel3Id("");
}}
disabled={!selectedPresetSection || presetLevel1Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择一级分类" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel1Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>二级分类</div>
<Select
value={selectedPresetLevel2Id || "__none__"}
onValueChange={(value) => {
const nextValue = value === "__none__" ? "" : value;
setSelectedPresetLevel2Id(nextValue);
setSelectedPresetLevel3Id("");
}}
disabled={!selectedPresetLevel1 || presetLevel2Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择二级分类" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel2Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>三级分类</div>
<Select
value={selectedPresetLevel3Id || "__none__"}
onValueChange={(value) => {
setSelectedPresetLevel3Id(value === "__none__" ? "" : value);
}}
disabled={!selectedPresetLevel2 || presetLevel3Options.length === 0}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue placeholder="选择三级分类" />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
<SelectItem value="__none__" className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}>
不选择
</SelectItem>
{presetLevel3Options.map((node) => (
<SelectItem
key={node.id}
value={node.id}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{node.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className={cn("rounded-lg border border-dashed px-4 py-6 text-sm", isDark ? "border-white/10 bg-black/20 text-white/55" : "text-muted-foreground")}>
还没有配置任何生图分类,请先到“风格化工作流配置 - 生图分类”中新增顶部分类页和三级分类树。
</div>
)}
</div>
) : null}
{type === "image" ? (
<div
className={cn(
"rounded-xl border px-4 py-3.5",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
当前模式:{modeConfig.modeType === "legacy" ? "旧版兼容" : selectedImageModeMeta.label}
</div>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
{modeConfig.modeType === "legacy"
? "当前模板沿用旧版多槽位执行逻辑。切换到新模式后,将按新的业务流程配置。"
: selectedImageModeMeta.description}
</div>
</div>
) : null}
{type === "image" && modeConfig.modeType !== "legacy" ? (
<div
className={cn(
"rounded-xl border p-5",
isDark ? "border-white/10 bg-white/[0.02]" : "border-slate-200 bg-muted/30"
)}
>
<div className="mb-5">
<div className={cn("text-sm font-semibold", isDark ? "text-white" : "text-foreground")}>
{selectedImageModeMeta.label}配置
</div>
<div className={cn("mt-1.5 text-sm leading-relaxed", isDark ? "text-white/50" : "text-muted-foreground")}>
{isSceneModeBuilder
? "第一步提示词组合 + 自定义提示词 + 输入图片 + 模板参考图,共同组成生图输入。"
: isDetailReplacementModeBuilder
? "详情替换会使用 SPU 获取到的详情参考图逐张替换,不走原来的详情页生成规划流程。"
: "SKU替换会按输入图与参考图一对一关系执行替换,并保留后续批量下载与文字编辑能力。"}
</div>
</div>
{isSceneModeBuilder ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>产品图槽位数量</div>
<Select
value={String(modeConfig.scene.productImageSlotCount)}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
productImageSlotCount: Number(value),
},
}))
}
>
<SelectTrigger
className={cn(
"h-9 text-sm",
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{[1, 2, 3, 4].map((count) => (
<SelectItem
key={count}
value={String(count)}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{count} 张产品图
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>模特图槽位</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
开启后会额外保留 1 个模特图槽位
</div>
</div>
<Switch
checked={modeConfig.scene.includeModelSlot}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
includeModelSlot: checked,
},
}))
}
className="shrink-0"
/>
</div>
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>允许模板参考图</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
不强制,但可以预置风格参考图
</div>
</div>
<Switch
checked={modeConfig.scene.allowTemplateReferenceImages}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
allowTemplateReferenceImages: checked,
},
}))
}
className="shrink-0"
/>
</div>
<div className={cn(
"flex items-center justify-between rounded-lg border px-3.5 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-background"
)}>
<div className="flex flex-col gap-0.5">
<div className={cn("text-sm font-medium", isDark ? "text-white" : "text-foreground")}>允许用户切换模型</div>
<div className={cn("text-xs", isDark ? "text-white/45" : "text-muted-foreground")}>
关闭后第二步直接锁定模板模型
</div>
</div>
<Switch
checked={modeConfig.scene.allowModelOverride}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
allowModelOverride: checked,
},
}))
}
className="shrink-0"
/>
</div>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
产品图槽位数量
</div>
<Select
value={String(
isDetailReplacementModeBuilder
? modeConfig.detail.productImageSlotCount
: modeConfig.sku.productImageSlotCount
)}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
productImageSlotCount: isDetailReplacementModeBuilder
? Number(value)
: prev.detail.productImageSlotCount,
},
sku: {
...prev.sku,
productImageSlotCount: isSkuReplacementModeBuilder
? Number(value)
: prev.sku.productImageSlotCount,
},
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{[1, 2, 3, 4].map((count) => (
<SelectItem
key={count}
value={String(count)}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{count} 张产品图
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
参考图来源
</div>
<Select
value={
isDetailReplacementModeBuilder
? modeConfig.detail.referenceImageSource
: modeConfig.sku.referenceImageSource
}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
referenceImageSource: isDetailReplacementModeBuilder
? (value as AgentTaskDetailReferenceSource)
: prev.detail.referenceImageSource,
},
sku: {
...prev.sku,
referenceImageSource: isSkuReplacementModeBuilder
? (value as AgentTaskDetailReferenceSource)
: prev.sku.referenceImageSource,
},
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className={cn("text-xs leading-5", isDark ? "text-white/42" : "text-muted-foreground")}>
{TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find(
(option) =>
option.value ===
(isDetailReplacementModeBuilder
? modeConfig.detail.referenceImageSource
: modeConfig.sku.referenceImageSource)
)?.description || "根据模式配置自动获取对应参考图"}
</div>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模特图槽位</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
开启后额外保留 1 个模特图槽位
</div>
</div>
<Switch
checked={
isDetailReplacementModeBuilder
? modeConfig.detail.includeModelSlot
: modeConfig.sku.includeModelSlot
}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
includeModelSlot: isDetailReplacementModeBuilder
? checked
: prev.detail.includeModelSlot,
},
sku: {
...prev.sku,
includeModelSlot: isSkuReplacementModeBuilder
? checked
: prev.sku.includeModelSlot,
},
}))
}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>启用后续文字编辑</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
结果图生成后,可调用现有文字编辑能力逐屏修改。
</div>
</div>
<Switch
checked={
isDetailReplacementModeBuilder
? modeConfig.detail.enableTextEditing
: modeConfig.sku.enableTextEditing
}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
enableTextEditing: isDetailReplacementModeBuilder
? checked
: prev.detail.enableTextEditing,
},
sku: {
...prev.sku,
enableTextEditing: isSkuReplacementModeBuilder
? checked
: prev.sku.enableTextEditing,
},
}))
}
/>
</div>
<div className={cn("rounded-xl border border-dashed px-3 py-3 text-xs", isDark ? "border-white/10 text-white/45" : "text-muted-foreground")}>
{isDetailReplacementModeBuilder
? `详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.detail.referenceImageSource)?.label || "详情图"},再把输入图逐张替换到对应参考图中。`
: `SKU替换模式会把用户选中的白底图与${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.sku.referenceImageSource)?.label || "SKU图"}建立一对一关系,后续在第二步专用工作台中执行替换。`}
</div>
</div>
)}
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>描述</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
{isStructuredImageModeBuilder ? (
<div className="space-y-3 rounded-2xl border p-4">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
{selectedImageModeMeta.label}提示词
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{isSceneModeBuilder
? "这里定义这个模式的基础风格、镜头、氛围和结构规则。第二步会自动叠加第一步组合提示词与用户补充提示词。"
: isDetailReplacementModeBuilder
? "这里定义详情替换时的统一替换规则,例如保留版式、替换主体、保证细节一致等。"
: "这里定义 SKU 替换时的一对一替换规则,例如保留场景、替换主体、统一光影和风格。"}
</div>
</div>
<Textarea
rows={6}
value={slots[0]?.promptTemplate || ""}
onChange={(event) =>
handleSlotChange(0, { promptTemplate: event.target.value })
}
placeholder={
isDetailReplacementModeBuilder
? "例如:保持参考详情图原有版式和信息层级,将商品主体替换为输入图,保留光影、材质和透视关系一致..."
: isSkuReplacementModeBuilder
? "例如:按一对一方式替换参考图中的商品主体,保持场景关系、机位、透视和氛围不变..."
: "例如:生成高质感电商场景图,保持产品主体比例真实,材质细节清晰,构图偏商业摄影..."
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
{isSceneModeBuilder && modeConfig.scene.allowTemplateReferenceImages ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模板参考图</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
可选上传,不强制;第二步会与用户选择的输入图片一起作为参考图参与生成。
</div>
<div className="mt-3">
<input
ref={(element) => {
fileInputRefs.current[0] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadReferenceImages(0, event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[0]?.click()}
disabled={uploadingSlotIndex === 0}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
{uploadingSlotIndex === 0 ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传模板参考图
</Button>
{slots[0]?.localReferenceAssets?.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{slots[0].localReferenceAssets.map((asset, assetIndex) => (
<div key={`${asset.assetId}-${assetIndex}`} className="relative">
<img
src={asset.url}
alt={asset.name || `模板参考图 ${assetIndex + 1}`}
className={cn(
"h-16 w-16 rounded-lg border object-cover",
isDark ? "border-white/10" : ""
)}
/>
</div>
))}
</div>
) : (
<div className={cn("mt-2 text-xs", isDark ? "text-white/40" : "text-muted-foreground")}>
未上传模板参考图
</div>
)}
</div>
</div>
) : null}
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>槽位配置</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
当前共 {slots.length} 个槽位,使用时固定上传 3 张素材图:产品图1、产品图2、模特图
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddSlot}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
<Plus className="mr-1 h-4 w-4" />
新增槽位
</Button>
</div>
<div className="space-y-3">
{slots.map((slot, index) => (
<div
key={`slot-${index}`}
className={cn(
"rounded-2xl border p-3",
isDark ? "border-white/10 bg-black/20" : ""
)}
>
<div className="mb-3 flex items-center justify-between">
<div className={cn("font-medium", isDark ? "text-white" : "")}>槽位 {index + 1}</div>
{slots.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
isDark ? "text-white/60 hover:bg-white/8 hover:text-white" : ""
)}
onClick={() => handleRemoveSlot(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>槽位名称</div>
<Input
value={slot.title || ""}
onChange={(event) =>
handleSlotChange(index, { title: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>上传提示语</div>
<Input
value={slot.uploadLabel || ""}
onChange={(event) =>
handleSlotChange(index, { uploadLabel: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>提示词</div>
<Textarea
rows={4}
value={slot.promptTemplate}
onChange={(event) =>
handleSlotChange(index, { promptTemplate: event.target.value })
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
</div>
<div className="mt-3">
<input
ref={(element) => {
fileInputRefs.current[index] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadReferenceImages(index, event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[index]?.click()}
disabled={uploadingSlotIndex === index}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
{uploadingSlotIndex === index ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传参考图
</Button>
{slot.localReferenceAssets.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{slot.localReferenceAssets.map((asset, assetIndex) => (
<div key={`${asset.assetId}-${assetIndex}`} className="relative">
<img
src={asset.url}
alt={asset.name || `参考图 ${assetIndex + 1}`}
className={cn(
"h-16 w-16 rounded-lg border object-cover",
isDark ? "border-white/10" : ""
)}
/>
</div>
))}
</div>
) : (
<div className={cn("mt-2 text-xs", isDark ? "text-white/40" : "text-muted-foreground")}>
未上传参考图
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="gap-2 pt-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className={cn(
"h-9 px-5 text-sm",
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
取消
</Button>
<Button
onClick={handleCreate}
disabled={isSubmitting}
className={cn(
"h-9 px-5 text-sm gap-1.5",
isDark
? "bg-gradient-to-r from-emerald-500 to-teal-500 text-white hover:from-emerald-400 hover:to-teal-400"
: "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
)}
>
{isSubmitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}