ai-chatbot/components/agents/AgentWorkflowMaterialPicker.tsx
Your Name ae5f1309b1
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve sku material loading feedback
2026-03-19 13:30:01 +08:00

458 lines
17 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 { 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"
)}
>
<div className="space-y-1">
<div className={cn("text-sm font-medium", isDark ? "text-white/92" : "")}>{title}</div>
<div className={cn("text-xs", isDark ? "text-white/62" : "text-muted-foreground")}>
{description}
</div>
</div>
<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>
);
}