1710 lines
72 KiB
TypeScript
1710 lines
72 KiB
TypeScript
"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", 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>
|
||
);
|
||
}
|