ai-chatbot/components/agents/AgentWorkflowMaterialPicker.tsx

1173 lines
44 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 { useVirtualizer } from "@tanstack/react-virtual";
import {
memo,
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Check, ImageIcon, Loader2, RefreshCw, Search, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { processInBrowserBatches } from "@/lib/client/yield-to-browser";
import { cn } from "@/lib/utils";
import type {
AgentWorkflowMaterialImage,
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;
onUploadImage?: (
file: File,
options?: {
replaceImageId?: string;
}
) => Promise<void>;
onRemoveUploadedImage?: (imageId: string) => void;
isUploadingNewImage?: boolean;
uploadingImageId?: string | null;
appearanceRecognitionEnabled?: boolean;
theme?: "light" | "dark";
}
const MATERIAL_GRID_VIRTUALIZE_THRESHOLD = 12;
const MATERIAL_GRID_GAP = 12;
const MATERIAL_GRID_MIN_CARD_HEIGHT = 258;
const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED =
process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false";
const MaterialImageCard = memo(function MaterialImageCard({
image,
selected,
isDark,
onToggle,
}: {
image: AgentWorkflowMaterialImage;
selected: boolean;
isDark: boolean;
onToggle: (imageId: string) => void;
}) {
const isPlaceholder = !String(image.imageUrl || "").trim();
return (
<button
type="button"
onClick={() => {
if (isPlaceholder) return;
onToggle(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>
) : (
<AgentPreviewImage
src={image.imageUrl}
alt={image.label}
className="h-full w-full object-cover"
previewWidth={480}
previewQuality={74}
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>
);
});
function MaterialImageGrid({
images,
selectedSet,
isDark,
onToggle,
}: {
images: AgentWorkflowMaterialImage[];
selectedSet: Set<string>;
isDark: boolean;
onToggle: (imageId: string) => void;
}) {
const parentRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const node = parentRef.current;
if (!node || images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD) return;
const updateWidth = () => setContainerWidth(node.clientWidth || 0);
updateWidth();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateWidth, { passive: true });
return () => window.removeEventListener("resize", updateWidth);
}
const observer = new ResizeObserver(updateWidth);
observer.observe(node);
return () => observer.disconnect();
}, [images.length]);
const columnCount = useMemo(() => {
if (containerWidth >= 1280) return 4;
if (containerWidth >= 900) return 3;
return 2;
}, [containerWidth]);
if (
!AGENT_STYLE_OPTIMIZED_RENDER_ENABLED ||
images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD
) {
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{images.map((image) => (
<MaterialImageCard
key={image.id}
image={image}
selected={selectedSet.has(image.id)}
isDark={isDark}
onToggle={onToggle}
/>
))}
</div>
);
}
const rowCount = Math.ceil(images.length / columnCount);
const cardWidth =
containerWidth > 0
? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount)
: 240;
const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight + MATERIAL_GRID_GAP,
overscan: 2,
});
return (
<div
ref={parentRef}
className="agent-dark-scrollbar max-h-[680px] overflow-y-auto pr-1"
data-agent-style-action="scroll-material-images"
>
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const start = virtualRow.index * columnCount;
const rowImages = images.slice(start, start + columnCount);
return (
<div
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 grid w-full gap-3"
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowImages.map((image) => (
<MaterialImageCard
key={image.id}
image={image}
selected={selectedSet.has(image.id)}
isDark={isDark}
onToggle={onToggle}
/>
))}
</div>
);
})}
</div>
</div>
);
}
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,
onUploadImage,
onRemoveUploadedImage,
isUploadingNewImage = false,
uploadingImageId = null,
appearanceRecognitionEnabled = true,
theme = "light",
}: AgentWorkflowMaterialPickerProps) {
const isDark = theme === "dark";
const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId);
const canUploadMaterialImage = Boolean(onUploadImage);
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
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 allImages = result?.images || [];
const uploadedImages = useMemo(
() =>
allImages
.filter((item) => item.sourceType === "upload")
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder),
[allImages]
);
const galleryImages = useMemo(
() => allImages.filter((item) => item.sourceType !== "upload"),
[allImages]
);
const selectedPrimaryImage = useMemo(
() => allImages.find((item) => selectedSet.has(item.id)) || null,
[allImages, selectedSet]
);
const visibleAppearanceSummary = selectedPrimaryImage
? selectedPrimaryImage.appearanceSummary || null
: allImages[0]?.appearanceSummary || result?.goodsInfo?.appearanceSummary || null;
const selectedCount = allImages.filter((item) => selectedSet.has(item.id)).length;
const selectedGalleryCount = galleryImages.filter((item) => selectedSet.has(item.id)).length;
const resolvedSelectionLimit =
typeof selectionLimit === "number" && Number.isFinite(selectionLimit) && selectionLimit > 0
? Math.max(1, Math.round(selectionLimit))
: null;
const hasSelectableImages = Boolean(
allImages.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,
},
...(appearanceRecognitionEnabled
? [
{
label: "外观识别",
value: visibleAppearanceSummary,
fullWidth: true,
},
]
: []),
].filter((item) => Boolean(item.value));
const remainingSelectableCount =
resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0);
const remainingUploadSlotCount =
!canUploadMaterialImage
? 0
: resolvedSelectionLimit === null
? 1
: Math.max(
resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length,
0
);
const totalImageCount = galleryImages.length;
const hasSystemRecommendedSelection =
result?.queryType === "sku" &&
recommendedImageIds.length > 0 &&
selectedGalleryCount === recommendedImageIds.length &&
recommendedImageIds.every((id) => selectedSet.has(id));
const isAutoSelectedResult =
Boolean(hasSystemRecommendedSelection) && !hasManualSelectionInteraction;
const canTogglePreviewMode = totalImageCount > 0;
const visibleImages = useMemo(() => {
if (!galleryImages.length) {
return [];
}
if (previewDisplayMode === "selected" && selectedGalleryCount > 0) {
return galleryImages.filter((image) => selectedSet.has(image.id));
}
return galleryImages;
}, [galleryImages, previewDisplayMode, selectedGalleryCount, selectedSet]);
const previewResetKey = useMemo(
() =>
JSON.stringify({
queryType: result?.queryType || "",
matchedSku: result?.matchedSku || "",
imageIds: galleryImages.map((image) => image.id),
recommendedImageIds,
}),
[galleryImages, recommendedImageIds, result?.matchedSku, result?.queryType]
);
useEffect(() => {
setHasManualSelectionInteraction(false);
}, [previewResetKey]);
useEffect(() => {
if (hasManualSelectionInteraction) {
return;
}
setPreviewDisplayMode("all");
}, [hasManualSelectionInteraction, previewResetKey]);
const hasFetchSuccess = !disabled && !isLoading && !errorText && Boolean(result);
const handleToggleImage = useCallback((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);
}
const nextIds = Array.from(next);
startTransition(() => {
onSelectedImageIdsChange(nextIds);
});
}, [
onSelectedImageIdsChange,
resolvedSelectionLimit,
selectedImageIds,
selectionLimitLabel,
]);
const handleSelectAll = useCallback(async () => {
setHasManualSelectionInteraction(true);
const allIds: string[] = [];
await processInBrowserBatches(
allImages,
(item) => {
if (String(item.imageUrl || "").trim()) {
allIds.push(item.id);
}
},
{ batchSize: 80 }
);
const nextIds =
resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds;
startTransition(() => {
onSelectedImageIdsChange(nextIds);
});
if (resolvedSelectionLimit !== null && allIds.length > resolvedSelectionLimit) {
toast.message(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片,已按上限选取`);
}
}, [allImages, onSelectedImageIdsChange, resolvedSelectionLimit, selectionLimitLabel]);
const handleUploadInputChange = async (
file: File | null,
options?: {
replaceImageId?: string;
}
) => {
if (!file || !onUploadImage) return;
if (!String(file.type || "").startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
try {
await onUploadImage(file, options);
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
}
};
const renderUploadPreview = (params: {
image?: AgentWorkflowMaterialImage | null;
selected?: boolean;
isUploading?: boolean;
}) => {
const { image, selected = false, isUploading = false } = params;
const hasImage = Boolean(image?.imageUrl);
return (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-[22px] border",
hasImage
? isDark
? "border-white/10 bg-white/[0.04]"
: "border-border bg-muted/20"
: isDark
? "border-dashed border-white/12 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_55%),rgba(255,255,255,0.02)]"
: "border-dashed border-primary/25 bg-primary/5"
)}
>
{hasImage ? (
<AgentPreviewImage
src={image?.imageUrl}
alt={image?.label || "上传素材"}
className="h-full w-full object-contain"
previewWidth={480}
previewQuality={78}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-4 text-center">
{isUploading ? (
<Loader2 className={cn("h-7 w-7 animate-spin", isDark ? "text-emerald-200" : "text-primary")} />
) : (
<Upload className={cn("h-7 w-7", isDark ? "text-emerald-200" : "text-primary")} />
)}
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "text-foreground")}>
{isUploading ? "正在自动抠图" : "上传商品图"}
</div>
<div className={cn("text-[11px] leading-5", isDark ? "text-white/55" : "text-muted-foreground")}>
{isUploading ? "系统会先抠图,再把透明底结果加入候选区。" : "支持 PNG / JPG / WEBP,上传后会自动抠图。"}
</div>
</div>
)}
<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">
{hasImage ? "用户上传" : "自动抠图上传"}
</span>
{hasImage ? (
<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"
)}
>
<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">
{hasImage ? image?.fileName || image?.label : isUploading ? "抠图处理中..." : "没有搜索到时也可以直接上传"}
</div>
</div>
);
};
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 || hasUploadInFlight}
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">
<div className="relative flex-1">
<Input
value={value}
onChange={(event) => onValueChange(event.target.value)}
placeholder={placeholder}
disabled={disabled || hasUploadInFlight}
className={cn(
hasFetchSuccess && "pr-11",
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 || hasUploadInFlight) return;
onSearch();
}
}}
/>
{hasFetchSuccess ? (
<span className="pointer-events-none absolute right-3 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full bg-emerald-500/18 text-emerald-300">
<Check className="h-3.5 w-3.5" />
</span>
) : null}
</div>
<Button
type="button"
onClick={onSearch}
disabled={disabled || isLoading || hasUploadInFlight || !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>
{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 && canUploadMaterialImage ? (
<div
className={cn(
"space-y-3 rounded-[24px] border p-3",
isDark ? "border-white/12 bg-white/[0.03]" : "border-border bg-background/70"
)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-1">
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>
直接上传商品图
</div>
<div className={cn("text-xs leading-5", isDark ? "text-white/58" : "text-muted-foreground")}>
搜索不到合适白底图时,可以直接上传原图。这里只统计商品图,模特图会在后续单独处理;系统会自动抠图后再加入当前候选素材。
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{resolvedSelectionLimit !== null ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-muted text-muted-foreground"
)}
>
本步骤需 {resolvedSelectionLimit} 张商品图
</span>
) : null}
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-muted text-muted-foreground"
)}
>
已上传 {uploadedImages.length} 张
</span>
{remainingUploadSlotCount > 0 ? (
<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 === null
? "可继续上传"
: `还可上传 ${remainingUploadSlotCount} 张`}
</span>
) : null}
</div>
</div>
{uploadedImages.length > 0 || remainingUploadSlotCount > 0 ? (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{uploadedImages.map((image) => {
const isSelected = selectedSet.has(image.id);
const isReplacing = uploadingImageId === image.id;
return (
<div
key={`uploaded-material-${image.id}`}
className={cn(
"overflow-hidden rounded-[24px] border transition-all",
isSelected
? 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]"
: "border-border bg-background"
)}
>
<button
type="button"
onClick={() => handleToggleImage(image.id)}
className="block w-full text-left"
>
{renderUploadPreview({
image,
selected: isSelected,
isUploading: isReplacing,
})}
</button>
<div className="grid grid-cols-2 gap-2 px-3 py-3">
<label
className={cn(
"inline-flex cursor-pointer items-center justify-center rounded-full border px-3 py-2 text-xs font-medium transition-colors",
isDark
? "border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white"
: "border-border bg-background text-foreground hover:bg-muted/60",
(isReplacing || isLoading) && "pointer-events-none opacity-50"
)}
>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
disabled={isReplacing || isUploadingNewImage || isLoading}
onChange={(event) => {
const file = event.currentTarget.files?.[0] || null;
event.currentTarget.value = "";
void handleUploadInputChange(file, { replaceImageId: image.id });
}}
/>
{isReplacing ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="mr-1.5 h-3.5 w-3.5" />
)}
替换
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onRemoveUploadedImage?.(image.id)}
disabled={isReplacing || isUploadingNewImage || isLoading}
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"
)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
删除
</Button>
</div>
</div>
);
})}
{Array.from({ length: remainingUploadSlotCount }, (_, index) => (
<label
key={`new-upload-slot-${index + 1}`}
className={cn(
"block cursor-pointer overflow-hidden rounded-[24px] border transition-all",
isDark
? "border-dashed border-emerald-400/25 bg-white/[0.02] hover:border-emerald-300/45"
: "border-dashed border-primary/30 bg-primary/5 hover:border-primary/50"
)}
>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
disabled={isUploadingNewImage || Boolean(uploadingImageId) || isLoading}
onChange={(event) => {
const file = event.currentTarget.files?.[0] || null;
event.currentTarget.value = "";
void handleUploadInputChange(file);
}}
/>
{renderUploadPreview({
isUploading: isUploadingNewImage && index === 0,
})}
</label>
))}
</div>
) : (
<div
className={cn(
"rounded-[20px] border px-3 py-2 text-xs",
isDark ? "border-white/10 bg-black/20 text-white/55" : "border-border bg-muted/20 text-muted-foreground"
)}
>
当前上传槽位已满。若要改用用户上传图片,可先取消一张检索图,或删除上面已上传的图片后再补充。
</div>
)}
</div>
) : null}
{!disabled && result ? (
<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"
)}
>
检索图 {totalImageCount} 张
</span>
{uploadedImages.length > 0 ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/82" : "bg-background"
)}
>
上传图 {uploadedImages.length} 张
</span>
) : null}
<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} 张
{remainingSelectableCount !== null ? ` · 还可选 ${remainingSelectableCount} 张` : ""}
</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={selectedGalleryCount === 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"
)}
>
已选 {selectedGalleryCount}
</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);
startTransition(() => {
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>
</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 ? (
<AgentPreviewImage
src={option.previewImageUrl}
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
previewWidth={230}
previewQuality={72}
/>
) : (
<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}
{totalImageCount > 0 ? (
<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"
? `当前优先展示已选中的 ${selectedGalleryCount} 张检索图,便于你快速确认结果。`
: `当前展示全部 ${totalImageCount} 张候选图片,可继续手动微调选图。`}
</div>
{canTogglePreviewMode ? (
<div className="text-[11px] text-emerald-100/72">
在上方切换“已选 / 全部”即可,无需再单独收起预览。
</div>
) : null}
</div>
) : null}
{visibleImages.length > 0 ? (
<MaterialImageGrid
images={visibleImages}
selectedSet={selectedSet}
isDark={isDark}
onToggle={handleToggleImage}
/>
) : (
<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,再继续拉取白底图。"
: totalImageCount > 0
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"
: "上传后的抠图结果会直接并入当前候选素材,并自动带入后续生图槽位。"}
</div>
) : null}
</section>
);
}