ai-chatbot/components/canvas/CanvasAddImageDialog.tsx

463 lines
16 KiB
TypeScript
Raw Permalink 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 { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type {
AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialSkuOption,
} from "@/types/agent";
export type CanvasAddImageDialogImage = AgentWorkflowMaterialImage;
interface CanvasAddImageDialogProps {
materialSearchEnabled: boolean;
onAddImages: (images: CanvasAddImageDialogImage[]) => Promise<void> | void;
onCreateBlankImage: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
}
function getMaterialSearchError(payload: any, fallback = "搜索失败") {
const error = payload?.error;
if (typeof error === "string") return error;
if (error?.message) return String(error.message);
return fallback;
}
function normalizeImages(result?: AgentWorkflowMaterialSearchResult | null) {
return Array.isArray(result?.images)
? result.images.filter((image) => String(image.imageUrl || "").trim())
: [];
}
function MaterialImageCard({
image,
selected,
onToggle,
}: {
image: AgentWorkflowMaterialImage;
selected: boolean;
onToggle: (id: string) => void;
}) {
return (
<button
className={cn(
"group overflow-hidden rounded-xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.25)]"
: "border-border hover:border-primary/45"
)}
onClick={() => onToggle(image.id)}
type="button"
>
<div className="relative aspect-square bg-muted/30">
<AgentPreviewImage
alt={image.label || image.fileName || "白底图"}
className="h-full w-full object-contain"
delivery="proxy"
previewQuality={76}
previewWidth={520}
src={image.previewUrl || image.imageUrl}
/>
<div
className={cn(
"absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full border text-xs shadow-sm transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-white/70 bg-black/35 text-white opacity-80 group-hover:opacity-100"
)}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</div>
</div>
<div className="space-y-1 px-3 py-2.5">
<div className="truncate text-sm font-medium">
{image.label || image.fileName || "白底图"}
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{image.group === "fixed" ? "固定角度" : "补充图"}</span>
<span className="truncate">{image.fileName || image.key}</span>
</div>
</div>
</button>
);
}
function SkuOptionCard({
option,
onSelect,
}: {
option: AgentWorkflowMaterialSkuOption;
onSelect: (skuName: string) => void;
}) {
return (
<button
className={cn(
"flex items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors",
option.hasWhiteBackground
? "hover:border-primary/55 hover:bg-muted/30"
: "cursor-not-allowed opacity-55"
)}
disabled={!option.hasWhiteBackground}
onClick={() => onSelect(option.skuName)}
type="button"
>
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-muted/40">
{option.previewImageUrl ? (
<AgentPreviewImage
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
previewQuality={72}
previewWidth={180}
src={option.previewImageUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-4 w-4" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{option.skuName}</div>
<div className="mt-1 text-xs text-muted-foreground">
{option.hasWhiteBackground
? `${option.whiteBackgroundCount} 张白底图`
: "暂无白底图"}
</div>
</div>
</button>
);
}
export function CanvasAddImageDialog({
materialSearchEnabled,
onAddImages,
onCreateBlankImage,
onOpenChange,
open,
}: CanvasAddImageDialogProps) {
const [query, setQuery] = useState("");
const [queryMode, setQueryMode] = useState<AgentWorkflowMaterialQueryType>("spu");
const [result, setResult] = useState<AgentWorkflowMaterialSearchResult | null>(null);
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [errorText, setErrorText] = useState<string | null>(null);
const images = useMemo(() => normalizeImages(result), [result]);
const skuOptions = Array.isArray(result?.skuOptions) ? result.skuOptions : [];
const selectedImages = useMemo(
() => images.filter((image) => selectedImageIds.includes(image.id)),
[images, selectedImageIds]
);
const resetDialogState = useCallback(() => {
setQuery("");
setQueryMode("spu");
setResult(null);
setSelectedImageIds([]);
setErrorText(null);
}, []);
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetDialogState();
}
onOpenChange(nextOpen);
},
[onOpenChange, resetDialogState]
);
const closeDialog = useCallback(() => {
resetDialogState();
onOpenChange(false);
}, [onOpenChange, resetDialogState]);
const handleSearch = async (nextQuery?: string, nextMode?: AgentWorkflowMaterialQueryType) => {
if (!materialSearchEnabled) return;
const keyword = String(nextQuery ?? query).trim();
const mode = nextMode || queryMode;
if (!keyword) {
setErrorText("请输入 SPU 或 SKU");
return;
}
try {
setIsSearching(true);
setErrorText(null);
const searchParams = new URLSearchParams({
keyword,
source: "primary",
queryType: mode,
appearanceRecognitionEnabled: "false",
});
const response = await fetch(
`/api/agent-workflow/material-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true || !payload?.data) {
throw new Error(getMaterialSearchError(payload));
}
setResult(payload.data);
setSelectedImageIds(
Array.isArray(payload.data?.recommendedImageIds)
? payload.data.recommendedImageIds.filter((id: string) =>
payload.data.images?.some((image: AgentWorkflowMaterialImage) => image.id === id)
)
: []
);
} catch (error) {
setResult(null);
setSelectedImageIds([]);
setErrorText(error instanceof Error ? error.message : "搜索失败");
} finally {
setIsSearching(false);
}
};
const handleSelectSku = (skuName: string) => {
setQuery(skuName);
setQueryMode("sku");
void handleSearch(skuName, "sku");
};
const handleToggleImage = (imageId: string) => {
setSelectedImageIds((current) =>
current.includes(imageId)
? current.filter((id) => id !== imageId)
: [...current, imageId]
);
};
const handleAddSelected = async () => {
if (!selectedImages.length) return;
try {
setIsAdding(true);
await onAddImages(selectedImages);
closeDialog();
} finally {
setIsAdding(false);
}
};
return (
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 pt-5 pb-4">
<DialogTitle className="flex items-center gap-2">
<ImagePlus className="h-5 w-5" />
添加图片
</DialogTitle>
<DialogDescription>
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
</DialogDescription>
</DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-6 py-5">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Button
className="h-11 justify-start gap-2 rounded-xl"
onClick={() => {
onCreateBlankImage();
closeDialog();
}}
type="button"
variant="secondary"
>
<ImageIcon className="h-4 w-4" />
空白图片节点
</Button>
{materialSearchEnabled ? (
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row">
<Select
onValueChange={(value) =>
setQueryMode(value === "sku" ? "sku" : "spu")
}
value={queryMode}
>
<SelectTrigger className="h-11 w-full rounded-xl sm:w-[118px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="spu">SPU</SelectItem>
<SelectItem value="sku">SKU</SelectItem>
</SelectContent>
</Select>
<div className="relative min-w-0 flex-1">
<Input
className="h-11 rounded-xl pr-10"
onChange={(event) => {
setQuery(event.target.value);
setErrorText(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSearch();
}
}}
placeholder={queryMode === "spu" ? "输入 SPU 查询 SKU" : "输入 SKU 查询白底图"}
value={query}
/>
{query ? (
<button
className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground hover:text-foreground"
onClick={() => {
setQuery("");
setResult(null);
setSelectedImageIds([]);
}}
type="button"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button
className="h-11 gap-2 rounded-xl"
disabled={isSearching}
onClick={() => void handleSearch()}
type="button"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
搜索
</Button>
</div>
) : null}
</div>
{!materialSearchEnabled ? (
<div className="mt-3 rounded-xl border border-dashed bg-background/70 px-3 py-2 text-muted-foreground text-xs">
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
</div>
) : null}
</div>
{errorText ? (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive text-sm">
{errorText}
</div>
) : null}
{skuOptions.length > 0 ? (
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">选择 SKU</div>
<div className="text-muted-foreground text-xs">
命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{skuOptions.map((option) => (
<SkuOptionCard
key={`${option.skuName}-${option.skuId || "sku"}`}
onSelect={handleSelectSku}
option={option}
/>
))}
</div>
</div>
) : null}
{images.length > 0 ? (
<div className="mt-5 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">
白底图素材
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
</div>
<div className="text-muted-foreground text-xs">
共 {images.length} 张,已选 {selectedImages.length} 张。
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => setSelectedImageIds(images.map((image) => image.id))}
size="sm"
type="button"
variant="outline"
>
全选
</Button>
<Button
disabled={!selectedImageIds.length}
onClick={() => setSelectedImageIds([])}
size="sm"
type="button"
variant="ghost"
>
清空
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-4">
{images.map((image) => (
<MaterialImageCard
image={image}
key={image.id}
onToggle={handleToggleImage}
selected={selectedImageIds.includes(image.id)}
/>
))}
</div>
</div>
) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? (
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-2xl border border-dashed text-muted-foreground text-sm">
当前没有可添加的白底图。
</div>
) : null}
</div>
<DialogFooter className="border-t px-6 py-4">
<Button
onClick={closeDialog}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding}
onClick={() => void handleAddSelected()}
type="button"
>
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
添加所选{selectedImages.length ? ` (${selectedImages.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}