Files
20260512-skg-tk/web/canvas-app/src/components/nodes/ImageNode.vue

993 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- Image node wrapper for hover area | 图片节点包裹层扩展悬浮区域 -->
<div class="image-node-wrapper" @mouseenter="showActions = true; showHandleMenu = true" @mouseleave="showActions = false; showHandleMenu = false">
<!-- Image node | 图片节点 -->
<div
class="image-node bg-[var(--bg-secondary)] rounded-xl border min-w-[200px] max-w-[280px] relative transition-all duration-200"
:class="data.selected ? 'border-1 border-blue-500 shadow-lg shadow-blue-500/20' : 'border border-[var(--border-color)]'">
<!-- Header | 头部 -->
<div class="px-3 py-2 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
v-if="!isEditingLabel"
@dblclick="startEditLabel"
class="text-sm font-medium text-[var(--text-primary)] cursor-text hover:bg-[var(--bg-tertiary)] px-1 rounded transition-colors"
title="双击编辑名称"
>{{ data.label || '图像生成结果' }}</span>
<input
v-else
ref="labelInputRef"
v-model="editingLabelValue"
@blur="finishEditLabel"
@keydown.enter="finishEditLabel"
@keydown.escape="cancelEditLabel"
class="text-sm font-medium bg-[var(--bg-tertiary)] text-[var(--text-primary)] px-1 rounded outline-none border border-blue-500"
/>
<!-- Public switch | 公开开关 -->
<n-tooltip trigger="hover">
<template #trigger>
<button
class="flex items-center"
title="设置公开(可被 @ 引用)"
>
<n-switch
:value="isPublic"
@update:value="handleTogglePublic"
size="small"
/>
</button>
</template>
{{ isPublic ? '已公开: ' + (data.label || '图片') : '点击公开(可被 @ 引用)' }}
</n-tooltip>
</div>
<div class="flex items-center gap-1">
<!-- Replace button | 替换按钮 -->
<n-tooltip trigger="hover">
<template #trigger>
<button @click="showReplaceModal = true" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<SwapHorizontalOutline />
</n-icon>
</button>
</template>
替换图片
</n-tooltip>
<n-tooltip v-if="data.url" trigger="hover">
<template #trigger>
<button @click="handlePreview" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<EyeOutline />
</n-icon>
</button>
</template>
预览
</n-tooltip>
<n-tooltip v-if="data.url" trigger="hover">
<template #trigger>
<button @click="handleDownload" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<DownloadOutline />
</n-icon>
</button>
</template>
下载
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<button @click="handleDuplicate" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<CopyOutline />
</n-icon>
</button>
</template>
复制节点
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<button @click="handleDelete" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14">
<TrashOutline />
</n-icon>
</button>
</template>
删除节点
</n-tooltip>
</div>
</div>
<!-- Model name | 模型名称 -->
<div v-if="data.model" class="mt-1 text-xs text-[var(--text-secondary)] truncate">
{{ data.model }}
</div>
</div>
<!-- Image preview area | 图片预览区域 -->
<div class="p-3">
<!-- Loading state | 加载状态 -->
<div v-if="data.loading"
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
<!-- Animated gradient overlay | 动画渐变遮罩 -->
<div
class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse">
</div>
<!-- Loading image | 加载图片 -->
<div class="relative z-10">
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
</div>
<span class="text-sm text-white font-medium relative z-10">创作中</span>
</div>
<!-- Error state | 错误状态 -->
<div v-else-if="data.error"
class="aspect-square rounded-xl bg-red-50 dark:bg-red-900/20 flex flex-col items-center justify-center gap-2 border border-red-200 dark:border-red-800">
<n-icon :size="32" class="text-red-500">
<CloseCircleOutline />
</n-icon>
<span class="text-sm text-red-600 dark:text-red-400 text-center px-2">{{ data.error }}</span>
</div>
<!-- Image display | 图片显示 -->
<div
v-else-if="data.url"
class="rounded-xl overflow-hidden relative"
ref="imageContainerRef"
>
<img
:src="data.url"
:alt="data.label"
class="w-full h-auto object-cover"
:class="{ 'pointer-events-none': isInpaintMode }"
/>
<!-- Inpaint canvas with events | 涂抹画布带事件 -->
<canvas
v-if="isInpaintMode"
ref="canvasRef"
class="absolute inset-0 w-full h-full cursor-none z-10"
@mousedown.stop.prevent="onCanvasPaint"
@mousemove.stop="onCanvasMove"
@mouseup.stop="onPaintEnd"
@mouseleave="onPaintEnd"
/>
<!-- Brush cursor | 画笔光标 -->
<div
v-show="brushCursor.visible && isInpaintMode"
class="absolute pointer-events-none border-2 border-purple-500 rounded-full bg-purple-400/30 transition-none"
:style="{
width: brushSize * 2 + 'px',
height: brushSize * 2 + 'px',
left: brushCursor.x - brushSize + 'px',
top: brushCursor.y - brushSize + 'px'
}"
/>
<!-- Inpaint toolbar | 涂抹工具栏 -->
<div
v-show="isInpaintMode"
class="absolute top-1.5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm rounded-full shadow-md border border-gray-200/80 dark:border-gray-700 z-[9999]"
@mousedown.stop
@click.stop
>
<!-- Mode indicator | 模式指示 -->
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 pr-1.5 border-r border-gray-200 dark:border-gray-600">
<n-icon :size="12"><BrushOutline /></n-icon>
<span>擦除</span>
</div>
<!-- Size slider | 大小滑块 -->
<div class="flex items-center gap-1 w-16">
<div class="w-1.5 h-1.5 rounded-full bg-purple-400"></div>
<input
type="range"
v-model="brushSize"
min="10"
max="80"
class="w-full h-0.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-purple"
/>
<div class="w-2.5 h-2.5 rounded-full bg-purple-400"></div>
</div>
<!-- Reset button | 重置按钮 -->
<button
@click="clearMask"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="清除"
>
<n-icon :size="12" class="text-gray-400"><RefreshOutline /></n-icon>
</button>
<!-- Apply button | 应用按钮 -->
<button
@click="applyInpaint"
class="px-2 py-0.5 bg-purple-500 hover:bg-purple-600 text-white text-xs rounded transition-colors"
>
应用
</button>
</div>
</div>
<!-- URL Loading state | URL 加载状态 -->
<div v-else-if="urlLoading"
class="aspect-square rounded-xl bg-gradient-to-br from-cyan-400 via-blue-300 to-amber-200 flex flex-col items-center justify-center gap-3 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-cyan-500/20 via-blue-400/20 to-amber-300/20 animate-pulse"></div>
<div class="relative z-10">
<img src="../../assets/loading.webp" alt="Loading" class="w-14 h-12" />
</div>
<span class="text-sm text-white font-medium relative z-10">加载中...</span>
</div>
<!-- Upload placeholder | 上传占位 -->
<div v-else class="rounded-xl bg-[var(--bg-tertiary)] border-2 border-dashed border-[var(--border-color)] p-3">
<!-- Upload area | 上传区域 -->
<div class="aspect-video flex flex-col items-center justify-center gap-2 relative cursor-pointer hover:bg-[var(--bg-secondary)] rounded-lg transition-colors">
<n-icon :size="32" class="text-[var(--text-secondary)]">
<ImageOutline />
</n-icon>
<span class="text-sm text-[var(--text-secondary)] text-center">拖放图片或点击上传</span>
<input type="file" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer"
@change="handleFileUpload" />
</div>
<!-- Divider | 分割线 -->
<div class="flex items-center gap-2 my-3">
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
<span class="text-xs text-[var(--text-secondary)]"></span>
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
</div>
<!-- URL input | URL 输入 -->
<div class="flex gap-2">
<input
v-model="urlInput"
type="text"
placeholder="输入图片地址..."
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
@keydown.enter="handleUrlSubmit"
/>
<button
@click="handleUrlSubmit"
:disabled="!urlInput.trim()"
class="px-3 py-2 text-xs bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
预览
</button>
</div>
</div>
</div>
<!-- Handles | 连接点 -->
<NodeHandleMenu :nodeId="id" nodeType="image" :visible="showHandleMenu" :operations="operations" @select="handleSelect" />
<Handle type="target" :position="Position.Left" id="left" class="!bg-[var(--accent-color)]" />
</div>
</div>
<!-- Image preview dialog | 图片预览弹窗 -->
<n-image-preview
v-model:show="showRef"
:src="props.data?.url"
/>
<!-- Replace image modal | 替换图片弹窗 -->
<n-modal v-model:show="showReplaceModal" preset="card" title="替换图片" class="w-[400px]" :mask-closable="true">
<div class="space-y-4">
<!-- Upload area | 上传区域 -->
<div
class="border-2 border-dashed border-[var(--border-color)] rounded-xl p-4 cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
@click="replaceFileInputRef?.click()"
>
<div class="flex flex-col items-center gap-2">
<n-icon :size="32" class="text-[var(--text-secondary)]">
<ImageOutline />
</n-icon>
<span class="text-sm text-[var(--text-secondary)]">点击上传图片</span>
<input
ref="replaceFileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleReplaceFileUpload"
/>
</div>
</div>
<!-- Divider | 分割线 -->
<div class="flex items-center gap-2">
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
<span class="text-xs text-[var(--text-secondary)]"></span>
<div class="flex-1 h-px bg-[var(--border-color)]"></div>
</div>
<!-- URL input | URL 输入 -->
<div class="flex gap-2">
<input
v-model="replaceUrlInput"
type="text"
placeholder="输入图片地址..."
class="flex-1 px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg outline-none focus:border-[var(--accent-color)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
@keydown.enter="handleReplaceUrlSubmit"
/>
<n-button type="primary" size="small" :disabled="!replaceUrlInput.trim()" @click="handleReplaceUrlSubmit">
确认
</n-button>
</div>
</div>
</n-modal>
</template>
<script setup>
/**
* Image node component | 图片节点组件
* Displays and manages image content with loading state
*/
import { ref, nextTick, computed } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
id: String,
data: Object
})
// Vue Flow instance | Vue Flow 实例
const { updateNodeInternals } = useVueFlow()
// Hover state | 悬浮状态
const showActions = ref(true)
const showHandleMenu = ref(false)
// Label editing state | Label 编辑状态
const isEditingLabel = ref(false)
const editingLabelValue = ref('')
const labelInputRef = ref(null)
// URL input state | URL 输入状态
const urlInput = ref('')
const urlLoading = ref(false)
// Replace modal state | 替换弹窗状态
const showReplaceModal = ref(false)
const replaceUrlInput = ref('')
const replaceFileInputRef = ref(null)
// Inpainting state | 涂抹重绘状态
const isInpaintMode = ref(false)
const brushSize = ref(40)
const isDrawing = ref(false)
const canvasRef = ref(null)
const imageContainerRef = ref(null)
const interactionLayerRef = ref(null)
const brushCursor = ref({ x: 0, y: 0, visible: false })
const maskData = ref(null)
// Computed public props status | 计算是否公开
const isPublic = computed(() => {
return props.data?.publicProps?.name != null && props.data?.publicProps?.name !== ''
})
// Handle toggle public | 处理切换公开状态
const handleTogglePublic = (value) => {
if (value) {
// 公开:使用节点名称
const name = props.data?.label || '图片'
updateNode(props.id, {
publicProps: { name }
})
} else {
// 取消公开
updateNode(props.id, {
publicProps: {}
})
}
}
// Image node menu operations | 图片节点菜单操作
const operations = [
{ type: 'imageConfig', label: '图生图', icon: ImageOutline, action: 'image_imageConfig' },
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline, action: 'image_videoConfig' }
]
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const action = item.action
if (action === 'image_imageConfig') {
// Image-to-image workflow | 图生图工作流
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
const sourceUrl = currentNode?.data?.url
if (!sourceUrl) {
window.$message?.warning('当前图片节点没有图片')
return
}
// Create text node for prompt
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create imageConfig node
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '生图配置'
})
// Connect edges
addEdge({ source: props.id, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
addEdge({ source: textNodeId, target: configNodeId, sourceHandle: 'right', targetHandle: 'left' })
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
window.$message?.success('已创建图生图工作流')
} else if (action === 'image_videoConfig') {
// Video generation workflow | 视频生成工作流
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create videoConfig node
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
label: '视频生成'
})
// Connect image to videoConfig
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' }
})
// Connect text to videoConfig
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
setTimeout(() => updateNodeInternals([textNodeId, configNodeId]), 50)
window.$message?.success('已创建视频生成工作流')
}
}
// Toggle inpaint mode | 切换涂抹模式
const toggleInpaintMode = () => {
isInpaintMode.value = !isInpaintMode.value
if (isInpaintMode.value) {
nextTick(() => initCanvas())
} else {
clearMask()
}
}
// Initialize canvas | 初始化画布
const initCanvas = () => {
setTimeout(() => {
const canvas = canvasRef.value
if (!canvas) return
// Set canvas internal size to match its CSS rendered size | 设置画布内部尺寸匹配 CSS 渲染尺寸
// clientWidth/clientHeight give the CSS box size
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
}, 100)
}
// Ensure canvas size matches display | 确保画布尺寸匹配显示
const syncCanvasSize = () => {
const canvas = canvasRef.value
if (!canvas) return
if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) {
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
}
}
// Canvas paint handlers | 画布绘制处理器
const onCanvasPaint = (e) => {
syncCanvasSize()
isDrawing.value = true
paintAt(e.offsetX, e.offsetY)
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
}
const onCanvasMove = (e) => {
brushCursor.value = { x: e.offsetX, y: e.offsetY, visible: true }
if (isDrawing.value) {
paintAt(e.offsetX, e.offsetY)
}
}
const onPaintEnd = () => {
isDrawing.value = false
brushCursor.value.visible = false
}
// Paint at coordinates | 在坐标绘制
const paintAt = (x, y) => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.beginPath()
ctx.arc(x, y, brushSize.value, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(139, 92, 246, 0.5)'
ctx.fill()
}
// Hide brush cursor | 隐藏画笔光标
const hideBrushCursor = () => {
brushCursor.value.visible = false
}
// Clear mask | 清除蒙版
const clearMask = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
maskData.value = null
}
// Apply inpaint and create workflow | 应用重绘并创建工作流
const applyInpaint = () => {
const canvas = canvasRef.value
if (!canvas || canvas.width === 0 || canvas.height === 0) {
window.$message?.error('画布未初始化')
return
}
// Get the original image and resize mask to match | 获取原图并调整蒙版大小匹配
const container = imageContainerRef.value
const img = container?.querySelector('img')
if (!img) {
window.$message?.error('未找到图片')
return
}
// Create mask at original image resolution | 创建原图分辨率的蒙版
const maskCanvas = document.createElement('canvas')
const imgWidth = img.naturalWidth || img.width
const imgHeight = img.naturalHeight || img.height
maskCanvas.width = imgWidth
maskCanvas.height = imgHeight
const maskCtx = maskCanvas.getContext('2d')
// Fill black background | 填充黑色背景
maskCtx.fillStyle = '#000000'
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height)
// Scale factor from display to original | 从显示尺寸到原图的缩放因子
const scaleX = imgWidth / canvas.width
const scaleY = imgHeight / canvas.height
// Get painted areas and scale to original resolution | 获取绑制区域并缩放到原图分辨率
const originalData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
// Draw scaled white areas on mask | 在蒙版上绘制缩放后的白色区域
maskCtx.fillStyle = '#FFFFFF'
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const i = (y * canvas.width + x) * 4
if (originalData.data[i + 3] > 0) {
// Scale and draw | 缩放并绘制
maskCtx.fillRect(
Math.floor(x * scaleX),
Math.floor(y * scaleY),
Math.ceil(scaleX),
Math.ceil(scaleY)
)
}
}
}
// Convert to base64 (remove data URL prefix for API) | 转换为 base64移除前缀用于 API
const dataUrl = maskCanvas.toDataURL('image/png')
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '')
maskData.value = base64Data
// Create inpaint workflow | 创建重绘工作流
createInpaintWorkflow()
}
// Create inpaint workflow | 创建重绘工作流
const createInpaintWorkflow = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '请输入重绘提示词...',
label: '重绘提示词'
})
// Create imageConfig node for inpainting | 创建图生图配置节点
const configNodeId = addNode('imageConfig', { x: nodeX + 600, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '局部重绘',
inpaintMode: true
})
// Update current node with mask data | 更新当前节点的蒙版数据
updateNode(props.id, {
maskData: maskData.value,
hasInpaintMask: true
})
// Connect image node to config node | 连接图片节点到配置节点
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Exit inpaint mode | 退出涂抹模式
isInpaintMode.value = false
// Force Vue Flow to recalculate | 强制重新计算
setTimeout(() => {
updateNodeInternals([textNodeId, configNodeId])
}, 50)
window.$message?.success('已创建局部重绘工作流')
}
// Convert file to base64 | 将文件转换为 base64
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// Handle file upload | 处理文件上传
const handleFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
// Convert to base64 | 转换为 base64
const base64 = await fileToBase64(file)
// Store both display URL and base64 | 同时存储显示 URL 和 base64
updateNode(props.id, {
url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL
base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用
fileName: file.name,
fileType: file.type,
label: '参考图',
updatedAt: Date.now()
})
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
}
}
}
// Handle URL submit | 处理 URL 提交
const handleUrlSubmit = () => {
const url = urlInput.value.trim()
if (!url) return
// Validate URL format | 验证 URL 格式
if (!url.startsWith('http://') && !url.startsWith('https://')) {
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
return
}
// Show loading state | 显示加载状态
urlLoading.value = true
// Preload image to check validity | 预加载图片检查有效性
const img = new Image()
img.onload = () => {
// Update node with URL | 更新节点 URL
updateNode(props.id, {
url: url,
label: '网络图片',
updatedAt: Date.now()
})
urlInput.value = ''
urlLoading.value = false
}
img.onerror = () => {
window.$message?.error('图片加载失败,请检查地址是否正确')
urlLoading.value = false
}
img.src = url
}
// Handle replace file upload | 处理替换文件上传
const handleReplaceFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
const base64 = await fileToBase64(file)
updateNode(props.id, {
url: base64,
base64: base64,
fileName: file.name,
fileType: file.type,
label: '参考图',
updatedAt: Date.now()
})
showReplaceModal.value = false
replaceUrlInput.value = ''
window.$message?.success('图片已替换')
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
}
}
}
// Handle replace URL submit | 处理替换 URL 提交
const handleReplaceUrlSubmit = () => {
const url = replaceUrlInput.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
window.$message?.warning('请输入有效的图片地址 (http:// 或 https://)')
return
}
const img = new Image()
img.onload = () => {
updateNode(props.id, {
url: url,
label: '网络图片',
updatedAt: Date.now()
})
showReplaceModal.value = false
replaceUrlInput.value = ''
window.$message?.success('图片已替换')
}
img.onerror = () => {
window.$message?.error('图片加载失败,请检查地址是否正确')
}
img.src = url
}
// Start editing label | 开始编辑 label
const startEditLabel = () => {
editingLabelValue.value = props.data?.label || '图像生成结果'
isEditingLabel.value = true
nextTick(() => {
labelInputRef.value?.focus()
labelInputRef.value?.select()
})
}
// Finish editing label | 完成编辑 label
const finishEditLabel = () => {
const newLabel = editingLabelValue.value.trim()
if (newLabel && newLabel !== props.data?.label) {
updateNode(props.id, { label: newLabel })
}
isEditingLabel.value = false
}
// Cancel editing label | 取消编辑 label
const cancelEditLabel = () => {
isEditingLabel.value = false
}
// Handle delete | 处理删除
const handleDelete = () => {
removeNode(props.id)
}
// Handle duplicate | 处理复制
const handleDuplicate = () => {
const newId = duplicateNode(props.id)
if (newId) {
// Clear selection and select the new node | 清除选中并选中新节点
updateNode(props.id, { selected: false })
updateNode(newId, { selected: true })
window.$message?.success('节点已复制')
setTimeout(() => {
updateNodeInternals(newId)
}, 50)
}
}
// Handle image generation | 处理图片生图(图生图)
const handleImageGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create ImageNode for editing | 创建图片编辑节点
const imageNodeId = addNode('image', { x: nodeX + 600, y: nodeY }, {
url: props.data.url, // Pass the current image as input
label: '图生图',
refImage: props.data.url // Mark as reference image
})
// Create imageConfig node for generation | 创建生图配置节点
const configNodeId = addNode('imageConfig', { x: nodeX + 900, y: nodeY }, {
model: 'auto',
size: '1024x1536',
label: '生图配置'
})
// Connect image node to new image node | 连接当前图片节点到新图片节点
addEdge({
source: props.id,
target: imageNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect new image node to config node | 连接新图片节点到配置节点
addEdge({
source: imageNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals([textNodeId, imageNodeId, configNodeId])
}, 50)
window.$message?.success('已创建图生图工作流')
}
// Preview state | 预览状态
const showRef = ref(false)
// Handle preview | 处理预览
const handlePreview = () => {
if (props.data.url) {
showRef.value = true
}
}
// Handle download | 处理下载
const handleDownload = () => {
if (props.data.url) {
const link = document.createElement('a')
link.href = props.data.url
link.download = props.data.fileName || `image_${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.$message?.success('图片下载中...')
}
}
// Handle video generation | 处理视频生成
const handleVideoGen = () => {
const currentNode = nodes.value.find(n => n.id === props.id)
const nodeX = currentNode?.position?.x || 0
const nodeY = currentNode?.position?.y || 0
// Create text node for prompt | 创建文本节点用于提示词
const textNodeId = addNode('text', { x: nodeX + 300, y: nodeY - 100 }, {
content: '',
label: '提示词'
})
// Create videoConfig node | 创建视频配置节点
const configNodeId = addNode('videoConfig', { x: nodeX + 600, y: nodeY }, {
label: '视频生成'
})
// Connect image node to config node with role | 连接图片节点到配置节点并设置角色
addEdge({
source: props.id,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
// Connect text node to config node | 连接文本节点到配置节点
addEdge({
source: textNodeId,
target: configNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals([textNodeId, configNodeId])
}, 50)
}
</script>
<style scoped>
.image-node-wrapper {
position: relative;
padding-right: 50px;
padding-top: 20px;
}
.image-node {
cursor: default;
position: relative;
}
/* Slider styling | 滑块样式 */
.slider-purple::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider-purple::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Inpaint mode cursor | 涂抹模式光标 */
.cursor-none {
cursor: none;
}
</style>