ai-chatbot/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx
Your Name 281593ff29
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: align agent workflow dark theme accents
2026-03-18 01:37:44 +08:00

1233 lines
48 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 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 {
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-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,
AgentWorkflowShowcaseConfig,
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;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
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)
);
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
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,
agentWorkflowShowcaseConfig: showcaseConfig,
}),
});
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),
}));
};
const handleUploadShowcaseAsset = async (
target: "video" | "poster",
file: File
) => {
const isVideo = target === "video";
const expectedPrefix = isVideo ? "video/" : "image/";
if (!file.type.startsWith(expectedPrefix)) {
toast.error(isVideo ? "请上传视频文件" : "请上传图片文件");
return;
}
const refKey = `showcase:${target}`;
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 || "上传失败");
}
setShowcaseConfig((prev) => ({
...prev,
[target === "video" ? "videoUrl" : "posterUrl"]: result.url,
}));
toast.success(isVideo ? "视频海报上传成功" : "视频封面上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploadingKey(null);
}
};
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>
配置第一步顶部展示区的视频海报。前台会按固定 21:9 比例自动循环播放,并同步显示可自定义标题、按钮文案和海报叠加文字。
</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={showcaseConfig.enabled}
onCheckedChange={(checked) =>
setShowcaseConfig((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={showcaseConfig.title}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
title: event.target.value,
}))
}
placeholder="三倍速出片,电影级质感"
/>
</div>
<div className="space-y-2">
<Label>顶部副标题</Label>
<Input
value={showcaseConfig.subtitle}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
subtitle: event.target.value,
}))
}
placeholder="第一步:风格选择 第二步:生图模式"
/>
</div>
<div className="space-y-2">
<Label>按钮文案</Label>
<Input
value={showcaseConfig.actionText}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
actionText: event.target.value,
}))
}
placeholder="我要创作"
/>
</div>
<div className="space-y-2">
<Label>海报底部说明</Label>
<Input
value={showcaseConfig.footerText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
footerText: event.target.value,
}))
}
placeholder="用户作品"
/>
</div>
</div>
<div className="space-y-2">
<Label>海报叠加文字</Label>
<Textarea
value={showcaseConfig.overlayText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
overlayText: event.target.value,
}))
}
rows={2}
placeholder="例如:让每一步选择,都能直达高质感成片"
/>
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="space-y-2">
<Label>视频链接</Label>
<Input
value={showcaseConfig.videoUrl}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>视频封面链接</Label>
<Input
value={showcaseConfig.posterUrl || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
posterUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="flex flex-wrap gap-2">
<input
ref={(element) => {
fileInputRefs.current["showcase:video"] = element;
}}
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("video", file);
event.target.value = "";
}}
/>
<input
ref={(element) => {
fileInputRefs.current["showcase:poster"] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("poster", file);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:video"}
onClick={() => fileInputRefs.current["showcase:video"]?.click()}
>
{uploadingKey === "showcase:video" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传视频
</Button>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:poster"}
onClick={() => fileInputRefs.current["showcase:poster"]?.click()}
>
{uploadingKey === "showcase:poster" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传封面图
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: "",
posterUrl: "",
}))
}
>
清空媒体
</Button>
</div>
<div className="text-xs text-muted-foreground">
建议优先上传静音短视频,前台会自动以 `muted + loop + playsInline` 形式循环播放;未配置视频时会回退到封面图展示。
</div>
</div>
<div className="space-y-2">
<Label>前台预览</Label>
<div className="overflow-hidden rounded-2xl border bg-slate-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
<div className="relative aspect-[10/3] w-full overflow-hidden bg-black">
{showcaseConfig.videoUrl ? (
<video
key={showcaseConfig.videoUrl}
src={showcaseConfig.videoUrl}
poster={showcaseConfig.posterUrl || undefined}
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : showcaseConfig.posterUrl ? (
<img
src={showcaseConfig.posterUrl}
alt={showcaseConfig.overlayText || showcaseConfig.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-white/45">
暂无视频海报
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/10 to-black/25" />
{showcaseConfig.overlayText ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-5 text-center text-lg font-semibold tracking-[0.18em] text-white/22">
{showcaseConfig.overlayText}
</div>
) : null}
{showcaseConfig.footerText ? (
<div className="pointer-events-none absolute bottom-3 left-3 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[11px] text-white/75">
{showcaseConfig.footerText}
</div>
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<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>
);
}