"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SyntheticEvent, } 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 { cn } from "@/lib/utils"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MIN_SLICE_HEIGHT = 120; type StitchSourceSegment = { card: AgentDetailReplacementReferenceItem; top: number; height: number; sourceWidth?: number; sourceHeight?: number; }; type MergedSnapshot = { width: number; height: number; previewUrl: string; defaultCutLines: number[]; sourceSegments: StitchSourceSegment[]; }; type DetailStitchPrepareResponse = { success?: boolean; data?: { previewUrl?: string; width?: number; height?: number; defaultCutLines?: number[]; sourceSegments?: StitchSourceSegment[]; failed?: Array<{ id?: string; label?: string; error?: string }>; }; error?: string; }; type DetailStitchApplyResponse = { success?: boolean; data?: { cards?: AgentDetailReplacementReferenceItem[]; }; error?: string; }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function buildStitchPreviewImageUrl(url: string, width: number) { const normalizedUrl = String(url || "").trim(); if (!normalizedUrl) return ""; return buildImageProxyUrl(normalizedUrl, { width: Math.min(Math.max(Math.round(width || 720), 96), 1200), quality: 84, format: "jpeg", delivery: "proxy", }); } 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 previewContainerRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [cutLines, setCutLines] = useState([]); const [draggingLineIndex, setDraggingLineIndex] = useState(null); const [isAddingCutLine, setIsAddingCutLine] = useState(false); const [previewCutLineY, setPreviewCutLineY] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isApplying, setIsApplying] = useState(false); const [applyProgressText, setApplyProgressText] = useState(""); const [loadingProgressText, setLoadingProgressText] = useState(""); const [errorText, setErrorText] = useState(null); const [warningText, setWarningText] = useState(null); const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState([]); const validCards = useMemo( () => cards.filter( (card): card is AgentDetailReplacementReferenceItem & { imageUrl: string } => Boolean(String(card.imageUrl || "").trim()) ), [cards] ); const validCardLoadKey = useMemo( () => validCards .map((card) => `${card.id}::${String(card.imageUrl || "").trim()}`) .join("|"), [validCards] ); const setupCardsRef = useRef(validCards); const setupCardsKeyRef = useRef(validCardLoadKey); if (setupCardsKeyRef.current !== validCardLoadKey) { setupCardsKeyRef.current = validCardLoadKey; setupCardsRef.current = validCards; } const setupCards = setupCardsRef.current; 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 passthroughSegmentIndexSet = useMemo( () => new Set(passthroughSegmentIndexes), [passthroughSegmentIndexes] ); const mergedPreviewDisplayUrl = useMemo( () => snapshot ? buildStitchPreviewImageUrl(snapshot.previewUrl, snapshot.width) : "", [snapshot] ); const useVirtualPreview = previewImageLoadFailed || !mergedPreviewDisplayUrl; useEffect(() => { if (!open) { setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); setLoadingProgressText(""); setWarningText(null); setPreviewImageLoadFailed(false); setPassthroughSegmentIndexes([]); return; } if (setupCards.length === 0) { setSnapshot(null); setCutLines([]); setErrorText(`当前没有可拼合的${sourceLabel}`); return; } let cancelled = false; const setup = async () => { try { setIsLoading(true); setErrorText(null); setWarningText(null); setPreviewImageLoadFailed(false); setLoadingProgressText(`正在读取 ${setupCards.length} 张参考图尺寸`); const response = await fetch("/api/agent-workflow/detail-stitch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "prepare", references: setupCards.map((card, index) => ({ ...card, sortOrder: card.sortOrder ?? index, })), }), }); const result = (await response.json().catch(() => ({}))) as DetailStitchPrepareResponse; if (cancelled) return; if (!response.ok || !result.success || !result.data) { throw new Error(result.error || "拼合参考图失败"); } const previewUrl = String(result.data.previewUrl || "").trim(); const width = Math.round(Number(result.data.width)); const height = Math.round(Number(result.data.height)); const sourceSegments = Array.isArray(result.data.sourceSegments) ? result.data.sourceSegments.filter( (segment): segment is StitchSourceSegment => Boolean(segment?.card?.imageUrl) && Number.isFinite(Number(segment.top)) && Number.isFinite(Number(segment.height)) ) : []; const defaultCutLines = Array.isArray(result.data.defaultCutLines) ? result.data.defaultCutLines .map((value) => Math.round(Number(value))) .filter((value) => Number.isFinite(value) && value > 0 && value < height) : []; if (!width || !height || sourceSegments.length === 0) { throw new Error("拼合结果无效,请稍后重试"); } setSnapshot({ width, height, previewUrl, defaultCutLines, sourceSegments, }); setPreviewImageLoadFailed(!previewUrl); setCutLines(defaultCutLines); setPassthroughSegmentIndexes([]); setWarningText( (result.data.failed?.length || 0) > 0 ? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。` : null ); } 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); setLoadingProgressText(""); } } }; void setup(); return () => { cancelled = true; }; }, [open, setupCards, sourceLabel]); useEffect(() => { const validIndexes = new Set(sliceSegments.map((segment) => segment.index)); setPassthroughSegmentIndexes((current) => current.filter((index) => validIndexes.has(index)) ); }, [sliceSegments]); const resolveInsertCutLineY = useCallback( (rawY: number) => { if (!snapshot) return null; const sortedLines = [...normalizedCutLines].sort((left, right) => left - right); const insertIndex = sortedLines.findIndex((value) => value > rawY); const previousBoundary = insertIndex <= 0 ? 0 : sortedLines[(insertIndex === -1 ? sortedLines.length : insertIndex) - 1]; const nextBoundary = insertIndex === -1 ? snapshot.height : sortedLines[insertIndex]; const minY = previousBoundary + MIN_SLICE_HEIGHT; const maxY = nextBoundary - MIN_SLICE_HEIGHT; if (maxY < minY) { return null; } return clamp(rawY, minY, maxY); }, [normalizedCutLines, snapshot] ); const resolveCanvasYFromClient = useCallback( (clientY: number) => { const container = previewContainerRef.current; if (!container || !snapshot) return null; const rect = container.getBoundingClientRect(); if (rect.height <= 0) return null; const rawY = ((clientY - rect.top) / rect.height) * snapshot.height; return clamp(rawY, 0, snapshot.height); }, [snapshot] ); 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]; const hasSpace = points.some( (value, index) => index < points.length - 1 && points[index + 1] - value >= MIN_SLICE_HEIGHT * 2 ); if (!hasSpace) { toast.error("当前没有足够空间继续添加切线"); return; } setIsAddingCutLine((current) => !current); setPreviewCutLineY(null); }, [normalizedCutLines, snapshot]); const handleResetCutLines = useCallback(() => { if (!snapshot) return; setCutLines(snapshot.defaultCutLines); }, [snapshot]); const handleClearCutLines = useCallback(() => { setCutLines([]); setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); }, []); const handleRemoveCutLine = useCallback((index: number) => { setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index)); }, []); const handlePreviewPointerMove = useCallback( (event: ReactPointerEvent) => { if (!isAddingCutLine) return; const rawY = resolveCanvasYFromClient(event.clientY); if (rawY === null) { setPreviewCutLineY(null); return; } setPreviewCutLineY(resolveInsertCutLineY(rawY)); }, [isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY] ); const handlePreviewPointerLeave = useCallback(() => { if (!isAddingCutLine) return; setPreviewCutLineY(null); }, [isAddingCutLine]); const handlePreviewClick = useCallback( (event: ReactPointerEvent) => { if (!isAddingCutLine) return; const rawY = resolveCanvasYFromClient(event.clientY); const nextY = rawY === null ? null : resolveInsertCutLineY(rawY); if (nextY === null) { toast.error("当前位置无法添加切线,请换一个位置"); return; } setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right)); setIsAddingCutLine(false); setPreviewCutLineY(null); }, [isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY] ); const handleLinePointerDown = useCallback( (index: number) => (event: ReactPointerEvent) => { event.preventDefault(); setDraggingLineIndex(index); }, [] ); const handleMergedPreviewImageError = useCallback( (event: SyntheticEvent) => { const image = event.currentTarget; const fallbackUrl = String(snapshot?.previewUrl || "").trim(); if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") { image.dataset.originalFallbackApplied = "1"; image.src = fallbackUrl; return; } setPreviewImageLoadFailed(true); setWarningText((current) => current || "拼合预览图加载失败,已切换为分段预览。"); }, [snapshot?.previewUrl] ); const handleSegmentPreviewImageError = useCallback( (originalUrl: string) => (event: SyntheticEvent) => { const image = event.currentTarget; const fallbackUrl = String(originalUrl || "").trim(); if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") { image.dataset.originalFallbackApplied = "1"; image.src = fallbackUrl; return; } image.style.opacity = "0"; }, [] ); const handleApply = useCallback(async () => { if (!snapshot) { toast.error("当前还没有可用的拼合图"); return; } if (sliceSegments.length === 0) { toast.error("当前没有可切割的参考图"); return; } setIsApplying(true); setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`); try { const response = await fetch("/api/agent-workflow/detail-stitch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "apply", previewUrl: snapshot.previewUrl, width: snapshot.width, height: snapshot.height, cutLines: normalizedCutLines, sourceSegments: snapshot.sourceSegments, passthroughSegmentIndexes, }), }); const result = (await response.json().catch(() => ({}))) as DetailStitchApplyResponse; if (!response.ok || !result.success || !result.data?.cards?.length) { throw new Error(result.error || "切割参考图失败"); } const orderedCards = result.data.cards.map((card, index) => ({ ...card, sortOrder: index, passthrough: passthroughSegmentIndexSet.has(index), })); if (orderedCards.length !== sliceSegments.length) { throw new Error("部分切片处理失败,请稍后重试"); } setApplyProgressText( normalizedCutLines.length === snapshot.defaultCutLines.length ? "已复用原图边界" : `已生成 ${orderedCards.length} 张切片` ); onApply(orderedCards); onOpenChange(false); toast.success(`已生成 ${orderedCards.length} 张切片参考图`); } catch (error) { console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error); toast.error(error instanceof Error ? error.message : "切割参考图失败"); } finally { setIsApplying(false); setApplyProgressText(""); } }, [ normalizedCutLines, onApply, onOpenChange, passthroughSegmentIndexes, passthroughSegmentIndexSet, sliceSegments.length, snapshot, ]); return ( { event.preventDefault(); }} onInteractOutside={(event) => { event.preventDefault(); }} onPointerDownOutside={(event) => { event.preventDefault(); }} overlayClassName="bg-black/85 backdrop-blur-sm" >
拼合切割{sourceLabel} 先把当前参考图按顺序纵向拼成一张长图,再通过横向切线重新切成多屏,继续用于详情替换逐屏生成。
原始 {validCards.length} 张
当前将切成 {sliceSegments.length || 0} 张
{passthroughSegmentIndexes.length > 0 ? (
直出 {passthroughSegmentIndexes.length} 张
) : null}
默认切线已按原始详情页边界生成,可直接拖动横线微调。 {isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
{isLoading ? (
{loadingProgressText || "正在拼合参考图..."}
会按当前参考图顺序生成虚拟长图,不再上传超长拼合图
) : errorText ? (
{errorText}
请先确认当前已经成功加载参考详情图,再进入拼合切割。
) : snapshot ? (
{warningText ? (
{warningText}
) : null}
{useVirtualPreview ? (
{snapshot.sourceSegments.map((segment, index) => { const segmentImageUrl = String(segment.card.imageUrl || "").trim(); return (
参考图 {index + 1} 加载失败
{segmentImageUrl ? ( {`拼合分段预览 2 ? "lazy" : "eager"} onError={handleSegmentPreviewImageError(segmentImageUrl)} /> ) : null}
); })}
) : ( 拼合长图预览 )}
{isAddingCutLine && previewCutLineY !== null ? (
点击落线
) : null} {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
{passthroughSegmentIndexSet.has(segment.index) ? "当前切片不会参与生图,应用后会直接进入结果区。" : "当前切片会继续参与详情替换生成。"}
)) ) : (
当前还没有可切割的长图。
)}
); }