画布图片新增提示词重绘对话框

This commit is contained in:
Your Name 2026-02-08 12:01:04 +08:00
parent 6cf0597227
commit 6b87adc622

View File

@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
@ -82,6 +83,38 @@ const sanitizeFileName = (name: string) => {
return safe || "image"; return safe || "image";
}; };
const COMMON_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
] as const;
function guessNearestAspectRatio(width: number, height: number): string {
if (!width || !height) return "auto";
const target = width / height;
let best = "auto";
let minDiff = Number.POSITIVE_INFINITY;
for (const ratio of COMMON_ASPECT_RATIOS) {
const [w, h] = ratio.split(":").map(Number);
const value = w / h;
const diff = Math.abs(target - value);
if (diff < minDiff) {
minDiff = diff;
best = ratio;
}
}
return best;
}
/** /**
* 在图片上绘制标记点,返回带标记的图片 base64 * 在图片上绘制标记点,返回带标记的图片 base64
*/ */
@ -177,8 +210,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [angleDialogOpen, setAngleDialogOpen] = useState(false); const [angleDialogOpen, setAngleDialogOpen] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeDialogOpen, setResizeDialogOpen] = useState(false); const [resizeDialogOpen, setResizeDialogOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [resizeWidth, setResizeWidth] = useState(1024); const [resizeWidth, setResizeWidth] = useState(1024);
const [resizeHeight, setResizeHeight] = useState(1024); const [resizeHeight, setResizeHeight] = useState(1024);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
@ -235,6 +270,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
if (!isSelected) { if (!isSelected) {
setTextEditPanelOpen(false); setTextEditPanelOpen(false);
setIsEditingTitle(false); setIsEditingTitle(false);
setPromptDialogOpen(false);
} }
}, [isSelected]); }, [isSelected]);
@ -243,6 +279,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setDraftTitle(getImageNodeTitle(data, node.id)); setDraftTitle(getImageNodeTitle(data, node.id));
} }
}, [data, isEditingTitle, node.id]); }, [data, isEditingTitle, node.id]);
useEffect(() => {
if (promptDialogOpen) {
setRegeneratePrompt(data.prompt || "");
}
}, [data.prompt, promptDialogOpen]);
// 🆕 处理图片点击标注 // 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => { const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
@ -668,6 +710,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
'scene-fusion': '详情替换', 'scene-fusion': '详情替换',
'annotate': '图片标注', 'annotate': '图片标注',
'text-edit': '编辑文字', 'text-edit': '编辑文字',
'prompt-regenerate': '提示词重绘',
}; };
return names[action] || action; return names[action] || action;
}; };
@ -704,6 +747,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
await handleOpenTextEdit(); await handleOpenTextEdit();
return; return;
} }
if (action === "prompt-regenerate") {
setRegeneratePrompt(data.prompt || "");
setPromptDialogOpen(true);
return;
}
const actionName = getActionName(action); const actionName = getActionName(action);
@ -955,6 +1003,92 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
} }
}; };
// 基于当前图片+新提示词重绘
const handlePromptRegenerate = async () => {
if (!data.src) return;
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const promptText = regeneratePrompt.trim();
if (!promptText) {
toast.error("请输入提示词");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setPromptDialogOpen(false);
setIsProcessing(true);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: promptText,
title: `${displayTitle}-重绘`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const aspectRatio = guessNearestAspectRatio(node.size.width, node.size.height);
const result = await imageGenerationService.generateImage({
prompt: promptText,
referenceImage: data.src,
aspectRatio,
imageCount: 1,
});
const image = result.data?.images?.[0];
if (result.success && image?.originalUrl) {
updateNode(newNodeId, {
data: {
src: image.originalUrl,
status: "completed",
prompt: promptText,
title: `${promptText.slice(0, 24) || displayTitle}-重绘`,
originalSrc: data.src,
},
});
toast.success("已根据新提示词生成图片");
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error(result.error || "重绘失败");
}
} catch (error) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error("重绘失败");
} finally {
setIsProcessing(false);
}
};
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转) // 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
const handleDownload = async () => { const handleDownload = async () => {
if (!data.src) return; if (!data.src) return;
@ -1188,6 +1322,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Layers className="h-4 w-4 mr-2" /> <Layers className="h-4 w-4 mr-2" />
详情替换 详情替换
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('prompt-regenerate')}>
<RefreshCw className="h-4 w-4 mr-2" />
提示词重绘
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('text-edit')}> <DropdownMenuItem onClick={() => handleAction('text-edit')}>
<Type className="h-4 w-4 mr-2" /> <Type className="h-4 w-4 mr-2" />
编辑文字 编辑文字
@ -1290,6 +1428,41 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 提示词重绘对话框 */}
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
提示词重绘
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
</p>
<div className="space-y-2">
<Label htmlFor={`prompt-regenerate-${node.id}`}>新提示词</Label>
<Textarea
id={`prompt-regenerate-${node.id}`}
value={regeneratePrompt}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
rows={6}
className="resize-y"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPromptDialogOpen(false)}>取消</Button>
<Button onClick={handlePromptRegenerate} disabled={isProcessing || !regeneratePrompt.trim()}>
{isProcessing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-2" />}
开始重绘
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{isSelected && data.src && typeof document !== 'undefined' && createPortal( {isSelected && data.src && typeof document !== 'undefined' && createPortal(
@ -1428,6 +1601,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent> <TooltipContent side="bottom" className="text-xs">编辑文字</TooltipContent>
</Tooltip> </Tooltip>
{/* 提示词重绘 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => handleAction('prompt-regenerate')}
>
<RefreshCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">提示词重绘</TooltipContent>
</Tooltip>
{/* 角度编辑 */} {/* 角度编辑 */}
<Tooltip> <Tooltip>