feat agent scene result resize dialog
This commit is contained in:
parent
19c76950db
commit
1721f53248
@ -49,6 +49,7 @@ import {
|
||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||||
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
@ -143,6 +144,11 @@ interface GeneratedImageGroup {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface SceneResultToolbarTarget {
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface AgentModeReferenceSearchResponse {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
@ -936,6 +942,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [activeResultModeType, setActiveResultModeType] =
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
|
||||
const [sceneResultToolbarTarget, setSceneResultToolbarTarget] =
|
||||
useState<SceneResultToolbarTarget | null>(null);
|
||||
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
|
||||
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
|
||||
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
|
||||
isLoading: boolean;
|
||||
@ -2217,6 +2226,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
}, [activeResultModeUrls, selectedResultUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
sceneResultToolbarTarget?.url &&
|
||||
!normalizeResultCollectionUrls(mergedResultCollections.scene_generation).includes(
|
||||
sceneResultToolbarTarget.url
|
||||
)
|
||||
) {
|
||||
setSceneResultToolbarTarget(null);
|
||||
}
|
||||
}, [mergedResultCollections.scene_generation, sceneResultToolbarTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentPresetId || !hasAgentUploadSlots) {
|
||||
setIsAttachmentSlotExpanded(false);
|
||||
@ -2569,6 +2589,64 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||
const response = await fetch(base64Url);
|
||||
const blob = await response.blob();
|
||||
const formData = new FormData();
|
||||
formData.append("file", blob, `agent-scene-resize-${Date.now()}.png`);
|
||||
|
||||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("上传失败");
|
||||
}
|
||||
|
||||
const result = await uploadResponse.json().catch(() => ({}));
|
||||
const uploadedUrl = String(result?.url || "").trim();
|
||||
if (!/^https?:\/\//i.test(uploadedUrl)) {
|
||||
throw new Error("上传结果无效");
|
||||
}
|
||||
return uploadedUrl;
|
||||
}, []);
|
||||
const handleSceneResultResizeComplete = useCallback(
|
||||
async (resizedImageUrl: string) => {
|
||||
if (!onResultPreviewUrlsChange) {
|
||||
toast.error("当前项目未接入结果保存能力");
|
||||
return;
|
||||
}
|
||||
|
||||
let finalUrl = String(resizedImageUrl || "").trim();
|
||||
if (finalUrl.startsWith("data:")) {
|
||||
try {
|
||||
toast.info("正在上传尺寸调整结果...");
|
||||
finalUrl = await uploadBase64ResultToStorage(finalUrl);
|
||||
} catch (error) {
|
||||
console.error("[AgentGenerationWorkspaceDarkV2] 上传尺寸调整结果失败:", error);
|
||||
toast.error("尺寸调整结果上传失败");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||||
toast.error("尺寸调整结果地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSceneUrls = Array.from(
|
||||
new Set([
|
||||
...normalizeResultCollectionUrls(mergedResultCollections.scene_generation),
|
||||
finalUrl,
|
||||
])
|
||||
);
|
||||
|
||||
onResultPreviewUrlsChange("scene_generation", nextSceneUrls);
|
||||
setActiveResultModeType("scene_generation");
|
||||
setSelectedResultUrl(finalUrl);
|
||||
setSceneResizeEditorTargetUrl(null);
|
||||
setSceneResultToolbarTarget(null);
|
||||
toast.success("尺寸调整结果已加入场景结果区");
|
||||
},
|
||||
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
|
||||
);
|
||||
const handleDeleteResultImage = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
@ -2582,11 +2660,23 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
if (fullscreenPreview === normalizedUrl) {
|
||||
setFullscreenPreview(null);
|
||||
}
|
||||
if (sceneResultToolbarTarget?.url === normalizedUrl) {
|
||||
setSceneResultToolbarTarget(null);
|
||||
}
|
||||
if (sceneResizeEditorTargetUrl === normalizedUrl) {
|
||||
setSceneResizeEditorTargetUrl(null);
|
||||
}
|
||||
|
||||
onDeleteResultImage(modeType, normalizedUrl);
|
||||
toast.success("结果图已删除");
|
||||
},
|
||||
[fullscreenPreview, onDeleteResultImage, selectedResultUrl]
|
||||
[
|
||||
fullscreenPreview,
|
||||
onDeleteResultImage,
|
||||
sceneResizeEditorTargetUrl,
|
||||
sceneResultToolbarTarget,
|
||||
selectedResultUrl,
|
||||
]
|
||||
);
|
||||
|
||||
const handleToolStepChange = useCallback(
|
||||
@ -5381,6 +5471,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{gridResultCards.map((card) => {
|
||||
const isActive = card.url && displayedResultUrl === card.url;
|
||||
const isSceneResultCard = activeResultModeType === "scene_generation";
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -5390,6 +5481,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
onClick={() => {
|
||||
if (!card.url) return;
|
||||
setSelectedResultUrl(card.url);
|
||||
if (isSceneResultCard) {
|
||||
setSceneResultToolbarTarget({
|
||||
url: card.url,
|
||||
index: card.index,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!card.url) return;
|
||||
@ -5401,6 +5498,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedResultUrl(card.url);
|
||||
if (isSceneResultCard) {
|
||||
setSceneResultToolbarTarget({
|
||||
url: card.url,
|
||||
index: card.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@ -5420,6 +5523,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
{isSceneResultCard ? (
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-white/78 backdrop-blur">
|
||||
单击打开工具栏,双击查看大图
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@ -5514,6 +5622,115 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AnimatePresence>
|
||||
{sceneResultToolbarTarget && activeResultModeType === "scene_generation" ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/75 px-4 backdrop-blur-sm"
|
||||
onClick={() => setSceneResultToolbarTarget(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="w-full max-w-[760px] rounded-[30px] border border-white/12 bg-[#090c0d]/96 p-5 shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-emerald-200/60">
|
||||
场景图工具栏
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
场景结果图 {sceneResultToolbarTarget.index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-white/42">
|
||||
这里可以快速预览、下载,或直接进入尺寸调整弹窗。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSceneResultToolbarTarget(null)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.03] text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.06]"
|
||||
aria-label="关闭工具栏"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,#050607_0%,#101417_100%)]">
|
||||
<img
|
||||
src={buildPreviewImageUrl(sceneResultToolbarTarget.url, 1280)}
|
||||
alt={`场景结果图 ${sceneResultToolbarTarget.index + 1}`}
|
||||
className="max-h-[58vh] w-full object-contain"
|
||||
onError={(event) =>
|
||||
handlePreviewImageLoadError(event, sceneResultToolbarTarget.url)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedResultUrl(sceneResultToolbarTarget.url);
|
||||
setSceneResultToolbarTarget(null);
|
||||
setFullscreenPreview(sceneResultToolbarTarget.url);
|
||||
}}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-[20px] border border-white/10 bg-white/[0.04] px-4 text-sm font-medium text-white transition-colors hover:border-white/20 hover:bg-white/[0.07]"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
查看大图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleDownloadOriginalImage(sceneResultToolbarTarget.url);
|
||||
}}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-[20px] border border-white/10 bg-white/[0.04] px-4 text-sm font-medium text-white transition-colors hover:border-white/20 hover:bg-white/[0.07]"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载原图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedResultUrl(sceneResultToolbarTarget.url);
|
||||
setSceneResizeEditorTargetUrl(sceneResultToolbarTarget.url);
|
||||
setSceneResultToolbarTarget(null);
|
||||
}}
|
||||
className="flex min-h-[56px] items-center justify-center gap-2 rounded-[20px] border border-emerald-400/28 bg-emerald-500/[0.10] px-4 text-sm font-medium text-emerald-50 transition-colors hover:border-emerald-300/45 hover:bg-emerald-500/[0.16]"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
尺寸调整
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{sceneResizeEditorTargetUrl ? (
|
||||
<ImageAnnotator
|
||||
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}
|
||||
imageUrl={sceneResizeEditorTargetUrl}
|
||||
initialResizeMode
|
||||
confirmButtonText="保存处理结果"
|
||||
appearance="dark"
|
||||
onConfirm={handleSceneResultResizeComplete}
|
||||
onCancel={() => setSceneResizeEditorTargetUrl(null)}
|
||||
open={Boolean(sceneResizeEditorTargetUrl)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSceneResizeEditorTargetUrl(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Fullscreen Preview */}
|
||||
<AnimatePresence>
|
||||
{fullscreenPreview && (
|
||||
|
||||
@ -3401,7 +3401,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
key={`resize-${node.id}-${data.src}`}
|
||||
imageUrl={data.src}
|
||||
initialResizeMode
|
||||
confirmButtonText="发送到画布"
|
||||
confirmButtonText="保存处理结果"
|
||||
onConfirm={handleResizeEditorComplete}
|
||||
onCancel={() => setResizeAnnotatorOpen(false)}
|
||||
open={resizeAnnotatorOpen}
|
||||
|
||||
@ -55,6 +55,7 @@ interface ImageAnnotatorProps {
|
||||
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
||||
confirmButtonText?: string;
|
||||
submitMode?: "confirm" | "publish-new-canvas";
|
||||
appearance?: "theme" | "dark";
|
||||
open?: boolean; // 兼容 Dialog 模式
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
@ -67,6 +68,7 @@ export function ImageAnnotator({
|
||||
initialResizeMode = false,
|
||||
confirmButtonText,
|
||||
submitMode = "confirm",
|
||||
appearance = "theme",
|
||||
open = true,
|
||||
onOpenChange,
|
||||
}: ImageAnnotatorProps) {
|
||||
@ -103,6 +105,7 @@ export function ImageAnnotator({
|
||||
const resolvedConfirmButtonText =
|
||||
confirmButtonText ||
|
||||
(submitMode === "publish-new-canvas" ? "发布到新的画布" : "发送到新对话");
|
||||
const forceDarkAppearance = appearance === "dark";
|
||||
const toCanvasLoadUrl = useCallback((url: string) => {
|
||||
return buildImageProxyUrl(url);
|
||||
}, []);
|
||||
@ -1513,11 +1516,17 @@ export function ImageAnnotator({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]",
|
||||
forceDarkAppearance ? "bg-[#09090b] text-zinc-100" : "bg-background"
|
||||
)}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>图片编辑器</DialogTitle>
|
||||
<DialogDescription>编辑、标注或处理图片。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className={cn("h-full w-full", forceDarkAppearance && "dark")}>
|
||||
|
||||
{/* Top Header - Minimal */}
|
||||
{!showSceneFusion && (
|
||||
@ -2006,6 +2015,7 @@ export function ImageAnnotator({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user