ai-chatbot/components/canvas/CanvasAddImageDialog.tsx

537 lines
19 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 { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
import { useCallback, useMemo, useRef, 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 { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
import { imageGenerationService } from "@/services/canvas";
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-[var(--ui-radius-control)] 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-0.5 px-2.5 py-2">
<div className="truncate text-[13px] font-medium">
{image.label || image.fileName || "白底图"}
</div>
<div className="flex items-center justify-between gap-2 text-[11px] 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-2.5 rounded-[var(--ui-radius-control)] border bg-background p-2.5 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-12 w-12 overflow-hidden rounded-[6px] 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-[13px] font-medium">{option.skuName}</div>
<div className="mt-0.5 text-[11px] 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 [isUploading, setIsUploading] = useState(false);
const localUploadInputRef = useRef<HTMLInputElement>(null);
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 && (isUploading || isAdding)) return;
if (!nextOpen) {
resetDialogState();
}
onOpenChange(nextOpen);
},
[isAdding, isUploading, 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);
}
};
const handleLocalUpload = async (files: File[]) => {
if (!files.length || isUploading || isAdding) return;
if (files.some((file) => !file.type.startsWith("image/"))) {
setErrorText("请选择图片文件");
return;
}
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
return;
}
setIsUploading(true);
setErrorText(null);
try {
const uploadedImages: CanvasAddImageDialogImage[] = [];
const failures: string[] = [];
for (const [index, file] of files.entries()) {
const upload = await imageGenerationService.uploadImage(file);
if (!upload.success || !upload.data?.url) {
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
continue;
}
uploadedImages.push({
id: upload.data.assetId || crypto.randomUUID(),
key: upload.data.pathname || file.name,
label: file.name.replace(/\.[^.]+$/, "") || file.name,
fileName: file.name,
imageUrl: upload.data.url,
assetId: upload.data.assetId,
mimeType: file.type,
sourceType: "upload",
group: "other",
sortOrder: index,
});
}
if (uploadedImages.length) await onAddImages(uploadedImages);
if (failures.length) setErrorText(failures.join(";"));
else closeDialog();
} catch (error) {
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
} finally {
setIsUploading(false);
}
};
return (
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
<DialogHeader className="border-b px-5 pb-3 pt-4">
<DialogTitle className="flex items-center gap-2">
<ImagePlus className="h-4 w-4" />
添加图片
</DialogTitle>
<DialogDescription>
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
</DialogDescription>
</DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
<div className="flex flex-col gap-2.5 sm:flex-row">
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void handleLocalUpload(files);
}}
ref={localUploadInputRef}
type="file"
/>
<Button
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => localUploadInputRef.current?.click()}
type="button"
variant="secondary"
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? "上传中…" : "本地上传"}
</Button>
<Button
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
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-2.5 sm:flex-row">
<Select
onValueChange={(value) =>
setQueryMode(value === "sku" ? "sku" : "spu")
}
value={queryMode}
>
<SelectTrigger className="h-9 w-full sm:w-[108px]">
<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-9 pr-9"
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-9 gap-2"
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-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
</div>
) : null}
</div>
{errorText ? (
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
{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="text-[13px] font-medium">选择 SKU</div>
<div className="text-[11px] text-muted-foreground">
命中 {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="text-[13px] font-medium">
白底图素材
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
</div>
<div className="text-[11px] text-muted-foreground">
共 {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-[var(--ui-radius-panel)] border border-dashed text-[13px] text-muted-foreground">
当前没有可添加的白底图。
</div>
) : null}
</div>
<DialogFooter className="border-t px-5 py-3">
<Button
disabled={isUploading || isAdding}
onClick={closeDialog}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding || isUploading}
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>
);
}