938 lines
36 KiB
TypeScript
938 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
import {
|
||
ArrowLeft,
|
||
Image as ImageIcon,
|
||
Loader2,
|
||
Plus,
|
||
Save,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import {
|
||
defaultAgentWorkflowUiConfig,
|
||
normalizeAgentWorkflowUiConfig,
|
||
} from "@/lib/agents/workflow-ui-config";
|
||
import {
|
||
defaultAgentWorkflowBrandModelConfig,
|
||
normalizeAgentWorkflowBrandModelConfig,
|
||
} from "@/lib/agents/workflow-brand-model-config";
|
||
import {
|
||
defaultAgentWorkflowMaterialApiConfig,
|
||
normalizeAgentWorkflowMaterialApiConfig,
|
||
} from "@/lib/agents/workflow-material-config";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import type {
|
||
AgentWorkflowBrandModelConfig,
|
||
AgentWorkflowMaterialApiConfig,
|
||
AgentWorkflowOptionUiConfig,
|
||
AgentWorkflowStepUiConfig,
|
||
AgentWorkflowUiConfig,
|
||
} from "@/types/agent";
|
||
|
||
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
|
||
|
||
function normalizeConfigOrdering(config: AgentWorkflowUiConfig): AgentWorkflowUiConfig {
|
||
return {
|
||
...config,
|
||
steps: config.steps.map((step, stepIndex) => ({
|
||
...step,
|
||
sortOrder: stepIndex,
|
||
options: step.options.map((option, optionIndex) => ({
|
||
...option,
|
||
sortOrder: optionIndex,
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function createWorkflowSlug(value: string, fallback: string) {
|
||
const normalized = value
|
||
.trim()
|
||
.replace(/\s+/g, "_")
|
||
.replace(/[^\w-]/g, "_")
|
||
.replace(/_+/g, "_")
|
||
.replace(/^_+|_+$/g, "")
|
||
.toLowerCase();
|
||
return normalized || fallback;
|
||
}
|
||
|
||
function ensureUniqueWorkflowKey(baseKey: string, existingKeys: string[]) {
|
||
const normalizedBase = createWorkflowSlug(baseKey, "item");
|
||
const keySet = new Set(existingKeys);
|
||
if (!keySet.has(normalizedBase)) {
|
||
return normalizedBase;
|
||
}
|
||
|
||
let suffix = 2;
|
||
let candidate = `${normalizedBase}_${suffix}`;
|
||
while (keySet.has(candidate)) {
|
||
suffix += 1;
|
||
candidate = `${normalizedBase}_${suffix}`;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
function createWorkflowOptionDraft(
|
||
stepKey: string,
|
||
optionIndex: number,
|
||
existingOptionKeys: string[]
|
||
): AgentWorkflowOptionUiConfig {
|
||
const optionKey = ensureUniqueWorkflowKey(
|
||
`${stepKey}_option_${optionIndex + 1}`,
|
||
existingOptionKeys
|
||
);
|
||
return {
|
||
key: optionKey,
|
||
buttonName: `选项${optionIndex + 1}`,
|
||
description: "",
|
||
previewImageUrl: "",
|
||
enabled: true,
|
||
sortOrder: optionIndex,
|
||
promptFragment: `请按选项${optionIndex + 1}执行。`,
|
||
};
|
||
}
|
||
|
||
function createWorkflowStepDraft(
|
||
stepIndex: number,
|
||
existingStepKeys: string[]
|
||
): AgentWorkflowStepUiConfig {
|
||
const stepKey = ensureUniqueWorkflowKey(`step_${stepIndex + 1}`, existingStepKeys);
|
||
return {
|
||
key: stepKey,
|
||
title: `新步骤${stepIndex + 1}`,
|
||
description: "",
|
||
enabled: true,
|
||
sortOrder: stepIndex,
|
||
options: [createWorkflowOptionDraft(stepKey, 0, [])],
|
||
};
|
||
}
|
||
|
||
interface AgentWorkflowSettingsClientProps {
|
||
backHref?: string;
|
||
backLabel?: string;
|
||
}
|
||
|
||
export function AgentWorkflowSettingsClient({
|
||
backHref = "/settings",
|
||
backLabel = "返回设置",
|
||
}: AgentWorkflowSettingsClientProps = {}) {
|
||
const { data, isLoading, mutate } = useSWR<{
|
||
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
|
||
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
|
||
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
|
||
}>("/api/site-settings", fetcher);
|
||
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
|
||
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
|
||
defaultAgentWorkflowMaterialApiConfig
|
||
);
|
||
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
|
||
defaultAgentWorkflowBrandModelConfig
|
||
);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig));
|
||
setMaterialApiConfig(
|
||
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
|
||
);
|
||
setBrandModelConfig(
|
||
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
|
||
);
|
||
}, [data]);
|
||
|
||
const configuredBrands = useMemo(
|
||
() => config.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
|
||
[config.inputOptions?.brands]
|
||
);
|
||
const brandModelBrands = useMemo(() => {
|
||
return Array.from(
|
||
new Set([
|
||
...configuredBrands,
|
||
...brandModelConfig.entries.map((entry) => entry.brandName),
|
||
])
|
||
).sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||
}, [brandModelConfig.entries, configuredBrands]);
|
||
|
||
const sortedSteps = useMemo(
|
||
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
|
||
[config.steps]
|
||
);
|
||
|
||
const updateStep = (
|
||
stepKey: string,
|
||
updater: (step: AgentWorkflowUiConfig["steps"][number]) => AgentWorkflowUiConfig["steps"][number]
|
||
) => {
|
||
setConfig((prev) =>
|
||
normalizeConfigOrdering({
|
||
...prev,
|
||
steps: prev.steps.map((step) => (step.key === stepKey ? updater(step) : step)),
|
||
})
|
||
);
|
||
};
|
||
|
||
const updateOption = (
|
||
stepKey: string,
|
||
optionKey: string,
|
||
updater: (
|
||
option: AgentWorkflowUiConfig["steps"][number]["options"][number]
|
||
) => AgentWorkflowUiConfig["steps"][number]["options"][number]
|
||
) => {
|
||
updateStep(stepKey, (step) => ({
|
||
...step,
|
||
options: step.options.map((option) =>
|
||
option.key === optionKey ? updater(option) : option
|
||
),
|
||
}));
|
||
};
|
||
|
||
const handleAddStep = () => {
|
||
setConfig((prev) =>
|
||
normalizeConfigOrdering({
|
||
...prev,
|
||
steps: [
|
||
...prev.steps,
|
||
createWorkflowStepDraft(prev.steps.length, prev.steps.map((step) => step.key)),
|
||
],
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleRemoveStep = (stepKey: string) => {
|
||
setConfig((prev) =>
|
||
normalizeConfigOrdering({
|
||
...prev,
|
||
steps: prev.steps.filter((step) => step.key !== stepKey),
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleAddOption = (stepKey: string) => {
|
||
updateStep(stepKey, (step) => ({
|
||
...step,
|
||
options: [
|
||
...step.options,
|
||
createWorkflowOptionDraft(step.key, step.options.length, step.options.map((item) => item.key)),
|
||
],
|
||
}));
|
||
};
|
||
|
||
const handleRemoveOption = (stepKey: string, optionKey: string) => {
|
||
updateStep(stepKey, (step) => ({
|
||
...step,
|
||
options: step.options.filter((option) => option.key !== optionKey),
|
||
}));
|
||
};
|
||
|
||
const handleUploadPreview = async (stepKey: string, optionKey: string, file: File) => {
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
return;
|
||
}
|
||
|
||
const refKey = `${stepKey}:${optionKey}`;
|
||
try {
|
||
setUploadingKey(refKey);
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const result = await response.json();
|
||
|
||
if (!response.ok || !result?.url) {
|
||
throw new Error(result?.error || "上传失败");
|
||
}
|
||
|
||
updateOption(stepKey, optionKey, (option) => ({
|
||
...option,
|
||
previewImageUrl: result.url,
|
||
}));
|
||
toast.success("预览图上传成功");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "预览图上传失败");
|
||
} finally {
|
||
setUploadingKey(null);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
setIsSaving(true);
|
||
const response = await fetch("/api/site-settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
agentWorkflowUiConfig: config,
|
||
agentWorkflowMaterialApiConfig: materialApiConfig,
|
||
agentWorkflowBrandModelConfig: brandModelConfig,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error("保存失败");
|
||
}
|
||
|
||
toast.success("智能体工作流配置已保存");
|
||
await mutate();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const updateBrandModelEntry = (
|
||
brandName: string,
|
||
updater: (
|
||
entry: NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
|
||
) => NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
|
||
) => {
|
||
setBrandModelConfig((prev) => {
|
||
const entryIndex = prev.entries.findIndex((entry) => entry.brandName === brandName);
|
||
const currentEntry =
|
||
entryIndex >= 0
|
||
? prev.entries[entryIndex]
|
||
: {
|
||
brandName,
|
||
images: [],
|
||
};
|
||
const nextEntry = updater(currentEntry);
|
||
const nextEntries =
|
||
entryIndex >= 0
|
||
? prev.entries.map((entry, index) => (index === entryIndex ? nextEntry : entry))
|
||
: [...prev.entries, nextEntry];
|
||
|
||
return {
|
||
entries: nextEntries
|
||
.filter((entry) => entry.images.length > 0 || configuredBrands.includes(entry.brandName))
|
||
.map((entry) => ({
|
||
...entry,
|
||
images: entry.images
|
||
.slice()
|
||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||
.map((image, index) => ({
|
||
...image,
|
||
sortOrder: index,
|
||
})),
|
||
})),
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleUploadBrandModelImages = async (brandName: string, files: FileList | null) => {
|
||
const pickedFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||
if (pickedFiles.length === 0) {
|
||
toast.error("请先选择图片文件");
|
||
return;
|
||
}
|
||
|
||
const refKey = `brand-model:${brandName}`;
|
||
try {
|
||
setUploadingKey(refKey);
|
||
const uploadedImages: AgentWorkflowBrandModelConfig["entries"][number]["images"] = [];
|
||
|
||
for (const file of pickedFiles) {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const result = await response.json();
|
||
|
||
if (!response.ok || !result?.url) {
|
||
throw new Error(result?.error || "上传失败");
|
||
}
|
||
|
||
uploadedImages.push({
|
||
id:
|
||
(typeof result.assetId === "string" && result.assetId.trim()) ||
|
||
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||
assetId: typeof result.assetId === "string" ? result.assetId : null,
|
||
imageUrl: result.url,
|
||
displayName: file.name,
|
||
mimeType: file.type || null,
|
||
sortOrder: 0,
|
||
});
|
||
}
|
||
|
||
updateBrandModelEntry(brandName, (entry) => ({
|
||
...entry,
|
||
images: [
|
||
...entry.images,
|
||
...uploadedImages.map((image, index) => ({
|
||
...image,
|
||
sortOrder: entry.images.length + index,
|
||
})),
|
||
],
|
||
}));
|
||
toast.success(
|
||
uploadedImages.length === 1 ? "模特图上传成功" : `已上传 ${uploadedImages.length} 张模特图`
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "模特图上传失败");
|
||
} finally {
|
||
setUploadingKey(null);
|
||
}
|
||
};
|
||
|
||
const handleRemoveBrandModelImage = (brandName: string, imageId: string) => {
|
||
updateBrandModelEntry(brandName, (entry) => ({
|
||
...entry,
|
||
images: entry.images.filter((image) => image.id !== imageId),
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="container mx-auto space-y-6 p-6">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Sparkles className="h-4 w-4" />
|
||
智能体配置中心
|
||
</div>
|
||
<h1 className="text-2xl font-semibold">智能体工作流配置</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
单独维护智能体页面左侧每个环节的功能按钮名称、说明、Prompt 片段和预览图。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button asChild variant="outline">
|
||
<Link href={backHref}>
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
{backLabel}
|
||
</Link>
|
||
</Button>
|
||
<Button onClick={handleSave} disabled={isSaving}>
|
||
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||
保存配置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>功能说明</CardTitle>
|
||
<CardDescription>
|
||
这里配置的是智能体工作流页面的功能卡片。网站设置页只保留入口,不再承载这些详细配置。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
</Card>
|
||
|
||
{isLoading ? (
|
||
<Card>
|
||
<CardContent className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-5">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>素材搜索接口配置</CardTitle>
|
||
<CardDescription>
|
||
配置主 SPU / 搭配 SKU 搜图所需的接口地址与 API Key。Key 会按系统设置方式存储到数据库中,不会下发到前台页面。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-4 py-3">
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium">启用素材搜索</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
开启后,智能体工作流页面支持搜索并选择白底素材图。
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={materialApiConfig.enabled}
|
||
onCheckedChange={(checked) =>
|
||
setMaterialApiConfig((prev) => ({
|
||
...prev,
|
||
enabled: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>接口基地址</Label>
|
||
<Input
|
||
value={materialApiConfig.baseUrl}
|
||
onChange={(event) =>
|
||
setMaterialApiConfig((prev) => ({
|
||
...prev,
|
||
baseUrl: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="https://sc.jiansun.vip/api/external"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>API Key</Label>
|
||
<Input
|
||
type="password"
|
||
value={materialApiConfig.apiKey}
|
||
onChange={(event) =>
|
||
setMaterialApiConfig((prev) => ({
|
||
...prev,
|
||
apiKey: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="请输入素材搜索 API Key"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-xs text-muted-foreground">
|
||
当前适配的是白底图搜索接口;前台不会直接暴露 Key,而是由服务端代查并转存图片。
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>基础下拉配置</CardTitle>
|
||
<CardDescription>
|
||
工作流左侧“品牌 / 店铺”采用后台配置的候选列表,前台支持输入关键字匹配下拉选择。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>品牌列表</Label>
|
||
<Textarea
|
||
value={(config.inputOptions?.brands || []).join("\n")}
|
||
onChange={(event) =>
|
||
setConfig((prev) => ({
|
||
...prev,
|
||
inputOptions: {
|
||
brands: event.target.value
|
||
.split("\n")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
stores: prev.inputOptions?.stores || [],
|
||
},
|
||
}))
|
||
}
|
||
rows={10}
|
||
placeholder={"例如:\n宜家\n顾家家居\n林氏家居"}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>店铺列表</Label>
|
||
<Textarea
|
||
value={(config.inputOptions?.stores || []).join("\n")}
|
||
onChange={(event) =>
|
||
setConfig((prev) => ({
|
||
...prev,
|
||
inputOptions: {
|
||
brands: prev.inputOptions?.brands || [],
|
||
stores: event.target.value
|
||
.split("\n")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
},
|
||
}))
|
||
}
|
||
rows={10}
|
||
placeholder={"例如:\n天猫旗舰店\n京东自营店\n抖音官方店"}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>品牌模特图库</CardTitle>
|
||
<CardDescription>
|
||
第二步会根据第一步选择的品牌,展示这里配置的品牌模特图供用户单选,并自动带入生图流程的模特图槽位。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{brandModelBrands.length > 0 ? (
|
||
brandModelBrands.map((brandName) => {
|
||
const refKey = `brand-model:${brandName}`;
|
||
const entry =
|
||
brandModelConfig.entries.find((item) => item.brandName === brandName) || null;
|
||
const images = entry?.images || [];
|
||
const isUploading = uploadingKey === refKey;
|
||
|
||
return (
|
||
<div key={brandName} className="rounded-2xl border bg-muted/10 p-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-medium">{brandName}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
当前已配置 {images.length} 张模特图
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
ref={(element) => {
|
||
fileInputRefs.current[refKey] = element;
|
||
}}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
void handleUploadBrandModelImages(brandName, event.target.files);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => fileInputRefs.current[refKey]?.click()}
|
||
disabled={isUploading}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
)}
|
||
上传模特图
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{images.length > 0 ? (
|
||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
|
||
{images.map((image) => (
|
||
<div
|
||
key={image.id}
|
||
className="overflow-hidden rounded-2xl border bg-background"
|
||
>
|
||
<div className="relative aspect-square overflow-hidden bg-muted/30">
|
||
<img
|
||
src={image.imageUrl}
|
||
alt={image.displayName || `${brandName} 模特图`}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
||
<div className="min-w-0 text-[11px] text-muted-foreground">
|
||
<div className="truncate">
|
||
{image.displayName || `${brandName} 模特图`}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||
onClick={() =>
|
||
handleRemoveBrandModelImage(brandName, image.id)
|
||
}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||
当前品牌还没有上传模特图,上传后第二步会自动按品牌展示给用户选择。
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||
先在上方“品牌列表”里配置品牌,随后即可为每个品牌上传对应的模特图。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex justify-end">
|
||
<Button type="button" variant="outline" onClick={handleAddStep}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新增步骤
|
||
</Button>
|
||
</div>
|
||
|
||
{sortedSteps.length > 0 ? (
|
||
sortedSteps.map((step) => (
|
||
<Card key={step.key}>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex-1 space-y-3">
|
||
<div className="space-y-1">
|
||
<div className="text-xs font-medium text-muted-foreground">
|
||
步骤 Key:{step.key}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
新增步骤后系统会自动生成唯一 Key,用于前台保存和读取选择结果。
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>步骤标题</Label>
|
||
<Input
|
||
value={step.title}
|
||
onChange={(event) =>
|
||
updateStep(step.key, (current) => ({
|
||
...current,
|
||
title: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="步骤标题"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>步骤说明</Label>
|
||
<Textarea
|
||
value={step.description || ""}
|
||
onChange={(event) =>
|
||
updateStep(step.key, (current) => ({
|
||
...current,
|
||
description: event.target.value,
|
||
}))
|
||
}
|
||
rows={2}
|
||
placeholder="步骤说明"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||
<Button type="button" variant="outline" size="sm" onClick={() => handleAddOption(step.key)}>
|
||
<Plus className="mr-1 h-4 w-4" />
|
||
新增选项
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-destructive hover:text-destructive"
|
||
onClick={() => handleRemoveStep(step.key)}
|
||
>
|
||
<Trash2 className="mr-1 h-4 w-4" />
|
||
删除步骤
|
||
</Button>
|
||
<Label htmlFor={`workflow-step-${step.key}`}>启用</Label>
|
||
<Switch
|
||
id={`workflow-step-${step.key}`}
|
||
checked={step.enabled}
|
||
onCheckedChange={(checked) =>
|
||
updateStep(step.key, (current) => ({
|
||
...current,
|
||
enabled: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent>
|
||
{step.options.length > 0 ? (
|
||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||
{step.options
|
||
.slice()
|
||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||
.map((option) => {
|
||
const refKey = `${step.key}:${option.key}`;
|
||
const isUploading = uploadingKey === refKey;
|
||
|
||
return (
|
||
<div key={option.key} className="rounded-xl border bg-muted/20 p-4 space-y-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="text-sm font-medium">{option.key}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
配置按钮名称与预览图
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 px-2 text-xs text-destructive hover:text-destructive"
|
||
onClick={() => handleRemoveOption(step.key, option.key)}
|
||
>
|
||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||
删除选项
|
||
</Button>
|
||
<Label
|
||
htmlFor={`workflow-option-${step.key}-${option.key}`}
|
||
className="text-xs text-muted-foreground"
|
||
>
|
||
启用
|
||
</Label>
|
||
<Switch
|
||
id={`workflow-option-${step.key}-${option.key}`}
|
||
checked={option.enabled}
|
||
onCheckedChange={(checked) =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
enabled: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-[148px_minmax(0,1fr)] gap-4">
|
||
<div className="space-y-3">
|
||
<div className="flex h-[120px] items-center justify-center overflow-hidden rounded-xl border bg-background">
|
||
{option.previewImageUrl ? (
|
||
<img
|
||
src={option.previewImageUrl}
|
||
alt={option.buttonName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="px-3 text-center text-xs text-muted-foreground">
|
||
<ImageIcon className="mx-auto mb-2 h-5 w-5 opacity-60" />
|
||
暂无预览图
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<input
|
||
ref={(element) => {
|
||
fileInputRefs.current[refKey] = element;
|
||
}}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
void handleUploadPreview(step.key, option.key, file);
|
||
event.target.value = "";
|
||
}}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={isUploading}
|
||
onClick={() => fileInputRefs.current[refKey]?.click()}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="mr-2 h-4 w-4" />
|
||
)}
|
||
上传图片
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
previewImageUrl: "",
|
||
}))
|
||
}
|
||
>
|
||
清空图片
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="space-y-2">
|
||
<Label>按钮名称</Label>
|
||
<Input
|
||
value={option.buttonName}
|
||
onChange={(event) =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
buttonName: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="按钮名称"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>说明文案</Label>
|
||
<Textarea
|
||
value={option.description || ""}
|
||
onChange={(event) =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
description: event.target.value,
|
||
}))
|
||
}
|
||
rows={2}
|
||
placeholder="说明文案"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>预览图链接</Label>
|
||
<Input
|
||
value={option.previewImageUrl || ""}
|
||
onChange={(event) =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
previewImageUrl: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Prompt 片段</Label>
|
||
<Textarea
|
||
value={option.promptFragment || ""}
|
||
onChange={(event) =>
|
||
updateOption(step.key, option.key, (current) => ({
|
||
...current,
|
||
promptFragment: event.target.value,
|
||
}))
|
||
}
|
||
rows={3}
|
||
placeholder="用于执行时注入的提示片段"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||
当前步骤还没有选项,请先新增一个选项。
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))
|
||
) : (
|
||
<Card>
|
||
<CardContent className="rounded-2xl border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">
|
||
还没有配置工作流步骤,点击上方“新增步骤”开始搭建第一步页面。
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|