ai-chatbot/components/agents/AgentDetailReferenceStitchDialog.tsx
Your Name a6083ac409
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: serve aliyun oss previews directly
2026-04-29 21:42:03 +08:00

930 lines
34 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 {
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;
revokeObjectUrl?: (() => void) | null;
};
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 createImageElement(src: string, useCrossOrigin = true) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
if (useCrossOrigin) {
image.crossOrigin = "anonymous";
}
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片加载失败"));
image.src = src;
});
}
async function loadImage(url: string) {
const normalizedSource = String(url || "").trim();
if (!normalizedSource) {
throw new Error("图片地址为空");
}
const candidateUrls = Array.from(
new Set(
[
buildImageProxyUrl(normalizedSource, {
width: MERGED_IMAGE_MAX_WIDTH,
quality: 92,
format: "png",
delivery: "proxy",
}),
buildImageProxyUrl(normalizedSource, {
width: Math.min(MERGED_IMAGE_MAX_WIDTH, 960),
quality: 86,
format: "webp",
delivery: "proxy",
}),
normalizedSource,
].filter((item): item is string => Boolean(item))
)
);
const errorMessages: string[] = [];
for (const candidateUrl of candidateUrls) {
try {
const response = await fetch(candidateUrl, {
cache: "force-cache",
credentials: "same-origin",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
throw new Error("返回内容不是图片");
}
const objectUrl = URL.createObjectURL(blob);
try {
const image = await createImageElement(objectUrl, false);
return {
image,
revokeObjectUrl: () => URL.revokeObjectURL(objectUrl),
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
} catch (error) {
errorMessages.push(
error instanceof Error ? error.message : `无法加载图片资源: ${candidateUrl}`
);
}
}
for (const candidateUrl of candidateUrls) {
try {
const image = await createImageElement(
candidateUrl,
!candidateUrl.startsWith("data:") && !candidateUrl.startsWith("blob:")
);
return {
image,
revokeObjectUrl: null,
};
} catch (error) {
errorMessages.push(
error instanceof Error ? error.message : `无法直接加载图片资源: ${candidateUrl}`
);
}
}
throw new Error(errorMessages[0] || "图片加载失败");
}
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<HTMLCanvasElement | null>(null);
const previewContainerRef = useRef<HTMLDivElement | null>(null);
const mergedCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
const [cutLines, setCutLines] = useState<number[]>([]);
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(null);
const [isAddingCutLine, setIsAddingCutLine] = useState(false);
const [previewCutLineY, setPreviewCutLineY] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyProgressText, setApplyProgressText] = useState("");
const [loadingProgressText, setLoadingProgressText] = useState("");
const [errorText, setErrorText] = useState<string | null>(null);
const [warningText, setWarningText] = useState<string | null>(null);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
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 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);
setIsAddingCutLine(false);
setPreviewCutLineY(null);
setLoadingProgressText("");
setWarningText(null);
setPassthroughSegmentIndexes([]);
return;
}
if (setupCards.length === 0) {
setSnapshot(null);
setCutLines([]);
setErrorText(`当前没有可拼合的${sourceLabel}`);
return;
}
let cancelled = false;
const setup = async () => {
let loadedImages: LoadedReferenceImage[] = [];
let snapshotCommitted = false;
try {
setIsLoading(true);
setErrorText(null);
setWarningText(null);
setLoadingProgressText(`正在加载参考图 0/${setupCards.length}`);
const failedCards: string[] = [];
let processedCount = 0;
for (const [index, card] of setupCards.entries()) {
try {
const loaded = await loadImage(card.imageUrl);
loadedImages.push({
card,
image: loaded.image,
width: loaded.image.naturalWidth,
height: loaded.image.naturalHeight,
scaledHeight: loaded.image.naturalHeight,
revokeObjectUrl: loaded.revokeObjectUrl,
} satisfies LoadedReferenceImage);
} catch (error) {
failedCards.push(card.label || `第 ${index + 1} 张参考图`);
console.error("[AgentDetailReferenceStitchDialog] Failed to load reference image:", {
cardId: card.id,
label: card.label,
imageUrl: card.imageUrl,
error,
});
} finally {
processedCount += 1;
if (!cancelled) {
setLoadingProgressText(`正在加载参考图 ${processedCount}/${setupCards.length}`);
}
}
}
if (cancelled) return;
if (loadedImages.length === 0) {
throw new Error("当前参考图加载失败,请稍后重试");
}
const next = buildMergedSnapshot(loadedImages);
mergedCanvasRef.current = next.canvas;
setSnapshot(next.snapshot);
setCutLines(next.snapshot.defaultCutLines);
snapshotCommitted = true;
setWarningText(
failedCards.length > 0
? `有 ${failedCards.length} 张参考图加载失败,已自动跳过异常图片。`
: 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 (!snapshotCommitted) {
loadedImages.forEach((item) => item.revokeObjectUrl?.());
}
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]);
useEffect(() => {
return () => {
snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.());
};
}, [snapshot]);
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<Blob>((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,
passthrough: passthroughSegmentIndexSet.has(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, passthroughSegmentIndexSet, sliceSegments, snapshot, validCards]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="h-[92vh] max-w-[1440px] overflow-hidden border-white/10 bg-[#060809] p-0 text-white"
onEscapeKeyDown={(event) => {
event.preventDefault();
}}
onInteractOutside={(event) => {
event.preventDefault();
}}
onPointerDownOutside={(event) => {
event.preventDefault();
}}
overlayClassName="bg-black/85 backdrop-blur-sm"
>
<DialogHeader className="border-b border-white/10 px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<DialogTitle className="flex items-center gap-2 text-white">
<Scissors className="h-5 w-5 text-emerald-300" />
拼合切割{sourceLabel}
</DialogTitle>
<DialogDescription className="text-white/45">
先把当前参考图按顺序纵向拼成一张长图,再通过横向切线重新切成多屏,继续用于详情替换逐屏生成。
</DialogDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs text-white/58">
原始 {validCards.length} 张
</div>
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100/85">
当前将切成 {sliceSegments.length || 0} 张
</div>
{passthroughSegmentIndexes.length > 0 ? (
<div className="rounded-full border border-cyan-400/20 bg-cyan-500/[0.08] px-3 py-1 text-xs text-cyan-100/85">
直出 {passthroughSegmentIndexes.length} 张
</div>
) : null}
</div>
</div>
</DialogHeader>
<div className="grid h-full min-h-0 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-h-0 border-b border-white/10 xl:border-b-0 xl:border-r">
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-6 py-4">
<Button
type="button"
variant="outline"
onClick={handleAddCutLine}
disabled={!snapshot || isLoading || isApplying}
className="border-white/12 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
<Plus className="mr-2 h-4 w-4" />
{isAddingCutLine ? "点击长图落线" : "添加切线"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleResetCutLines}
disabled={!snapshot || isLoading || isApplying}
className="border-white/12 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
<RotateCcw className="mr-2 h-4 w-4" />
恢复原页边界
</Button>
<Button
type="button"
variant="outline"
onClick={handleClearCutLines}
disabled={!snapshot || isLoading || isApplying || normalizedCutLines.length === 0}
className="border-red-400/20 bg-red-500/[0.06] text-red-100 hover:bg-red-500/[0.14] hover:text-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
清空切线
</Button>
<div className="text-xs text-white/42">
默认切线已按原始详情页边界生成,可直接拖动横线微调。
{isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
</div>
</div>
<div className="agent-dark-scrollbar h-full overflow-auto px-6 py-5">
{isLoading ? (
<div className="flex min-h-[420px] flex-col items-center justify-center gap-3 rounded-[24px] border border-dashed border-white/12 bg-black/20 text-center">
<Loader2 className="h-6 w-6 animate-spin text-emerald-300" />
<div className="text-sm text-white/75">
{loadingProgressText || "正在拼合参考图..."}
</div>
<div className="text-xs text-white/42">会按当前参考图顺序从上到下拼成一张长图</div>
</div>
) : errorText ? (
<div className="flex min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-red-400/20 bg-red-500/[0.05] px-6 text-center">
<div className="text-sm font-medium text-red-100">{errorText}</div>
<div className="mt-2 text-xs leading-6 text-red-100/60">
请先确认当前已经成功加载参考详情图,再进入拼合切割。
</div>
</div>
) : snapshot ? (
<div className="space-y-4">
{warningText ? (
<div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/88">
{warningText}
</div>
) : null}
<div className="flex justify-center">
<div
ref={previewContainerRef}
className="relative w-full max-w-[650px] overflow-hidden rounded-[24px] border border-white/10 bg-white shadow-[0_18px_48px_rgba(0,0,0,0.22)]"
onPointerMove={handlePreviewPointerMove}
onPointerLeave={handlePreviewPointerLeave}
onClick={handlePreviewClick}
>
<canvas
ref={previewCanvasRef}
className="block h-auto w-full select-none"
/>
<div className="pointer-events-none absolute inset-0">
{isAddingCutLine && previewCutLineY !== null ? (
<div
className="absolute inset-x-0"
style={{ top: `${(previewCutLineY / snapshot.height) * 100}%` }}
>
<div className="absolute inset-x-0 flex -translate-y-1/2 items-center justify-center">
<div className="relative w-full">
<div className="h-[2px] w-full border-t border-dashed border-emerald-400 bg-transparent" />
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
点击落线
</div>
</div>
</div>
</div>
) : null}
{normalizedCutLines.map((lineY, index) => {
const topPercent = (lineY / snapshot.height) * 100;
return (
<div
key={`cut-line-${index}-${lineY}`}
className="absolute inset-x-0"
style={{ top: `${topPercent}%` }}
>
<div
onPointerDown={handleLinePointerDown(index)}
className={cn(
"pointer-events-auto absolute inset-x-0 flex -translate-y-1/2 cursor-row-resize items-center justify-center",
draggingLineIndex === index && "z-20"
)}
style={{ touchAction: "none" }}
>
<div className="relative w-full">
<div className="h-[2px] w-full bg-emerald-400 shadow-[0_0_16px_rgba(52,211,153,0.42)]" />
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
切线 {index + 1}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveCutLine(index);
}}
className="absolute right-3 top-1/2 pointer-events-auto -translate-y-1/2 rounded-full border border-white/12 bg-black/72 px-2 py-1 text-[11px] text-white/78 transition-colors hover:border-white/20 hover:text-white"
>
删除
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-white/48">
向下拖动可浏览整张拼合长图。每条横线代表一次切割边界;右侧可把某些切片标记为“直接应用”,这些模块会跳过继续生成,直接进入结果区。
</div>
</div>
) : null}
</div>
</div>
<div className="flex min-h-0 flex-col">
<div className="border-b border-white/10 px-5 py-4">
<div className="text-sm font-semibold text-white">切割结果预估</div>
<div className="mt-1 text-xs text-white/42">
应用切割后,下方每一段都会回到详情替换参考图区;标记为“不处理直出”的切片会跳过继续生成,未标记的切片再逐屏生成。
</div>
</div>
<div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
{sliceSegments.length > 0 ? (
sliceSegments.map((segment) => (
<div
key={`slice-segment-${segment.index}`}
className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">
切片 {segment.index + 1}
</div>
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
高度 {segment.height}px
</div>
</div>
<div className="mt-2 text-xs leading-6 text-white/42">
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-[11px] text-white/45">
{passthroughSegmentIndexSet.has(segment.index)
? "当前切片不会参与生图,应用后会直接进入结果区。"
: "当前切片会继续参与详情替换生成。"}
</div>
<button
type="button"
onClick={() =>
setPassthroughSegmentIndexes((current) =>
current.includes(segment.index)
? current.filter((item) => item !== segment.index)
: [...current, segment.index].sort((left, right) => left - right)
)
}
className={cn(
"rounded-full border px-3 py-1 text-[11px] transition-colors",
passthroughSegmentIndexSet.has(segment.index)
? "border-cyan-400/35 bg-cyan-500/[0.12] text-cyan-100"
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white"
)}
>
{passthroughSegmentIndexSet.has(segment.index)
? "取消不处理"
: "标记不处理"}
</button>
</div>
</div>
))
) : (
<div className="flex min-h-[220px] items-center justify-center rounded-[20px] border border-dashed border-white/12 bg-black/20 px-5 text-center text-sm text-white/42">
当前还没有可切割的长图。
</div>
)}
</div>
<div className="border-t border-white/10 px-5 py-4">
<Button
type="button"
onClick={handleApply}
disabled={isLoading || isApplying || !snapshot || sliceSegments.length === 0}
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
>
{isApplying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{applyProgressText || "正在应用切割"}
</>
) : (
<>
<Scissors className="mr-2 h-4 w-4" />
应用切割并替换参考图
</>
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}