537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|