464 lines
18 KiB
TypeScript
464 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo } from "react";
|
||
import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
|
||
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[];
|
||
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,
|
||
onValueChange,
|
||
onQueryModeChange,
|
||
onSearch,
|
||
onSkuOptionSelect,
|
||
onSelectedImageIdsChange,
|
||
theme = "light",
|
||
}: AgentWorkflowMaterialPickerProps) {
|
||
const isDark = theme === "dark";
|
||
const selectedSet = useMemo(
|
||
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
|
||
[selectedImageIds]
|
||
);
|
||
const selectedCount = result?.images
|
||
? result.images.filter((item) => selectedSet.has(item.id)).length
|
||
: 0;
|
||
const hasSelectableImages = Boolean(
|
||
result?.images?.some((item) => Boolean(String(item.imageUrl || "").trim()))
|
||
);
|
||
|
||
const handleToggleImage = (imageId: string) => {
|
||
const next = new Set(selectedImageIds);
|
||
if (next.has(imageId)) {
|
||
next.delete(imageId);
|
||
} else {
|
||
next.add(imageId);
|
||
}
|
||
onSelectedImageIdsChange(Array.from(next));
|
||
};
|
||
|
||
const handleSelectAll = () => {
|
||
onSelectedImageIdsChange(
|
||
result?.images
|
||
?.filter((item) => Boolean(String(item.imageUrl || "").trim()))
|
||
.map((item) => item.id) || []
|
||
);
|
||
};
|
||
|
||
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}
|
||
{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 ? (
|
||
<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>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<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={() => 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>
|
||
|
||
{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="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||
{result.images.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>
|
||
) : 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>
|
||
);
|
||
}
|