feat: support target detail uploads and improve scale adjust
This commit is contained in:
parent
25af7e5526
commit
af63ebba9e
@ -451,13 +451,13 @@ function buildScaleAdjustPrompt(
|
|||||||
const top = Math.round(region.rect.y * 1000) / 10;
|
const top = Math.round(region.rect.y * 1000) / 10;
|
||||||
const width = Math.round(region.rect.width * 1000) / 10;
|
const width = Math.round(region.rect.width * 1000) / 10;
|
||||||
const height = Math.round(region.rect.height * 1000) / 10;
|
const height = Math.round(region.rect.height * 1000) / 10;
|
||||||
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):识别该框选范围内的完整主体物体,以主体视觉中心为锚点执行整体等比${actionLabel},目标尺寸为原物体的 ${Math.round(scaleFactor * 100)}%。`;
|
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸为原主体的 ${Math.round(scaleFactor * 100)}%。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||||||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
|
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
|
||||||
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
|
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
|
||||||
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||||||
"",
|
"",
|
||||||
@ -465,11 +465,13 @@ function buildScaleAdjustPrompt(
|
|||||||
regionLines,
|
regionLines,
|
||||||
"",
|
"",
|
||||||
"关键要求:",
|
"关键要求:",
|
||||||
"1. 只调整框选主体物体的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
"1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
||||||
"2. 缩小时,主体应围绕原位置中心自然变小,露出的背景需要与原图环境、纹理、阴影和透视自然补全。",
|
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||||||
"3. 放大时,主体应围绕原位置中心自然变大,边缘与遮挡关系要合理,不能变形,也不能超出画面造成突兀裁切。",
|
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
|
||||||
"4. 未被框选的其它画面区域必须保持原图不变,不允许重绘、改色、补光、移动物体或改变背景细节。",
|
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
|
||||||
"5. 最终输出必须是一张干净照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
|
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||||||
|
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
|
||||||
|
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,6 +574,8 @@ export async function POST(request: NextRequest) {
|
|||||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
|
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
|
||||||
? buildScaleAdjustPrompt(regions, scalePercent)
|
? buildScaleAdjustPrompt(regions, scalePercent)
|
||||||
: buildLocalEditPrompt(regions);
|
: buildLocalEditPrompt(regions);
|
||||||
|
const isScaleAdjustOperation =
|
||||||
|
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
|
||||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||||||
region.referenceImages.map((reference) => reference.url)
|
region.referenceImages.map((reference) => reference.url)
|
||||||
);
|
);
|
||||||
@ -594,6 +598,7 @@ export async function POST(request: NextRequest) {
|
|||||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||||||
regionCount: regions.length,
|
regionCount: regions.length,
|
||||||
scalePercent,
|
scalePercent,
|
||||||
|
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
|
||||||
sourceHeight,
|
sourceHeight,
|
||||||
sourceNodeId: body.sourceNodeId || null,
|
sourceNodeId: body.sourceNodeId || null,
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
@ -608,17 +613,21 @@ export async function POST(request: NextRequest) {
|
|||||||
? body.model.trim()
|
? body.model.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
isScaleAdjustOperation
|
||||||
|
? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、主体未缩放、整块框选区域缩放"
|
||||||
|
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||||
prompt,
|
prompt,
|
||||||
promptExtend: false,
|
promptExtend: false,
|
||||||
referenceImages: [
|
referenceImages: [
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
...regionReferenceImageUrls,
|
...regionReferenceImageUrls,
|
||||||
],
|
],
|
||||||
localEditComposite: {
|
localEditComposite: isScaleAdjustOperation
|
||||||
sourceImageUrl,
|
? undefined
|
||||||
regions: regions.map((region) => region.rect),
|
: {
|
||||||
},
|
sourceImageUrl,
|
||||||
|
regions: regions.map((region) => region.rect),
|
||||||
|
},
|
||||||
ownerKey: session.user.id,
|
ownerKey: session.user.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,11 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
type SyntheticEvent,
|
type SyntheticEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react";
|
import { Loader2, Plus, RotateCcw, Scissors, Trash2, Upload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -22,6 +23,10 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { createClientLogger } from "@/lib/observability/client-logger";
|
import { createClientLogger } from "@/lib/observability/client-logger";
|
||||||
|
import {
|
||||||
|
createUploadedDetailReferenceCard,
|
||||||
|
uploadDetailReferenceImageFile,
|
||||||
|
} from "@/components/agents/detail-reference-upload";
|
||||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||||
|
|
||||||
const MIN_SLICE_HEIGHT = 120;
|
const MIN_SLICE_HEIGHT = 120;
|
||||||
@ -211,6 +216,10 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
const [warningText, setWarningText] = useState<string | null>(null);
|
const [warningText, setWarningText] = useState<string | null>(null);
|
||||||
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
|
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
|
||||||
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
|
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
|
||||||
|
const [segmentReplacementCards, setSegmentReplacementCards] = useState<
|
||||||
|
Record<number, AgentDetailReplacementReferenceItem>
|
||||||
|
>({});
|
||||||
|
const [uploadingSegmentIndex, setUploadingSegmentIndex] = useState<number | null>(null);
|
||||||
const hasManualCutLineChangesRef = useRef(false);
|
const hasManualCutLineChangesRef = useRef(false);
|
||||||
const onPreparedRef = useRef(onPrepared);
|
const onPreparedRef = useRef(onPrepared);
|
||||||
|
|
||||||
@ -265,6 +274,10 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
() => new Set(passthroughSegmentIndexes),
|
() => new Set(passthroughSegmentIndexes),
|
||||||
[passthroughSegmentIndexes]
|
[passthroughSegmentIndexes]
|
||||||
);
|
);
|
||||||
|
const segmentReplacementCount = useMemo(
|
||||||
|
() => Object.keys(segmentReplacementCards).length,
|
||||||
|
[segmentReplacementCards]
|
||||||
|
);
|
||||||
const mergedPreviewDisplayUrl = useMemo(
|
const mergedPreviewDisplayUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
snapshot
|
snapshot
|
||||||
@ -285,6 +298,8 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setWarningText(null);
|
setWarningText(null);
|
||||||
setPreviewImageLoadFailed(false);
|
setPreviewImageLoadFailed(false);
|
||||||
setPassthroughSegmentIndexes([]);
|
setPassthroughSegmentIndexes([]);
|
||||||
|
setSegmentReplacementCards({});
|
||||||
|
setUploadingSegmentIndex(null);
|
||||||
hasManualCutLineChangesRef.current = false;
|
hasManualCutLineChangesRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -311,6 +326,8 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setPreviewImageLoadFailed(true);
|
setPreviewImageLoadFailed(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsPreparing(true);
|
setIsPreparing(true);
|
||||||
|
setSegmentReplacementCards({});
|
||||||
|
setUploadingSegmentIndex(null);
|
||||||
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
|
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
@ -431,6 +448,15 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setPassthroughSegmentIndexes((current) =>
|
setPassthroughSegmentIndexes((current) =>
|
||||||
current.filter((index) => validIndexes.has(index))
|
current.filter((index) => validIndexes.has(index))
|
||||||
);
|
);
|
||||||
|
setSegmentReplacementCards((current) => {
|
||||||
|
const nextEntries = Object.entries(current).filter(([index]) =>
|
||||||
|
validIndexes.has(Number(index))
|
||||||
|
);
|
||||||
|
if (nextEntries.length === Object.keys(current).length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return Object.fromEntries(nextEntries);
|
||||||
|
});
|
||||||
}, [sliceSegments]);
|
}, [sliceSegments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -658,6 +684,39 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleReplaceSegmentImage = useCallback(
|
||||||
|
async (segmentIndex: number, event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadingSegmentIndex(segmentIndex);
|
||||||
|
try {
|
||||||
|
const upload = await uploadDetailReferenceImageFile(file);
|
||||||
|
const replacementCard = createUploadedDetailReferenceCard({
|
||||||
|
upload,
|
||||||
|
index: segmentIndex,
|
||||||
|
labelPrefix: "更换切片",
|
||||||
|
});
|
||||||
|
setSegmentReplacementCards((current) => ({
|
||||||
|
...current,
|
||||||
|
[segmentIndex]: {
|
||||||
|
...replacementCard,
|
||||||
|
sortOrder: segmentIndex,
|
||||||
|
passthrough: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
toast.success(`已更换切片 ${segmentIndex + 1}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "切片图片更换失败");
|
||||||
|
} finally {
|
||||||
|
setUploadingSegmentIndex(null);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleApply = useCallback(async () => {
|
const handleApply = useCallback(async () => {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
toast.error("当前还没有可用的拼合图");
|
toast.error("当前还没有可用的拼合图");
|
||||||
@ -668,6 +727,10 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
toast.error("当前没有可切割的参考图");
|
toast.error("当前没有可切割的参考图");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (uploadingSegmentIndex !== null) {
|
||||||
|
toast.error("请等待切片图片上传完成");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
|
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
|
||||||
@ -691,11 +754,14 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
throw new Error(result.error || "切割参考图失败");
|
throw new Error(result.error || "切割参考图失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedCards = result.data.cards.map((card, index) => ({
|
const orderedCards = result.data.cards.map((card, index) => {
|
||||||
...card,
|
const replacementCard = segmentReplacementCards[index];
|
||||||
sortOrder: index,
|
return {
|
||||||
passthrough: passthroughSegmentIndexSet.has(index),
|
...(replacementCard || card),
|
||||||
}));
|
sortOrder: index,
|
||||||
|
passthrough: passthroughSegmentIndexSet.has(index),
|
||||||
|
};
|
||||||
|
});
|
||||||
if (orderedCards.length !== sliceSegments.length) {
|
if (orderedCards.length !== sliceSegments.length) {
|
||||||
throw new Error("部分切片处理失败,请稍后重试");
|
throw new Error("部分切片处理失败,请稍后重试");
|
||||||
}
|
}
|
||||||
@ -728,8 +794,10 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
passthroughSegmentIndexes,
|
passthroughSegmentIndexes,
|
||||||
passthroughSegmentIndexSet,
|
passthroughSegmentIndexSet,
|
||||||
|
segmentReplacementCards,
|
||||||
sliceSegments.length,
|
sliceSegments.length,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
uploadingSegmentIndex,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -770,6 +838,11 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
直出 {passthroughSegmentIndexes.length} 张
|
直出 {passthroughSegmentIndexes.length} 张
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{segmentReplacementCount > 0 ? (
|
||||||
|
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100/85">
|
||||||
|
已更换 {segmentReplacementCount} 张
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -856,28 +929,28 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
<div
|
<div
|
||||||
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
|
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
|
||||||
className="relative overflow-hidden bg-white"
|
className="relative overflow-hidden bg-white"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||||
contentVisibility: index > 4 ? "auto" : undefined,
|
contentVisibility: index > 4 ? "auto" : undefined,
|
||||||
containIntrinsicSize:
|
containIntrinsicSize:
|
||||||
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
|
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
|
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
|
||||||
参考图 {index + 1} 加载失败
|
参考图 {index + 1} 加载失败
|
||||||
</div>
|
</div>
|
||||||
{segmentImageUrl ? (
|
{segmentImageUrl ? (
|
||||||
<img
|
<img
|
||||||
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
|
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
|
||||||
alt={`拼合分段预览 ${index + 1}`}
|
alt={`拼合分段预览 ${index + 1}`}
|
||||||
className="relative z-10 block h-full w-full select-none object-fill"
|
className="relative z-10 block h-full w-full select-none object-fill"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading={index > 2 ? "lazy" : "eager"}
|
loading={index > 2 ? "lazy" : "eager"}
|
||||||
fetchPriority={index < 2 ? "high" : "low"}
|
fetchPriority={index < 2 ? "high" : "low"}
|
||||||
onError={handleSegmentPreviewImageError(originalImageUrl)}
|
onError={handleSegmentPreviewImageError(originalImageUrl)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -976,51 +1049,121 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
|
|
||||||
<div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
<div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||||
{sliceSegments.length > 0 ? (
|
{sliceSegments.length > 0 ? (
|
||||||
sliceSegments.map((segment) => (
|
sliceSegments.map((segment) => {
|
||||||
<div
|
const replacementCard = segmentReplacementCards[segment.index];
|
||||||
key={`slice-segment-${segment.index}`}
|
const replacementUrl = String(
|
||||||
className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3"
|
replacementCard?.previewUrl || replacementCard?.imageUrl || ""
|
||||||
>
|
).trim();
|
||||||
<div className="flex items-center justify-between gap-3">
|
const isUploadingThisSegment = uploadingSegmentIndex === segment.index;
|
||||||
<div className="text-sm font-medium text-white">
|
return (
|
||||||
切片 {segment.index + 1}
|
<div
|
||||||
|
key={`slice-segment-${segment.index}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[20px] border bg-white/[0.03] p-3",
|
||||||
|
replacementCard
|
||||||
|
? "border-emerald-400/25"
|
||||||
|
: "border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
|
<div className="mt-2 text-xs leading-6 text-white/42">
|
||||||
高度 {segment.height}px
|
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
|
||||||
|
</div>
|
||||||
|
{replacementUrl ? (
|
||||||
|
<div className="mt-3 overflow-hidden rounded-[16px] border border-emerald-400/20 bg-white">
|
||||||
|
<img
|
||||||
|
src={buildStitchSegmentDisplayImageUrl(replacementUrl, 360)}
|
||||||
|
alt={`已更换切片 ${segment.index + 1}`}
|
||||||
|
className="block aspect-[4/3] w-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={handleSegmentPreviewImageError(replacementCard?.imageUrl || replacementUrl)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-[120px] flex-1 text-[11px] text-white/45">
|
||||||
|
{replacementCard
|
||||||
|
? "已使用手动更换图片,应用后会替代当前切片。"
|
||||||
|
: passthroughSegmentIndexSet.has(segment.index)
|
||||||
|
? "当前切片不会参与生图,应用后会直接进入结果区。"
|
||||||
|
: "当前切片会继续参与详情替换生成。"}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"inline-flex cursor-pointer items-center rounded-full border px-3 py-1 text-[11px] transition-colors",
|
||||||
|
isUploadingThisSegment
|
||||||
|
? "border-emerald-400/35 bg-emerald-500/[0.12] text-emerald-100"
|
||||||
|
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white",
|
||||||
|
(isApplying || uploadingSegmentIndex !== null) &&
|
||||||
|
!isUploadingThisSegment &&
|
||||||
|
"pointer-events-none opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.heic,.heif"
|
||||||
|
className="hidden"
|
||||||
|
disabled={isApplying || uploadingSegmentIndex !== null}
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleReplaceSegmentImage(segment.index, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isUploadingThisSegment ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{replacementCard ? "重新更换" : "更换图片"}
|
||||||
|
</label>
|
||||||
|
{replacementCard ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSegmentReplacementCards((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[segment.index];
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] text-white/62 transition-colors hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
恢复切片
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
</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 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">
|
||||||
当前还没有可切割的长图。
|
当前还没有可切割的长图。
|
||||||
@ -1029,12 +1172,19 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/10 px-5 py-4">
|
<div className="border-t border-white/10 px-5 py-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0}
|
disabled={
|
||||||
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
|
isLoading ||
|
||||||
>
|
isPreparing ||
|
||||||
|
isApplying ||
|
||||||
|
uploadingSegmentIndex !== null ||
|
||||||
|
!snapshot ||
|
||||||
|
sliceSegments.length === 0
|
||||||
|
}
|
||||||
|
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
{isApplying ? (
|
{isApplying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@ -65,6 +65,12 @@ import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetai
|
|||||||
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
|
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
|
||||||
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
||||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
|
import {
|
||||||
|
applyUploadedDetailReferenceToCard,
|
||||||
|
createUploadedDetailReferenceCard,
|
||||||
|
uploadDetailReferenceImageFile,
|
||||||
|
type UploadedDetailReferenceImage,
|
||||||
|
} from "@/components/agents/detail-reference-upload";
|
||||||
import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
|
import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
|
||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
@ -1174,6 +1180,11 @@ function DetailResultLocalEditDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{submitting ? (
|
||||||
|
<DetailResultProcessingScanOverlay
|
||||||
|
label={isScaleAdjustMode ? "比例调整处理中" : "局部编辑处理中"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -1920,31 +1931,36 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
|||||||
|
|
||||||
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
|
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/38 backdrop-blur-[1.5px]">
|
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/34">
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-0 opacity-45"
|
className="absolute inset-0 opacity-50"
|
||||||
animate={{
|
animate={{
|
||||||
backgroundPosition: ["0px 0px", "0px 28px"],
|
backgroundPosition: ["0px 0px", "0px 20px"],
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1.45, repeat: Infinity, ease: "linear" }}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)",
|
"linear-gradient(rgba(255,255,255,0.11) 0px, rgba(255,255,255,0.11) 1px, transparent 1px, transparent 20px)",
|
||||||
backgroundSize: "100% 28px",
|
backgroundSize: "100% 20px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-x-[-18%] h-24 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.10)_17%,rgba(16,185,129,0.64)_43%,rgba(255,255,255,0.92)_50%,rgba(16,185,129,0.58)_58%,rgba(34,211,238,0.10)_83%,transparent_100%)] shadow-[0_0_42px_rgba(16,185,129,0.46)]"
|
className="absolute inset-x-[-8%] h-[26%] min-h-[110px]"
|
||||||
animate={{ y: ["-45%", "115%"], opacity: [0.58, 1, 0.7] }}
|
animate={{ top: ["-30%", "104%"], opacity: [0.38, 0.95, 0.48] }}
|
||||||
transition={{ duration: 1.85, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
|
transition={{ duration: 3.2, repeat: Infinity, ease: "linear" }}
|
||||||
/>
|
>
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.06)_18%,rgba(52,211,153,0.22)_34%,rgba(255,255,255,0.92)_50%,rgba(52,211,153,0.26)_58%,rgba(34,211,238,0.08)_74%,transparent_100%)]" />
|
||||||
|
<div className="absolute inset-x-[3%] top-1/2 h-px -translate-y-1/2 bg-white/95 shadow-[0_0_18px_rgba(255,255,255,0.92),0_0_34px_rgba(52,211,153,0.55)]" />
|
||||||
|
<div className="absolute inset-x-[6%] top-[calc(50%-10px)] h-px bg-emerald-200/48" />
|
||||||
|
<div className="absolute inset-x-[6%] top-[calc(50%+10px)] h-px bg-cyan-200/38" />
|
||||||
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.20),transparent_58%)]"
|
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.12),transparent_62%)]"
|
||||||
animate={{ opacity: [0.18, 0.44, 0.18] }}
|
animate={{ opacity: [0.16, 0.28, 0.16] }}
|
||||||
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 2.6, repeat: Infinity, ease: "easeInOut" }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/65 bg-zinc-950/86 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.48),0_0_22px_rgba(16,185,129,0.24)] backdrop-blur-md">
|
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/65 bg-zinc-950/86 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.48),0_0_22px_rgba(16,185,129,0.24)] backdrop-blur-md">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
|
||||||
@ -1954,6 +1970,23 @@ function DetailResultProcessingScanOverlay({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResultProcessedHintBubble({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -10, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
className="pointer-events-none absolute left-1/2 top-3 z-40 -translate-x-1/2"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200/70 bg-zinc-950/88 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_34px_rgba(0,0,0,0.42),0_0_20px_rgba(16,185,129,0.22)] backdrop-blur-md">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AnimatedGenerationPlaceholderCard(params: {
|
function AnimatedGenerationPlaceholderCard(params: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -2176,6 +2209,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState("");
|
const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState("");
|
||||||
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
|
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
|
||||||
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
|
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
|
||||||
|
const [detailReferenceUploadTarget, setDetailReferenceUploadTarget] =
|
||||||
|
useState<null | { type: "append" } | { type: "replace"; cardId: string }>(null);
|
||||||
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
|
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
lastQuery: string | null;
|
lastQuery: string | null;
|
||||||
@ -2194,6 +2229,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
|
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
|
||||||
DirectDetailGenerationImage[]
|
DirectDetailGenerationImage[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [processedResultHintByUrl, setProcessedResultHintByUrl] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||||
() => createEmptyReplacementResultState()
|
() => createEmptyReplacementResultState()
|
||||||
);
|
);
|
||||||
@ -2206,6 +2244,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
||||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||||
useState<Set<string>>(() => new Set());
|
useState<Set<string>>(() => new Set());
|
||||||
|
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||||
const closePsdTemplateDialog = useCallback(() => {
|
const closePsdTemplateDialog = useCallback(() => {
|
||||||
setIsPsdTemplateDialogOpen(false);
|
setIsPsdTemplateDialogOpen(false);
|
||||||
setPsdTemplateDialogSourceUrl(null);
|
setPsdTemplateDialogSourceUrl(null);
|
||||||
@ -3139,6 +3178,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
);
|
);
|
||||||
}, [historyMessages, setMessages]);
|
}, [historyMessages, setMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(processedResultHintTimerRef.current).forEach((timerId) => {
|
||||||
|
window.clearTimeout(timerId);
|
||||||
|
});
|
||||||
|
processedResultHintTimerRef.current = {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWorkflowModelLocked) {
|
if (isWorkflowModelLocked) {
|
||||||
setSelectedModelId((current) => (current === "auto" ? current : "auto"));
|
setSelectedModelId((current) => (current === "auto" ? current : "auto"));
|
||||||
@ -5121,11 +5169,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
|
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
|
||||||
);
|
);
|
||||||
const appendResultUrlToCollection = useCallback(
|
const appendResultUrlToCollection = useCallback(
|
||||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||||
const normalizedUrl = String(imageUrl || "").trim();
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
if (!normalizedUrl || !onResultPreviewUrlsChange) {
|
if (!normalizedUrl || !onResultPreviewUrlsChange) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUrls = Array.from(
|
const nextUrls = Array.from(
|
||||||
@ -5139,13 +5187,38 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setSelectedResultUrl(normalizedUrl);
|
setSelectedResultUrl(normalizedUrl);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
|
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
|
||||||
);
|
);
|
||||||
const handleEnhanceDetailGenerationResult = useCallback(
|
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
|
||||||
async (params: {
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
cardId: string;
|
const normalizedLabel = String(label || "").trim();
|
||||||
imageUrl: string;
|
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
|
||||||
|
|
||||||
|
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
|
||||||
|
if (existingTimer) {
|
||||||
|
window.clearTimeout(existingTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessedResultHintByUrl((current) => ({
|
||||||
|
...current,
|
||||||
|
[normalizedUrl]: normalizedLabel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
processedResultHintTimerRef.current[normalizedUrl] = window.setTimeout(() => {
|
||||||
|
setProcessedResultHintByUrl((current) => {
|
||||||
|
if (!(normalizedUrl in current)) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[normalizedUrl];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
delete processedResultHintTimerRef.current[normalizedUrl];
|
||||||
|
}, 2800);
|
||||||
|
}, []);
|
||||||
|
const handleEnhanceDetailGenerationResult = useCallback(
|
||||||
|
async (params: {
|
||||||
|
cardId: string;
|
||||||
|
imageUrl: string;
|
||||||
resultModeType?: "detail_generation" | "detail_replacement";
|
resultModeType?: "detail_generation" | "detail_replacement";
|
||||||
}) => {
|
}) => {
|
||||||
const cardId = String(params.cardId || "").trim();
|
const cardId = String(params.cardId || "").trim();
|
||||||
@ -5296,6 +5369,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setActiveResultModeType("detail_generation");
|
setActiveResultModeType("detail_generation");
|
||||||
setSelectedResultUrl(nextUrl);
|
setSelectedResultUrl(nextUrl);
|
||||||
}
|
}
|
||||||
|
showProcessedResultHint(
|
||||||
|
nextUrl,
|
||||||
|
isScaleAdjust ? "比例调整完成" : "局部编辑完成"
|
||||||
|
);
|
||||||
setDetailLocalEditTarget(null);
|
setDetailLocalEditTarget(null);
|
||||||
toast.success(
|
toast.success(
|
||||||
resultModeType === "detail_replacement"
|
resultModeType === "detail_replacement"
|
||||||
@ -5333,6 +5410,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
detailLocalEditSubmittingId,
|
detailLocalEditSubmittingId,
|
||||||
detailLocalEditTarget,
|
detailLocalEditTarget,
|
||||||
handleUpdateReplacementResultRecord,
|
handleUpdateReplacementResultRecord,
|
||||||
|
showProcessedResultHint,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const handlePsdDerivedImageCreated = useCallback(
|
const handlePsdDerivedImageCreated = useCallback(
|
||||||
@ -5341,13 +5419,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
||||||
);
|
);
|
||||||
const handlePsdRenderedResultCreated = useCallback(
|
const handlePsdRenderedResultCreated = useCallback(
|
||||||
(imageUrl: string) => {
|
(imageUrl: string) => {
|
||||||
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
|
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
|
||||||
},
|
},
|
||||||
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
||||||
);
|
);
|
||||||
const handleDeleteResultImage = useCallback(
|
const handleDeleteResultImage = useCallback(
|
||||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||||
const normalizedUrl = String(imageUrl || "").trim();
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
if (!normalizedUrl || !onDeleteResultImage) {
|
if (!normalizedUrl || !onDeleteResultImage) {
|
||||||
@ -5363,6 +5441,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
if (sceneResizeEditorTargetUrl === normalizedUrl) {
|
if (sceneResizeEditorTargetUrl === normalizedUrl) {
|
||||||
setSceneResizeEditorTargetUrl(null);
|
setSceneResizeEditorTargetUrl(null);
|
||||||
}
|
}
|
||||||
|
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
|
||||||
|
if (existingTimer) {
|
||||||
|
window.clearTimeout(existingTimer);
|
||||||
|
delete processedResultHintTimerRef.current[normalizedUrl];
|
||||||
|
}
|
||||||
|
setProcessedResultHintByUrl((current) => {
|
||||||
|
if (!(normalizedUrl in current)) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[normalizedUrl];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
onDeleteResultImage(modeType, normalizedUrl);
|
onDeleteResultImage(modeType, normalizedUrl);
|
||||||
toast.success("结果图已删除");
|
toast.success("结果图已删除");
|
||||||
@ -6258,6 +6347,101 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
references: [],
|
references: [],
|
||||||
}));
|
}));
|
||||||
}, [updateDetailReplacementState]);
|
}, [updateDetailReplacementState]);
|
||||||
|
const clearDetailReplacementResultRecords = useCallback((cardIds: string[]) => {
|
||||||
|
const idSet = new Set(cardIds.map((item) => String(item || "").trim()).filter(Boolean));
|
||||||
|
if (idSet.size === 0) return;
|
||||||
|
|
||||||
|
setReplacementResultState((current) => {
|
||||||
|
const nextDetail = { ...current.detail_replacement };
|
||||||
|
let changed = false;
|
||||||
|
idSet.forEach((cardId) => {
|
||||||
|
if (cardId in nextDetail) {
|
||||||
|
delete nextDetail[cardId];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
detail_replacement: nextDetail,
|
||||||
|
}
|
||||||
|
: current;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const handleAppendDetailReferenceFiles = useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const files = Array.from(input.files || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setDetailReferenceUploadTarget({ type: "append" });
|
||||||
|
try {
|
||||||
|
const uploads: UploadedDetailReferenceImage[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
uploads.push(await uploadDetailReferenceImageFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDetailReplacementState((current) => {
|
||||||
|
const startIndex = current.references.length;
|
||||||
|
const uploadedCards = uploads.map((upload, uploadIndex) =>
|
||||||
|
createUploadedDetailReferenceCard({
|
||||||
|
upload,
|
||||||
|
index: startIndex + uploadIndex,
|
||||||
|
labelPrefix: "上传目标详情",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
references: [...current.references, ...uploadedCards].map((card, index) => ({
|
||||||
|
...card,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
toast.success(`已上传 ${uploads.length} 张目标详情`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "目标详情上传失败");
|
||||||
|
} finally {
|
||||||
|
setDetailReferenceUploadTarget(null);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateDetailReplacementState]
|
||||||
|
);
|
||||||
|
const handleReplaceDetailReferenceCardFile = useCallback(
|
||||||
|
async (cardId: string, event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setDetailReferenceUploadTarget({ type: "replace", cardId });
|
||||||
|
try {
|
||||||
|
const upload = await uploadDetailReferenceImageFile(file);
|
||||||
|
let didReplace = false;
|
||||||
|
updateDetailReplacementState((current) => ({
|
||||||
|
...current,
|
||||||
|
references: current.references.map((card, index) => {
|
||||||
|
if (card.id !== cardId) return card;
|
||||||
|
didReplace = true;
|
||||||
|
return {
|
||||||
|
...applyUploadedDetailReferenceToCard(card, upload),
|
||||||
|
sortOrder: card.sortOrder ?? index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
if (didReplace) {
|
||||||
|
clearDetailReplacementResultRecords([cardId]);
|
||||||
|
toast.success("已更换当前目标详情图");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "目标详情更换失败");
|
||||||
|
} finally {
|
||||||
|
setDetailReferenceUploadTarget(null);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearDetailReplacementResultRecords, updateDetailReplacementState]
|
||||||
|
);
|
||||||
const handlePreparedDetailReferenceCards = useCallback(
|
const handlePreparedDetailReferenceCards = useCallback(
|
||||||
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
||||||
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
|
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
|
||||||
@ -7425,11 +7609,34 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
目标 / {detailReferenceSourceLabel}
|
目标 / {detailReferenceSourceLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Input
|
<label
|
||||||
value={detailProductIdQueryInput}
|
className={cn(
|
||||||
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
|
"inline-flex h-12 cursor-pointer items-center justify-center rounded-[22px] border border-white/10 bg-white/[0.03] px-5 text-sm text-white transition-colors hover:bg-white/[0.08]",
|
||||||
|
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.heic,.heif"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
disabled={detailReferenceUploadTarget !== null}
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleAppendDetailReferenceFiles(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detailReferenceUploadTarget?.type === "append" ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
上传目标详情
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={detailProductIdQueryInput}
|
||||||
|
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -7924,10 +8131,33 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
<div className="text-xs text-white/40">
|
<div className="text-xs text-white/40">
|
||||||
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
|
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{detailReplacementReferenceCards.length > 0 ? (
|
<label
|
||||||
<button
|
className={cn(
|
||||||
|
"inline-flex cursor-pointer items-center rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white",
|
||||||
|
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.heic,.heif"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
disabled={detailReferenceUploadTarget !== null}
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleAppendDetailReferenceFiles(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detailReferenceUploadTarget?.type === "append" ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
上传
|
||||||
|
</label>
|
||||||
|
{detailReplacementReferenceCards.length > 0 ? (
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDetailReferenceStitchDialogOpen(true)}
|
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]"
|
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]"
|
||||||
@ -7943,11 +8173,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
{detailReplacementReferenceCards.length > 0 ? (
|
{detailReplacementReferenceCards.length > 0 ? (
|
||||||
detailReplacementReferenceCards.map((card, index) => (
|
detailReplacementReferenceCards.map((card, index) => (
|
||||||
(() => {
|
(() => {
|
||||||
const isActive = activeDetailReferenceCardId === card.id;
|
const isActive = activeDetailReferenceCardId === card.id;
|
||||||
return (
|
const isReplacingReference =
|
||||||
<div
|
detailReferenceUploadTarget?.type === "replace" &&
|
||||||
|
detailReferenceUploadTarget.cardId === card.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -7958,10 +8191,34 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
)}
|
)}
|
||||||
onClick={() => setActiveDetailReferenceCardId(card.id)}
|
onClick={() => setActiveDetailReferenceCardId(card.id)}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
|
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
|
||||||
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
|
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
|
||||||
<button
|
<label
|
||||||
type="button"
|
className={cn(
|
||||||
|
IMAGE_OVERLAY_ACTION_CLASS,
|
||||||
|
"inline-flex cursor-pointer items-center gap-1.5",
|
||||||
|
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.heic,.heif"
|
||||||
|
className="hidden"
|
||||||
|
disabled={detailReferenceUploadTarget !== null}
|
||||||
|
onChange={(event) => {
|
||||||
|
void handleReplaceDetailReferenceCardFile(card.id, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isReplacingReference ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{isReplacingReference ? "更换中" : "更换"}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleRemoveDetailReferenceCard(card.id);
|
handleRemoveDetailReferenceCard(card.id);
|
||||||
@ -8037,10 +8294,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
|
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
|
||||||
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
|
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
|
||||||
<div className="mt-2 text-xs leading-6 text-white/40">
|
<div className="mt-2 text-xs leading-6 text-white/40">
|
||||||
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。
|
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图;也可以直接上传目标详情图。
|
||||||
删除不需要的参考图后,列表会自动上移补位。
|
删除不需要的参考图后,列表会自动上移补位。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -8119,6 +8376,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
<div className={cn("absolute left-3 top-3", IMAGE_OVERLAY_STATUS_BASE_CLASS, statusMeta.className)}>
|
<div className={cn("absolute left-3 top-3", IMAGE_OVERLAY_STATUS_BASE_CLASS, statusMeta.className)}>
|
||||||
{statusMeta.label}
|
{statusMeta.label}
|
||||||
</div>
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{card.resultUrl && processedResultHintByUrl[card.resultUrl] ? (
|
||||||
|
<ResultProcessedHintBubble
|
||||||
|
label={processedResultHintByUrl[card.resultUrl]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}>
|
<div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}>
|
||||||
当前联动
|
当前联动
|
||||||
@ -8967,6 +9231,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
<span>套PSD</span>
|
<span>套PSD</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AnimatePresence>
|
||||||
|
{card.url && processedResultHintByUrl[card.url] ? (
|
||||||
|
<ResultProcessedHintBubble
|
||||||
|
label={processedResultHintByUrl[card.url]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
129
components/agents/detail-reference-upload.ts
Normal file
129
components/agents/detail-reference-upload.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||||
|
|
||||||
|
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface UploadedDetailReferenceImage {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
contentType: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedDetailReferenceImage(file: File) {
|
||||||
|
return (
|
||||||
|
file.type.startsWith("image/") ||
|
||||||
|
/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailReferenceUploadFilename(file: File) {
|
||||||
|
const extension = file.name.toLowerCase().split(".").pop() || "jpg";
|
||||||
|
const baseName =
|
||||||
|
file.name
|
||||||
|
.replace(/\.[^.]+$/, "")
|
||||||
|
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48) || "target-detail";
|
||||||
|
return `${baseName}-${Date.now()}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDimension(value: unknown) {
|
||||||
|
const numberValue = Math.round(Number(value));
|
||||||
|
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLocalImageDimensions(file: File) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { width: null, height: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<{ width: number | null; height: number | null }>((resolve) => {
|
||||||
|
const objectUrl = window.URL.createObjectURL(file);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
const width = normalizeDimension(image.naturalWidth);
|
||||||
|
const height = normalizeDimension(image.naturalHeight);
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
resolve({ width, height });
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
resolve({ width: null, height: null });
|
||||||
|
};
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDetailReferenceImageFile(
|
||||||
|
file: File
|
||||||
|
): Promise<UploadedDetailReferenceImage> {
|
||||||
|
if (!isSupportedDetailReferenceImage(file)) {
|
||||||
|
throw new Error("请上传图片文件");
|
||||||
|
}
|
||||||
|
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
|
||||||
|
throw new Error("目标详情图过大,请压缩到 50MB 以内");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensionsPromise = readLocalImageDimensions(file);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, buildDetailReferenceUploadFilename(file));
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !payload?.url) {
|
||||||
|
throw new Error(payload?.error || "目标详情图上传失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = String(payload.url || "").trim();
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
throw new Error("目标详情图上传后未返回可用地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDimensions = await dimensionsPromise;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
name: file.name || "目标详情图",
|
||||||
|
contentType: file.type || null,
|
||||||
|
assetId: typeof payload.assetId === "string" ? payload.assetId : null,
|
||||||
|
width: normalizeDimension(payload.width) || localDimensions.width,
|
||||||
|
height: normalizeDimension(payload.height) || localDimensions.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUploadedDetailReferenceCard(params: {
|
||||||
|
upload: UploadedDetailReferenceImage;
|
||||||
|
index: number;
|
||||||
|
labelPrefix?: string;
|
||||||
|
}): AgentDetailReplacementReferenceItem {
|
||||||
|
const labelPrefix = params.labelPrefix || "上传目标详情";
|
||||||
|
return {
|
||||||
|
id: `manual-detail-reference-${Date.now()}-${params.index + 1}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`,
|
||||||
|
label: `${labelPrefix} ${String(params.index + 1).padStart(2, "0")}`,
|
||||||
|
imageUrl: params.upload.url,
|
||||||
|
previewUrl: params.upload.url,
|
||||||
|
width: params.upload.width,
|
||||||
|
height: params.upload.height,
|
||||||
|
candidateId: null,
|
||||||
|
sortOrder: params.index,
|
||||||
|
passthrough: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUploadedDetailReferenceToCard(
|
||||||
|
card: AgentDetailReplacementReferenceItem,
|
||||||
|
upload: UploadedDetailReferenceImage
|
||||||
|
): AgentDetailReplacementReferenceItem {
|
||||||
|
return {
|
||||||
|
...card,
|
||||||
|
imageUrl: upload.url,
|
||||||
|
previewUrl: upload.url,
|
||||||
|
width: upload.width,
|
||||||
|
height: upload.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user