ai-chatbot/components/agents/AgentWorkflowMaterialPicker.tsx
Your Name e5ef86f089
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: simplify agent material picker controls
2026-03-25 11:14:32 +08:00

759 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, ChevronUp, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type {
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSearchResult,
} from "@/types/agent";
interface AgentWorkflowMaterialPickerProps {
title?: string;
description?: string;
value: string;
queryMode: AgentWorkflowMaterialQueryType;
placeholder: string;
buttonLabel: string;
searchNote?: string;
disabled?: boolean;
disabledHint?: string;
isLoading?: boolean;
errorText?: string | null;
result?: AgentWorkflowMaterialSearchResult | null;
selectedImageIds: string[];
selectionLimit?: number | null;
selectionLimitLabel?: string;
onValueChange: (value: string) => void;
onQueryModeChange: (value: AgentWorkflowMaterialQueryType) => void;
onSearch: () => void;
onSkuOptionSelect?: (skuName: string) => void;
onSelectedImageIdsChange: (next: string[]) => void;
theme?: "light" | "dark";
}
export function AgentWorkflowMaterialPicker({
title,
description,
value,
queryMode,
placeholder,
buttonLabel,
searchNote,
disabled = false,
disabledHint,
isLoading = false,
errorText,
result,
selectedImageIds,
selectionLimit = null,
selectionLimitLabel = "当前生图模式",
onValueChange,
onQueryModeChange,
onSearch,
onSkuOptionSelect,
onSelectedImageIdsChange,
theme = "light",
}: AgentWorkflowMaterialPickerProps) {
const isDark = theme === "dark";
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
const [isResultDetailsExpanded, setIsResultDetailsExpanded] = useState(true);
const selectedSet = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
[selectedImageIds]
);
const recommendedImageIds = useMemo(
() =>
Array.isArray(result?.recommendedImageIds)
? result.recommendedImageIds.map((item) => String(item || "").trim()).filter(Boolean)
: [],
[result?.recommendedImageIds]
);
const selectedCount = result?.images
? result.images.filter((item) => selectedSet.has(item.id)).length
: 0;
const resolvedSelectionLimit =
typeof selectionLimit === "number" && Number.isFinite(selectionLimit) && selectionLimit > 0
? Math.max(1, Math.round(selectionLimit))
: null;
const hasSelectableImages = Boolean(
result?.images?.some((item) => Boolean(String(item.imageUrl || "").trim()))
);
const goodsInfoItems = [
{
label: "商品名称",
value: result?.goodsInfo?.productName || null,
fullWidth: true,
},
{
label: "尺寸信息",
value: result?.goodsInfo?.sizeInfo || null,
},
{
label: "材质信息",
value: result?.goodsInfo?.materialInfo || null,
},
{
label: "设计师",
value: result?.goodsInfo?.designer || null,
},
{
label: "采购负责人",
value: result?.goodsInfo?.buyer || null,
},
{
label: "板材类型",
value: result?.goodsInfo?.boardType || null,
},
].filter((item) => Boolean(item.value));
const remainingSelectableCount =
resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0);
const totalImageCount = result?.images?.length || 0;
const hasSystemRecommendedSelection =
result?.queryType === "sku" &&
recommendedImageIds.length > 0 &&
selectedImageIds.length === recommendedImageIds.length &&
recommendedImageIds.every((id) => selectedSet.has(id));
const isAutoSelectedResult =
Boolean(hasSystemRecommendedSelection) && !hasManualSelectionInteraction;
const canTogglePreviewMode = totalImageCount > 0 && selectedCount > 0 && selectedCount < totalImageCount;
const collapsedResultSummary = useMemo(() => {
const title = result?.goodsInfo?.productName || result?.matchedSku || result?.query || "当前SKU";
return `${title} · 系统已自动选中 ${selectedCount} 张图`;
}, [result?.goodsInfo?.productName, result?.matchedSku, result?.query, selectedCount]);
const visibleImages = useMemo(() => {
if (!result?.images?.length) {
return [];
}
if (previewDisplayMode === "selected" && selectedCount > 0) {
return result.images.filter((image) => selectedSet.has(image.id));
}
return result.images;
}, [previewDisplayMode, result?.images, selectedCount, selectedSet]);
const previewResetKey = useMemo(
() =>
JSON.stringify({
queryType: result?.queryType || "",
matchedSku: result?.matchedSku || "",
imageIds: result?.images?.map((image) => image.id) || [],
recommendedImageIds,
}),
[recommendedImageIds, result?.images, result?.matchedSku, result?.queryType]
);
useEffect(() => {
setHasManualSelectionInteraction(false);
}, [previewResetKey]);
useEffect(() => {
if (hasManualSelectionInteraction) {
return;
}
setPreviewDisplayMode(hasSystemRecommendedSelection ? "selected" : "all");
setIsResultDetailsExpanded(!hasSystemRecommendedSelection);
}, [hasManualSelectionInteraction, hasSystemRecommendedSelection, previewResetKey]);
const handleToggleImage = (imageId: string) => {
setHasManualSelectionInteraction(true);
const next = new Set(selectedImageIds);
if (next.has(imageId)) {
next.delete(imageId);
} else {
if (resolvedSelectionLimit !== null && next.size >= resolvedSelectionLimit) {
toast.error(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片`);
return;
}
next.add(imageId);
}
onSelectedImageIdsChange(Array.from(next));
};
const handleSelectAll = () => {
setHasManualSelectionInteraction(true);
const allIds =
result?.images
?.filter((item) => Boolean(String(item.imageUrl || "").trim()))
.map((item) => item.id) || [];
const nextIds =
resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds;
onSelectedImageIdsChange(nextIds);
if (resolvedSelectionLimit !== null && allIds.length > resolvedSelectionLimit) {
toast.message(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片,已按上限选取`);
}
};
return (
<section
className={cn(
"space-y-3 rounded-[24px] border p-4",
isDark ? "border-white/12 bg-black/20" : "bg-muted/20"
)}
>
{title || description ? (
<div className="space-y-1">
{title ? (
<div className={cn("text-sm font-medium", isDark ? "text-white/92" : "")}>{title}</div>
) : null}
{description ? (
<div className={cn("text-xs", isDark ? "text-white/62" : "text-muted-foreground")}>
{description}
</div>
) : null}
</div>
) : null}
<div className="flex flex-wrap gap-2">
{(["sku", "spu"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onQueryModeChange(mode)}
disabled={disabled}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
queryMode === mode
? isDark
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-primary bg-primary/10 text-primary"
: isDark
? "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-45"
)}
>
按{mode === "sku" ? "SKU" : "SPU"}搜索
</button>
))}
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Input
value={value}
onChange={(event) => onValueChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/25 focus-visible:ring-emerald-400/30"
)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (disabled) return;
onSearch();
}
}}
/>
<Button
type="button"
onClick={onSearch}
disabled={disabled || isLoading || !value.trim()}
className={cn(
isDark &&
"border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.11] hover:text-white disabled:text-white/40"
)}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{buttonLabel}
</Button>
</div>
{searchNote ? (
<div className={cn("text-xs", isDark ? "text-white/56" : "text-muted-foreground")}>
{searchNote}
</div>
) : null}
{resolvedSelectionLimit !== null ? (
<div
className={cn(
"rounded-[20px] border px-3 py-2 text-xs",
isDark
? "border-emerald-500/18 bg-emerald-500/[0.08] text-emerald-100"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
)}
>
{selectionLimitLabel}最多可选择 {resolvedSelectionLimit} 张图片
{remainingSelectableCount !== null ? `,当前还可选择 ${remainingSelectableCount} 张。` : "。"}
</div>
) : null}
{disabled && disabledHint ? (
<div
className={cn(
"rounded-[20px] border px-3 py-2 text-xs",
isDark
? "border-amber-500/20 bg-amber-500/10 text-amber-100"
: "border-amber-200 bg-amber-50 text-amber-800"
)}
>
{disabledHint}
</div>
) : null}
{!disabled && errorText ? (
<div className="rounded-[20px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{errorText}
</div>
) : null}
{!disabled && result ? (
isAutoSelectedResult && !isResultDetailsExpanded ? (
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-[22px] border px-4 py-3",
isDark
? "border-emerald-500/18 bg-emerald-500/[0.06] text-emerald-100"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
)}
>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.12em] text-emerald-200/70">
已自动准备完成
</div>
<div className="mt-1 truncate text-sm font-medium text-white/92">
{collapsedResultSummary}
</div>
<div className="mt-1 text-xs text-emerald-100/72">
默认先替你收起结果区,需要检查商品信息、已选图片或手动微调时再展开查看。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsResultDetailsExpanded(true)}
className={cn(
"shrink-0",
isDark &&
"border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white"
)}
>
<ChevronDown className="mr-2 h-4 w-4" />
查看已选内容
</Button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className={cn("flex flex-wrap gap-2 text-xs", isDark ? "text-white/52" : "text-muted-foreground")}>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
命中:{result.matchedSku || result.query}
</span>
{result.matchedSpu ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
SPU:{result.matchedSpu}
</span>
) : null}
{result.productModel ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
型号:{result.productModel}
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
共 {result.totalCount} 张
</span>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
已选 {selectedCount} 张
</span>
{resolvedSelectionLimit !== null ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-emerald-500/[0.12] text-emerald-200" : "bg-emerald-50 text-emerald-700"
)}
>
最多 {resolvedSelectionLimit} 张
</span>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{canTogglePreviewMode ? (
<div
className={cn(
"inline-flex items-center rounded-full border p-1",
isDark ? "border-white/12 bg-white/[0.04]" : "border-border bg-background"
)}
>
<button
type="button"
onClick={() => setPreviewDisplayMode("selected")}
disabled={selectedCount === 0}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
previewDisplayMode === "selected"
? isDark
? "bg-emerald-500/[0.16] text-emerald-100"
: "bg-primary/12 text-primary"
: isDark
? "text-white/60 hover:text-white disabled:text-white/28"
: "text-muted-foreground disabled:text-muted-foreground/50"
)}
>
已选 {selectedCount}
</button>
<button
type="button"
onClick={() => setPreviewDisplayMode("all")}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
previewDisplayMode === "all"
? isDark
? "bg-white/[0.08] text-white"
: "bg-muted text-foreground"
: isDark
? "text-white/60 hover:text-white"
: "text-muted-foreground"
)}
>
全部 {totalImageCount}
</button>
</div>
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={!hasSelectableImages}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white disabled:text-white/35"
)}
>
全选
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setHasManualSelectionInteraction(true);
onSelectedImageIdsChange([]);
}}
disabled={selectedCount === 0 && !hasSelectableImages}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white disabled:text-white/35"
)}
>
清空已选
</Button>
{isAutoSelectedResult ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsResultDetailsExpanded(false)}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white disabled:text-white/35"
)}
>
<ChevronUp className="mr-2 h-4 w-4" />
收起
</Button>
) : null}
</div>
</div>
{goodsInfoItems.length > 0 ? (
<div
className={cn(
"grid gap-2 rounded-[24px] border p-3",
isDark
? "border-white/12 bg-white/[0.03]"
: "border-border bg-background/70",
"grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
)}
>
{goodsInfoItems.map((item) => (
<div
key={`goods-info-${item.label}`}
className={cn(
"rounded-[18px] border px-3 py-2",
isDark ? "border-white/8 bg-black/20" : "border-border bg-muted/30",
item.fullWidth ? "md:col-span-2 xl:col-span-3" : ""
)}
>
<div className={cn("text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
{item.label}
</div>
<div className={cn("mt-1 text-sm font-medium leading-6", isDark ? "text-white/90" : "text-foreground")}>
{item.value}
</div>
</div>
))}
</div>
) : null}
{result.skuOptions?.length ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{result.skuOptions.map((option) => (
<div
key={`${option.skuName}-${option.skuId || "sku"}`}
className={cn(
"overflow-hidden rounded-[24px] border transition-all",
isDark
? "border-white/12 bg-white/[0.02] hover:border-white/22"
: "border-border bg-background"
)}
>
<div className="flex items-center gap-3 p-3">
<div
className={cn(
"h-16 w-16 overflow-hidden rounded-2xl border",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-muted/40"
)}
>
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/30">
<ImageIcon className="h-5 w-5" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className={cn("truncate text-sm font-medium", isDark ? "text-white/92" : "")}>
{option.skuName}
</div>
<div className={cn("mt-1 text-[11px]", isDark ? "text-white/62" : "text-muted-foreground")}>
{option.hasWhiteBackground
? `已有 ${option.whiteBackgroundCount} 张白底图`
: "暂无白底图"}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!option.hasWhiteBackground}
onClick={() => onSkuOptionSelect?.(option.skuName)}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white/88 hover:bg-white/[0.10] hover:text-white disabled:text-white/35"
)}
>
选择SKU
</Button>
</div>
</div>
))}
</div>
) : null}
{result.images?.length ? (
<div className="space-y-3">
{isAutoSelectedResult || canTogglePreviewMode ? (
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-[20px] border px-3 py-2.5",
isDark
? "border-emerald-500/18 bg-emerald-500/[0.06] text-emerald-100"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
)}
>
<div className="text-xs leading-5">
{previewDisplayMode === "selected"
? `当前优先展示已选中的 ${selectedCount} 张图片,便于你快速确认结果。`
: `当前展示全部 ${totalImageCount} 张候选图片,可继续手动微调选图。`}
</div>
{canTogglePreviewMode ? (
<div className="text-[11px] text-emerald-100/72">
在上方切换“已选 / 全部”即可,无需再单独收起预览。
</div>
) : null}
</div>
) : null}
{visibleImages.length > 0 ? (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{visibleImages.map((image) => {
const selected = selectedSet.has(image.id);
const isPlaceholder = !String(image.imageUrl || "").trim();
return (
<button
key={image.id}
type="button"
onClick={() => {
if (isPlaceholder) return;
handleToggleImage(image.id);
}}
disabled={isPlaceholder}
className={cn(
"group overflow-hidden rounded-[24px] border bg-background text-left transition-all",
isPlaceholder && "cursor-wait",
selected
? isDark
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
{isPlaceholder ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]">
<Loader2 className="h-6 w-6 animate-spin text-white/55" />
<div className="h-2 w-20 rounded-full bg-white/10" />
<div className="h-2 w-14 rounded-full bg-white/10" />
</div>
) : (
<img
src={image.imageUrl}
alt={image.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
{isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"}
</span>
{!isPlaceholder ? (
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
<Check className="h-3.5 w-3.5" />
</span>
) : null}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
{isPlaceholder ? "白底图加载中..." : image.label}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/88" : "")}>
{isPlaceholder ? "正在获取 SKU 图片..." : image.fileName}
</div>
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
{isPlaceholder
? "已先展示预览卡位,图片会逐步加载出来。"
: `系统会先按权重自动选图,你也可以点击${selected ? "取消" : "调整"}。`}
</div>
</div>
</button>
);
})}
</div>
) : (
<div
className={cn(
"flex min-h-[132px] flex-col items-center justify-center rounded-[24px] border border-dashed px-4 text-center",
isDark
? "border-white/12 bg-white/[0.03] text-white/55"
: "border-border bg-background/60 text-muted-foreground"
)}
>
<div className="text-sm font-medium">
当前没有可展示的已选图片
</div>
<div className="mt-2 text-xs">
切换到“全部 {totalImageCount}”可查看全部候选图片并重新调整。
</div>
</div>
)}
</div>
) : null}
</div>
)
) : (
<div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-[24px] border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-white/[0.03] text-white/56"
: "bg-background/60 text-muted-foreground"
)}
>
{disabled ? (
<>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
先选择生图模式,再搜索并选择素材图。
</>
) : isLoading ? (
<>
<Loader2 className="mb-2 h-5 w-5 animate-spin" />
正在搜索并导入图片,请稍候...
</>
) : (
<>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
{value.trim() ? "输入后点击搜索,从素材库拉取白底图。" : "先输入关键词,再搜索素材图。"}
</>
)}
</div>
)}
{(result?.images?.length || result?.skuOptions?.length) ? (
<div
className={cn(
"flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs",
isDark ? "bg-white/[0.04] text-white/60" : "bg-background text-muted-foreground"
)}
>
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{result?.skuOptions?.length
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
: "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"}
</div>
) : null}
</section>
);
}