feat agent scene result resize dialog
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-26 17:38:32 +08:00
parent 19c76950db
commit 1721f53248
3 changed files with 230 additions and 3 deletions

View File

@ -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 && (

View File

@ -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}

View File

@ -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>
);