"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, } from "react"; import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MERGED_IMAGE_MAX_WIDTH = 1600; const MIN_SLICE_HEIGHT = 120; const MERGED_IMAGE_MAX_PIXELS = 42_000_000; type LoadedReferenceImage = { card: AgentDetailReplacementReferenceItem; image: HTMLImageElement; width: number; height: number; scaledHeight: number; }; type MergedSnapshot = { width: number; height: number; defaultCutLines: number[]; loadedImages: LoadedReferenceImage[]; }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function loadImage(url: string) { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = "anonymous"; image.onload = () => resolve(image); image.onerror = () => reject(new Error("图片加载失败")); image.src = buildImageProxyUrl(url, { width: MERGED_IMAGE_MAX_WIDTH, quality: 92, format: "png", }); }); } function buildMergedSnapshot(images: LoadedReferenceImage[]) { const baseWidth = Math.max( 1, Math.min( MERGED_IMAGE_MAX_WIDTH, Math.max(...images.map((item) => item.width), 1) ) ); const baseTotalHeight = images.reduce((total, item) => { return total + Math.max(1, Math.round((baseWidth / Math.max(item.width, 1)) * item.height)); }, 0); const basePixels = baseWidth * baseTotalHeight; const safeScale = basePixels > MERGED_IMAGE_MAX_PIXELS ? Math.sqrt(MERGED_IMAGE_MAX_PIXELS / Math.max(basePixels, 1)) : 1; const targetWidth = Math.max( 1, Math.min( baseWidth, Math.max(baseWidth > 720 ? 720 : baseWidth, Math.round(baseWidth * safeScale)) ) ); let totalHeight = 0; const defaultCutLines: number[] = []; const normalizedImages = images.map((item, index) => { const scaledHeight = Math.max( 1, Math.round((targetWidth / Math.max(item.width, 1)) * item.height) ); totalHeight += scaledHeight; if (index < images.length - 1) { defaultCutLines.push(totalHeight); } return { ...item, scaledHeight, }; }); const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = totalHeight; const context = canvas.getContext("2d"); if (!context) { throw new Error("无法初始化拼合画布"); } context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height); let offsetY = 0; normalizedImages.forEach((item) => { context.drawImage(item.image, 0, offsetY, targetWidth, item.scaledHeight); offsetY += item.scaledHeight; }); return { canvas, snapshot: { width: targetWidth, height: totalHeight, defaultCutLines, loadedImages: normalizedImages, } satisfies MergedSnapshot, }; } interface AgentDetailReferenceStitchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; cards: AgentDetailReplacementReferenceItem[]; sourceLabel?: string; onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void; } export function AgentDetailReferenceStitchDialog({ open, onOpenChange, cards, sourceLabel = "详情参考图", onApply, }: AgentDetailReferenceStitchDialogProps) { const previewCanvasRef = useRef(null); const previewContainerRef = useRef(null); const mergedCanvasRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [cutLines, setCutLines] = useState([]); const [draggingLineIndex, setDraggingLineIndex] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isApplying, setIsApplying] = useState(false); const [applyProgressText, setApplyProgressText] = useState(""); const [errorText, setErrorText] = useState(null); const validCards = useMemo( () => cards.filter( (card): card is AgentDetailReplacementReferenceItem & { imageUrl: string } => Boolean(String(card.imageUrl || "").trim()) ), [cards] ); const normalizedCutLines = useMemo( () => [...cutLines] .filter((value) => Number.isFinite(value)) .sort((left, right) => left - right), [cutLines] ); const sliceSegments = useMemo(() => { if (!snapshot) return []; const points = [0, ...normalizedCutLines, snapshot.height]; return Array.from({ length: Math.max(points.length - 1, 0) }, (_, index) => ({ index, top: points[index], height: Math.max(points[index + 1] - points[index], 1), })); }, [normalizedCutLines, snapshot]); const redrawPreviewCanvas = useCallback(() => { const previewCanvas = previewCanvasRef.current; const mergedCanvas = mergedCanvasRef.current; if (!previewCanvas || !mergedCanvas || !snapshot) { return; } previewCanvas.width = snapshot.width; previewCanvas.height = snapshot.height; const context = previewCanvas.getContext("2d"); if (!context) { return; } context.clearRect(0, 0, snapshot.width, snapshot.height); context.drawImage(mergedCanvas, 0, 0); }, [snapshot]); useEffect(() => { redrawPreviewCanvas(); }, [redrawPreviewCanvas]); useEffect(() => { if (!open) { setDraggingLineIndex(null); return; } if (validCards.length === 0) { setSnapshot(null); setCutLines([]); setErrorText(`当前没有可拼合的${sourceLabel}`); return; } let cancelled = false; const setup = async () => { try { setIsLoading(true); setErrorText(null); const loadedImages = await Promise.all( validCards.map(async (card) => { const image = await loadImage(card.imageUrl); return { card, image, width: image.naturalWidth, height: image.naturalHeight, scaledHeight: image.naturalHeight, } satisfies LoadedReferenceImage; }) ); if (cancelled) return; const next = buildMergedSnapshot(loadedImages); mergedCanvasRef.current = next.canvas; setSnapshot(next.snapshot); setCutLines(next.snapshot.defaultCutLines); } catch (error) { if (cancelled) return; console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); setSnapshot(null); setCutLines([]); setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); } finally { if (!cancelled) { setIsLoading(false); } } }; void setup(); return () => { cancelled = true; }; }, [open, sourceLabel, validCards]); useEffect(() => { if (draggingLineIndex === null || !snapshot) { return; } const handlePointerMove = (event: PointerEvent) => { const container = previewContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); if (rect.height <= 0) return; const rawY = ((event.clientY - rect.top) / rect.height) * snapshot.height; setCutLines((current) => { const next = [...current].sort((left, right) => left - right); const minY = draggingLineIndex === 0 ? MIN_SLICE_HEIGHT : next[draggingLineIndex - 1] + MIN_SLICE_HEIGHT; const maxY = draggingLineIndex === next.length - 1 ? snapshot.height - MIN_SLICE_HEIGHT : next[draggingLineIndex + 1] - MIN_SLICE_HEIGHT; next[draggingLineIndex] = clamp(rawY, minY, maxY); return next; }); }; const handlePointerUp = () => { setDraggingLineIndex(null); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; }, [draggingLineIndex, snapshot]); const handleAddCutLine = useCallback(() => { if (!snapshot) return; const points = [0, ...normalizedCutLines, snapshot.height]; let bestIndex = -1; let bestHeight = 0; for (let index = 0; index < points.length - 1; index += 1) { const currentHeight = points[index + 1] - points[index]; if (currentHeight > bestHeight) { bestHeight = currentHeight; bestIndex = index; } } if (bestIndex < 0 || bestHeight < MIN_SLICE_HEIGHT * 2) { toast.error("当前没有足够空间继续添加切线"); return; } const nextY = Math.round((points[bestIndex] + points[bestIndex + 1]) / 2); setCutLines((current) => [...current, nextY].sort((left, right) => left - right) ); }, [normalizedCutLines, snapshot]); const handleResetCutLines = useCallback(() => { if (!snapshot) return; setCutLines(snapshot.defaultCutLines); }, [snapshot]); const handleRemoveCutLine = useCallback((index: number) => { setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index)); }, []); const handleLinePointerDown = useCallback( (index: number) => (event: ReactPointerEvent) => { event.preventDefault(); setDraggingLineIndex(index); }, [] ); const handleApply = useCallback(async () => { if (!snapshot || !mergedCanvasRef.current) { toast.error("当前还没有可用的拼合图"); return; } if (sliceSegments.length === 0) { toast.error("当前没有可切割的参考图"); return; } setIsApplying(true); setApplyProgressText(`正在上传切片 1/${sliceSegments.length}`); try { const nextCards: AgentDetailReplacementReferenceItem[] = []; const sourceCanvas = mergedCanvasRef.current; const sourceCandidateId = validCards[0]?.candidateId || null; const timestamp = Date.now(); for (const segment of sliceSegments) { setApplyProgressText(`正在上传切片 ${segment.index + 1}/${sliceSegments.length}`); const sliceCanvas = document.createElement("canvas"); sliceCanvas.width = snapshot.width; sliceCanvas.height = segment.height; const context = sliceCanvas.getContext("2d"); if (!context) { throw new Error("切片画布初始化失败"); } context.fillStyle = "#ffffff"; context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); context.drawImage( sourceCanvas, 0, segment.top, snapshot.width, segment.height, 0, 0, snapshot.width, segment.height ); const blob = await new Promise((resolve, reject) => { sliceCanvas.toBlob( (value) => { if (value) { resolve(value); return; } reject(new Error("切片导出失败")); }, "image/jpeg", 0.92 ); }); const formData = new FormData(); formData.append( "file", new File([blob], `detail-reference-slice-${timestamp}-${segment.index + 1}.jpg`, { type: "image/jpeg", }) ); const response = await fetchUploadWithRetry(formData); const result = await response.json().catch(() => ({})); if (!response.ok || !result?.url) { throw new Error(result?.error || `第 ${segment.index + 1} 张切片上传失败`); } nextCards.push({ id: `detail-stitch-slice-${timestamp}-${segment.index + 1}`, label: `拼合切片 ${segment.index + 1}`, imageUrl: String(result.url), candidateId: sourceCandidateId, sortOrder: segment.index, }); } onApply(nextCards); onOpenChange(false); toast.success(`已生成 ${nextCards.length} 张切片参考图`); } catch (error) { console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error); toast.error(error instanceof Error ? error.message : "切割参考图失败"); } finally { setIsApplying(false); setApplyProgressText(""); } }, [onApply, onOpenChange, sliceSegments, snapshot, validCards]); return (
拼合切割{sourceLabel} 先把当前参考图按顺序纵向拼成一张长图,再通过横向切线重新切成多屏,继续用于详情替换逐屏生成。
原始 {validCards.length} 张
当前将切成 {sliceSegments.length || 0} 张
默认切线已按原始详情页边界生成,可直接拖动横线微调。
{isLoading ? (
正在拼合参考图...
会按当前参考图顺序从上到下拼成一张长图
) : errorText ? (
{errorText}
请先确认当前已经成功加载参考详情图,再进入拼合切割。
) : snapshot ? (
{normalizedCutLines.map((lineY, index) => { const topPercent = (lineY / snapshot.height) * 100; return (
切线 {index + 1}
); })}
向下拖动可浏览整张拼合长图。每条横线代表一次切割边界,应用后会替换当前详情参考图列表。
) : null}
切割结果预估
应用切割后,下方每一段都会回到详情替换参考图区,继续逐屏生成。
{sliceSegments.length > 0 ? ( sliceSegments.map((segment) => (
切片 {segment.index + 1}
高度 {segment.height}px
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
)) ) : (
当前还没有可切割的长图。
)}
); }