feat: add detail reference stitch workflow
This commit is contained in:
parent
01cce4ac55
commit
ccfaef9df9
630
components/agents/AgentDetailReferenceStitchDialog.tsx
Normal file
630
components/agents/AgentDetailReferenceStitchDialog.tsx
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
"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<HTMLImageElement>((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<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 [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
const [applyProgressText, setApplyProgressText] = useState("");
|
||||||
|
const [errorText, setErrorText] = useState<string | null>(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<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="h-[92vh] max-w-[1440px] overflow-hidden border-white/10 bg-[#060809] p-0 text-white"
|
||||||
|
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>
|
||||||
|
</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" />
|
||||||
|
添加切线
|
||||||
|
</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>
|
||||||
|
<div className="text-xs text-white/42">
|
||||||
|
默认切线已按原始详情页边界生成,可直接拖动横线微调。
|
||||||
|
</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">正在拼合参考图...</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">
|
||||||
|
<div
|
||||||
|
ref={previewContainerRef}
|
||||||
|
className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
className="block h-auto w-full select-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{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 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>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ import {
|
|||||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||||
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||||||
|
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
@ -551,6 +552,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastQuery: null,
|
lastQuery: null,
|
||||||
});
|
});
|
||||||
|
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
|
||||||
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
|
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
|
||||||
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
|
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@ -1756,6 +1758,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
references: [],
|
references: [],
|
||||||
}));
|
}));
|
||||||
}, [updateDetailReplacementState]);
|
}, [updateDetailReplacementState]);
|
||||||
|
const handleApplyStitchedDetailReferenceCards = useCallback(
|
||||||
|
(nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
||||||
|
updateDetailReplacementState((current) => ({
|
||||||
|
...current,
|
||||||
|
references: nextCards,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateDetailReplacementState]
|
||||||
|
);
|
||||||
const handleSkuReplacementSearch = useCallback(async () => {
|
const handleSkuReplacementSearch = useCallback(async () => {
|
||||||
const normalized = skuReplacementQueryInput.trim().toUpperCase();
|
const normalized = skuReplacementQueryInput.trim().toUpperCase();
|
||||||
if (!workflowStoreName) {
|
if (!workflowStoreName) {
|
||||||
@ -2381,6 +2392,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
`查询${detailReferenceSourceLabel}`
|
`查询${detailReferenceSourceLabel}`
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{detailReplacementReferenceCards.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDetailReferenceStitchDialogOpen(true)}
|
||||||
|
className="h-12 rounded-[22px] border-emerald-400/20 bg-emerald-500/[0.08] px-5 text-sm text-emerald-100 hover:bg-emerald-500/[0.14] hover:text-emerald-50"
|
||||||
|
>
|
||||||
|
拼合切割参考图
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{detailReplacementReferenceCards.length > 0 ? (
|
{detailReplacementReferenceCards.length > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -2520,6 +2541,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</motion.section>
|
</motion.section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isDetailReplacementPreset ? (
|
||||||
|
<AgentDetailReferenceStitchDialog
|
||||||
|
open={detailReferenceStitchDialogOpen}
|
||||||
|
onOpenChange={setDetailReferenceStitchDialogOpen}
|
||||||
|
cards={detailReplacementReferenceCards}
|
||||||
|
sourceLabel={`${detailReferenceSourceLabel}参考图`}
|
||||||
|
onApply={handleApplyStitchedDetailReferenceCards}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<motion.section
|
<motion.section
|
||||||
id="agent-anchor-results"
|
id="agent-anchor-results"
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -2682,8 +2713,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
|
仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
|
<div className="flex items-center gap-2">
|
||||||
{detailReplacementReferenceCards.length} 张
|
{detailReplacementReferenceCards.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetailReferenceStitchDialogOpen(true)}
|
||||||
|
className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100 transition-colors hover:bg-emerald-500/[0.14]"
|
||||||
|
>
|
||||||
|
拼合切割
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
|
||||||
|
{detailReplacementReferenceCards.length} 张
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -611,30 +611,6 @@ export function AgentWorkflowPanelDarkV2({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center gap-2 text-lg font-semibold text-white">
|
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500/20 text-xs font-bold text-emerald-400">
|
|
||||||
{activeEmbeddedStepIndex + 2}
|
|
||||||
</span>
|
|
||||||
{activeEmbeddedStepEntry.step.title}
|
|
||||||
{activeEmbeddedStepEntry.depth > 1 ? (
|
|
||||||
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[11px] font-medium text-white/45">
|
|
||||||
{activeEmbeddedStepEntry.depth === 2 ? "二级模块" : "三级模块"}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="mb-2 text-xs text-white/55">
|
|
||||||
{stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key]
|
|
||||||
? `已选:${stepSelectionSummaryMap[activeEmbeddedStepEntry.step.key]}`
|
|
||||||
: "待选择:完成当前卡片后会自动推进到下一步"}
|
|
||||||
</div>
|
|
||||||
{activeEmbeddedStepEntry.step.description ? (
|
|
||||||
<p className="text-sm text-white/40">
|
|
||||||
{activeEmbeddedStepEntry.step.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderOptionLevels(
|
{renderOptionLevels(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user