Files
20260512-skg-tk/web/canvas-app/src/views/Canvas.vue
2026-05-26 10:08:22 +08:00

879 lines
31 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>
<!-- Canvas page | 画布页面 -->
<div class="h-screen w-screen flex flex-col bg-[var(--bg-primary)]">
<!-- Header | 顶部导航 -->
<AppHeader class="bg-[var(--bg-secondary)]">
<template #left>
<button
@click="goBack"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
>
<n-icon :size="20"><ChevronBackOutline /></n-icon>
</button>
<n-dropdown :options="projectOptions" @select="handleProjectAction">
<button class="flex items-center gap-1 hover:bg-[var(--bg-tertiary)] px-2 py-1 rounded-lg transition-colors">
<span class="font-medium">{{ projectName }}</span>
<n-icon :size="16"><ChevronDownOutline /></n-icon>
</button>
</n-dropdown>
</template>
<template #right>
<button
@click="showDownloadModal = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }"
title="批量下载素材"
>
<n-icon :size="20"><DownloadOutline /></n-icon>
</button>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template>
</AppHeader>
<!-- Main canvas area | 主画布区域 -->
<div class="flex-1 relative overflow-hidden">
<!-- Vue Flow canvas | Vue Flow 画布 -->
<VueFlow
:key="flowKey"
v-model:nodes="nodes"
v-model:edges="edges"
v-model:viewport="viewport"
:node-types="nodeTypes"
:edge-types="edgeTypes"
:default-viewport="canvasViewport"
:min-zoom="0.1"
:max-zoom="2"
:snap-to-grid="true"
:snap-grid="[20, 20]"
@connect="onConnect"
@node-click="onNodeClick"
@pane-click="onPaneClick"
@viewport-change="handleViewportChange"
@edges-change="onEdgesChange"
class="canvas-flow"
>
<Background v-if="showGrid" :gap="20" :size="1" />
<MiniMap
v-if="!isMobile"
position="bottom-right"
:pannable="true"
:zoomable="true"
/>
</VueFlow>
<!-- Left toolbar | 左侧工具栏 -->
<aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10">
<button
@click="showNodeMenu = !showNodeMenu"
class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors"
title="添加节点"
>
<n-icon :size="20"><AddOutline /></n-icon>
</button>
<button
@click="showWorkflowPanel = true"
class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors"
title="工作流模板"
>
<n-icon :size="20"><AppsOutline /></n-icon>
</button>
<div class="w-full h-px bg-[var(--border-color)] my-1"></div>
<button
v-for="tool in tools"
:key="tool.id"
@click="tool.action"
:disabled="tool.disabled && tool.disabled()"
class="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
:title="tool.name"
>
<n-icon :size="20"><component :is="tool.icon" /></n-icon>
</button>
</aside>
<!-- Node menu popup | 节点菜单弹窗 -->
<div
v-if="showNodeMenu"
class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20"
>
<button
v-for="nodeType in nodeTypeOptions"
:key="nodeType.type"
@click="addNewNode(nodeType.type)"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left"
>
<n-icon :size="20" :color="nodeType.color"><component :is="nodeType.icon" /></n-icon>
<span class="text-sm">{{ nodeType.name }}</span>
</button>
</div>
<!-- Bottom controls | 底部控制 -->
<div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1">
<!-- <button
@click="showGrid = !showGrid"
:class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'"
class="p-2 rounded transition-colors"
title="切换网格"
>
<n-icon :size="16"><GridOutline /></n-icon>
</button> -->
<button
@click="fitView({ padding: 0.2 })"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors"
title="适应视图"
>
<n-icon :size="16"><LocateOutline /></n-icon>
</button>
<div class="flex items-center gap-1 px-2">
<button @click="zoomOut" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14"><RemoveOutline /></n-icon>
</button>
<span class="text-xs min-w-[40px] text-center">{{ Math.round(viewport.zoom * 100) }}%</span>
<button @click="zoomIn" class="p-1 hover:bg-[var(--bg-tertiary)] rounded transition-colors">
<n-icon :size="14"><AddOutline /></n-icon>
</button>
</div>
</div>
<!-- Bottom input panel (floating) | 底部输入面板悬浮 -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20">
<!-- Processing indicator | 处理中指示器 -->
<div
v-if="isProcessing"
class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse"
>
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
<n-spin :size="14" />
<span>正在生成提示词...</span>
</div>
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{{ currentResponse }}
</div>
</div>
<div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3">
<textarea
v-model="chatInput"
:placeholder="inputPlaceholder"
:disabled="isProcessing"
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[40px] max-h-[120px] disabled:opacity-50"
rows="1"
@keydown.enter.exact="handleEnterKey"
@keydown.enter.ctrl="sendMessage"
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<button
@click="handlePolish"
:disabled="isProcessing || !chatInput.trim()"
class="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="AI 润色提示词"
>
AI 润色
</button>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<n-switch v-model:value="autoExecute" size="small" />
自动执行
</label>
<button
@click="sendMessage"
:disabled="isProcessing"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<n-spin v-if="isProcessing" :size="16" />
<n-icon v-else :size="20" color="white"><SendOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
<span class="text-xs text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="chatInput = tag"
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="14"><RefreshOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" />
<!-- Rename Modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
<template #action>
<n-button @click="showRenameModal = false">取消</n-button>
<n-button type="primary" @click="confirmRename">确定</n-button>
</template>
</n-modal>
<!-- Delete Confirm Modal | 删除确认弹窗 -->
<n-modal v-model:show="showDeleteModal" preset="dialog" title="删除项目" type="warning">
<p>确定要删除项目{{ projectName }}此操作不可恢复</p>
<template #action>
<n-button @click="showDeleteModal = false">取消</n-button>
<n-button type="error" @click="confirmDelete">删除</n-button>
</template>
</n-modal>
<!-- Download Modal | 下载弹窗 -->
<DownloadModal v-model:show="showDownloadModal" />
<!-- Workflow Panel | 工作流面板 -->
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" />
</div>
</template>
<script setup>
/**
* Canvas view component | 画布视图组件
* Main infinite canvas with Vue Flow integration
*/
import { ref, computed, onMounted, onUnmounted, watch, nextTick, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { MiniMap } from '@vue-flow/minimap'
import { NIcon, NSwitch, NDropdown, NMessageProvider, NSpin, NModal, NInput, NButton } from 'naive-ui'
import {
ChevronBackOutline,
ChevronDownOutline,
SettingsOutline,
AddOutline,
ImageOutline,
SendOutline,
RefreshOutline,
TextOutline,
VideocamOutline,
ColorPaletteOutline,
BookmarkOutline,
ArrowUndoOutline,
ArrowRedoOutline,
GridOutline,
LocateOutline,
RemoveOutline,
DownloadOutline,
AppsOutline,
ChatbubbleOutline
} from '@vicons/ionicons5'
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
import DownloadModal from '../components/DownloadModal.vue'
import WorkflowPanel from '../components/WorkflowPanel.vue'
import AppHeader from '../components/AppHeader.vue'
// API Config state | API 配置状态
const modelStore = useModelStore()
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Initialize models on page load | 页面加载时初始化模型
onMounted(() => {
loadAllModels()
})
// Chat templates | 问答模板
const CHAT_TEMPLATES = {
imagePrompt: {
name: '生图提示词',
systemPrompt: '你是一个专业的 AI 绘画提示词编辑。只优化用户已经给出的主体、风格、光线、构图和细节,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini'
},
videoPrompt: {
name: '视频提示词',
systemPrompt: '你是一个专业的 AI 视频提示词编辑。只优化用户已经给出的主体、动作、场景、镜头和节奏,不添加用户没有提到的品牌、产品或营销话术。直接返回提示词,不要其他解释。',
model: 'gpt-4o-mini'
}
}
// Current template | 当前模板
const currentTemplate = ref('imagePrompt')
// Chat hook with image prompt template | 问答 hook
const {
loading: chatLoading,
status: chatStatus,
currentResponse,
send: sendChat
} = useChat({
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
model: CHAT_TEMPLATES.imagePrompt.model,
mode: 'image',
targetLanguage: 'en'
})
// Workflow orchestrator hook | 工作流编排 hook
const {
isAnalyzing: workflowAnalyzing,
isExecuting: workflowExecuting,
currentStep: workflowStep,
totalSteps: workflowTotalSteps,
executionLog: workflowLog,
analyzeIntent,
executeWorkflow,
createTextToImageWorkflow,
createMultiAngleStoryboard,
WORKFLOW_TYPES
} = useWorkflowOrchestrator()
// Custom node components | 自定义节点组件
import TextNode from '../components/nodes/TextNode.vue'
import ImageConfigNode from '../components/nodes/ImageConfigNode.vue'
import VideoNode from '../components/nodes/VideoNode.vue'
import ImageNode from '../components/nodes/ImageNode.vue'
import VideoConfigNode from '../components/nodes/VideoConfigNode.vue'
import LLMConfigNode from '../components/nodes/LLMConfigNode.vue'
import ImageRoleEdge from '../components/edges/ImageRoleEdge.vue'
import PromptOrderEdge from '../components/edges/PromptOrderEdge.vue'
import ImageOrderEdge from '../components/edges/ImageOrderEdge.vue'
const router = useRouter()
const route = useRoute()
// Vue Flow instance | Vue Flow 实例
const { viewport, zoomIn, zoomOut, fitView, updateNodeInternals } = useVueFlow()
// Register custom node types | 注册自定义节点类型
const nodeTypes = {
text: markRaw(TextNode),
imageConfig: markRaw(ImageConfigNode),
video: markRaw(VideoNode),
image: markRaw(ImageNode),
videoConfig: markRaw(VideoConfigNode),
llmConfig: markRaw(LLMConfigNode)
}
// Register custom edge types | 注册自定义边类型
const edgeTypes = {
imageRole: markRaw(ImageRoleEdge),
promptOrder: markRaw(PromptOrderEdge),
imageOrder: markRaw(ImageOrderEdge)
}
// UI state | UI状态
const showNodeMenu = ref(false)
const chatInput = ref('')
const autoExecute = ref(false)
const isMobile = ref(false)
const showGrid = ref(true)
const showApiSettings = ref(false)
const isProcessing = ref(false)
// Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key
const flowKey = ref(Date.now())
// Modal state | 弹窗状态
const showRenameModal = ref(false)
const showDeleteModal = ref(false)
const showDownloadModal = ref(false)
const showWorkflowPanel = ref(false)
const renameValue = ref('')
// Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => {
return nodes.value.some(n =>
(n.type === 'image' || n.type === 'video') && n.data?.url
)
})
// Project info | 项目信息
const projectName = computed(() => {
const project = projects.value.find(p => p.id === route.params.id)
return project?.name || '未命名项目'
})
// Project dropdown options | 项目下拉选项
const projectOptions = [
{ label: '重命名', key: 'rename' },
{ label: '复制', key: 'duplicate' },
{ label: '删除', key: 'delete' }
]
// Toolbar tools | 工具栏工具
const tools = [
{ id: 'text', name: '文本', icon: TextOutline, action: () => addNewNode('text') },
{ id: 'image', name: '图片', icon: ImageOutline, action: () => addNewNode('image') },
{ id: 'imageConfig', name: '文生图', icon: ColorPaletteOutline, action: () => addNewNode('imageConfig') },
{ id: 'videoConfig', name: '视频生成', icon: VideocamOutline, action: () => addNewNode('videoConfig') },
{ id: 'undo', name: '撤销', icon: ArrowUndoOutline, action: () => undo(), disabled: () => !canUndo() },
{ id: 'redo', name: '重做', icon: ArrowRedoOutline, action: () => redo(), disabled: () => !canRedo() }
]
// Node type options for menu | 节点类型菜单选项
const nodeTypeOptions = [
{ type: 'text', name: '文本节点', icon: TextOutline, color: '#3b82f6' },
{ type: 'llmConfig', name: 'LLM文本生成', icon: ChatbubbleOutline, color: '#a855f7' },
{ type: 'imageConfig', name: '文生图配置', icon: ColorPaletteOutline, color: '#22c55e' },
{ type: 'videoConfig', name: '视频生成配置', icon: VideocamOutline, color: '#f59e0b' },
{ type: 'image', name: '图片节点', icon: ImageOutline, color: '#8b5cf6' },
{ type: 'video', name: '视频节点', icon: VideocamOutline, color: '#ef4444' }
]
// Input placeholder | 输入占位符
const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
// Quick suggestions | 快捷建议
const suggestions = [
'像个魔法森林',
'三只不同的小猫',
'生成多角度分镜',
'夏日田野环绕漫步'
]
// Add new node | 添加新节点
const addNewNode = async (type) => {
// Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
// Add node at viewport center | 在视口中心添加节点
const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 })
// Set highest z-index | 设置最高层级
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
updateNode(nodeId, { zIndex: maxZIndex + 1 })
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(nodeId)
}, 50)
showNodeMenu.value = false
}
// Handle add workflow from panel | 处理从面板添加工作流
const handleAddWorkflow = ({ workflow, options }) => {
// Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
// Create nodes from workflow template | 从工作流模板创建节点
const startPosition = { x: viewportCenterX - 300, y: viewportCenterY - 200 }
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
// Start batch operation manually | 手动开始批量操作
startBatchOperation()
// Add nodes to canvas in batch | 批量将节点添加到画布
const nodeSpecs = newNodes.map(node => ({
type: node.type,
position: node.position,
data: node.data
}))
const nodeIds = addNodes(nodeSpecs, false)
// Map old node IDs to new IDs | 映射旧节点ID到新ID
const idMap = {}
newNodes.forEach((node, index) => {
idMap[node.id] = nodeIds[index]
})
// Add edges to canvas in batch | 批量将边添加到画布
const edgeSpecs = newEdges.map(edge => ({
source: idMap[edge.source] || edge.source,
target: idMap[edge.target] || edge.target,
sourceHandle: edge.sourceHandle || 'right',
targetHandle: edge.targetHandle || 'left',
type: edge.type,
data: edge.data
}))
// Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量)
addEdges(edgeSpecs, false)
// End batch operation and save to history | 结束批量操作并保存到历史
endBatchOperation()
// Delay node internals update | 延迟节点内部更新
setTimeout(() => {
// Update node internals | 更新节点内部
nodeIds.forEach(nodeId => {
updateNodeInternals(nodeId)
})
}, 100)
window.$message?.success(`已添加工作流: ${workflow.name}`)
}
// Handle connection | 处理连接
const onConnect = (params) => {
// Check connection types | 检查连接类型
const sourceNode = nodes.value.find(n => n.id === params.source)
const targetNode = nodes.value.find(n => n.id === params.target)
if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') {
// Use imageRole edge type | 使用图片角色边类型
addEdge({
...params,
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
// Use promptOrder edge type | 使用提示词顺序边类型
// Calculate next order number | 计算下一个顺序号
const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder'
)
const nextOrder = existingTextEdges.length + 1
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: nextOrder }
})
} else if (sourceNode?.type === 'image' && targetNode?.type === 'imageConfig') {
// Use imageOrder edge type | 使用图片顺序边类型
// Calculate next order number | 计算下一个顺序号
const existingImageEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'imageOrder'
)
// Get @ mentioned image count from connected TextNodes | 获取已连接 TextNode 中 @ 提及的图片数量
let mentionedImageCount = 0
const connectedTextEdges = edges.value.filter(e => e.target === params.target)
for (const edge of connectedTextEdges) {
const sourceNode = nodes.value.find(n => n.id === edge.source)
if (sourceNode?.type === 'text') {
const content = sourceNode.data?.content || ''
// Count @ mentions of image nodes | 统计图片节点的 @ 提及
const mentionRegex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g
let match
while ((match = mentionRegex.exec(content)) !== null) {
const mentionedNode = nodes.value.find(n => n.id === match[1])
if (mentionedNode?.type === 'image') {
mentionedImageCount++
}
}
}
}
// Next order = existing edges + mentioned image count + 1 | 下一个序号 = 现有边数 + @提及图片数 + 1
const nextOrder = existingImageEdges.length + mentionedImageCount + 1
addEdge({
...params,
type: 'imageOrder',
data: { imageOrder: nextOrder }
})
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'imageConfig') {
// LLM output as prompt for image generation | LLM 输出作为图片生成提示词
const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder'
)
const nextOrder = existingTextEdges.length + 1
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: nextOrder }
})
} else if (sourceNode?.type === 'llmConfig' && targetNode?.type === 'videoConfig') {
// LLM output as prompt for video generation | LLM 输出作为视频生成提示词
addEdge({
...params,
type: 'promptOrder',
data: { promptOrder: 1 }
})
} else {
addEdge(params)
}
}
const onNodeClick = (event) => {
// nodes.value.forEach(node => {
// updateNode(node.id, { selected: false })
// })
// // Select clicked node | 选中的节点
// const clickedNode = nodes.value.find(n => n.id === event.node.id)
// if (clickedNode) {
// updateNode(event.node.id, { selected: true })
// }
}
// Handle viewport change | 处理视口变化
const handleViewportChange = (newViewport) => {
updateViewport(newViewport)
}
// Handle edges change | 处理边变化
const onEdgesChange = (changes) => {
// Check if any edge is being removed | 检查是否有边被删除
const hasRemoval = changes.some(change => change.type === 'remove')
if (hasRemoval) {
// Trigger history save after edge removal | 边删除后触发历史保存
nextTick(() => {
manualSaveHistory()
})
}
}
// Handle pane click | 处理画布点击
const onPaneClick = () => {
showNodeMenu.value = false
// Clear all selections | 清除所有选中
// nodes.value = nodes.value.map(node => ({
// ...node,
// selected: false
// }))
}
// Handle project action | 处理项目操作
const handleProjectAction = (key) => {
switch (key) {
case 'rename':
renameValue.value = projectName.value
showRenameModal.value = true
break
case 'duplicate':
// TODO: Implement duplicate
window.$message?.info('复制功能开发中')
break
case 'delete':
showDeleteModal.value = true
break
}
}
// Confirm rename | 确认重命名
const confirmRename = () => {
const projectId = route.params.id
if (renameValue.value.trim()) {
renameProject(projectId, renameValue.value.trim())
window.$message?.success('已重命名')
}
showRenameModal.value = false
}
// Confirm delete | 确认删除
const confirmDelete = () => {
const projectId = route.params.id
// deleteProject(projectId) // TODO: import deleteProject
showDeleteModal.value = false
window.$message?.success('项目已删除')
router.push('/')
}
// Handle Enter key | 处理回车键
const handleEnterKey = (e) => {
e.preventDefault()
sendMessage()
}
// Handle AI polish | 处理 AI 润色
const handlePolish = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const originalInput = chatInput.value
try {
// Call chat API to polish the prompt | 调用 AI 润色提示词
const result = await sendChat(input, true)
if (result) {
chatInput.value = result
window.$message?.success('提示词已润色')
}
} catch (err) {
chatInput.value = originalInput
window.$message?.error(err.message || '润色失败')
} finally {
isProcessing.value = false
}
}
// Send message | 发送消息
const sendMessage = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const content = chatInput.value
chatInput.value = ''
try {
// Calculate position to avoid overlap | 计算位置避免重叠
let maxY = 0
if (nodes.value.length > 0) {
maxY = Math.max(...nodes.value.map(n => n.position.y))
}
const baseX = 100
const baseY = maxY + 200
if (autoExecute.value) {
// Auto-execute mode: analyze intent and execute workflow | 自动执行模式:分析意图并执行工作流
window.$message?.info('正在分析工作流...')
try {
// Analyze user intent | 分析用户意图
const result = await analyzeIntent(content)
// Ensure we have valid workflow params | 确保有效的工作流参数
const workflowParams = {
workflow_type: result?.workflow_type || WORKFLOW_TYPES.TEXT_TO_IMAGE,
image_prompt: result?.image_prompt || content,
video_prompt: result?.video_prompt || content,
character: result?.character,
shots: result?.shots,
multi_angle: result?.multi_angle,
picture_book: result?.picture_book
}
window.$message?.info(`执行工作流: ${result?.description || '文生图'}`)
// Execute the workflow | 执行工作流
await executeWorkflow(workflowParams, { x: baseX, y: baseY })
window.$message?.success('工作流已启动')
} catch (err) {
console.error('Workflow error:', err)
// Fallback to simple text-to-image | 回退到文生图
window.$message?.warning('使用默认文生图工作流')
await createTextToImageWorkflow(content, { x: baseX, y: baseY })
}
} else {
// Manual mode: just create nodes | 手动模式:仅创建节点
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
content: content,
label: '提示词'
})
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
label: '文生图'
})
addEdge({
source: textNodeId,
target: imageConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
}
} catch (err) {
window.$message?.error(err.message || '创建失败')
} finally {
isProcessing.value = false
}
}
// Go back to home | 返回首页
const goBack = () => {
router.push('/')
}
// Check if mobile | 检测是否移动端
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
// Load project by ID | 根据ID加载项目
const loadProjectById = (projectId) => {
// Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染
flowKey.value = Date.now()
if (projectId && projectId !== 'new') {
loadProject(projectId)
} else {
// New project - clear canvas | 新项目 - 清空画布
clearCanvas()
}
}
// Watch for route changes | 监听路由变化
watch(
() => route.params.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
// Save current project before switching | 切换前保存当前项目
if (oldId) {
saveProject()
}
// Load new project | 加载新项目
loadProjectById(newId)
}
}
)
// Initialize | 初始化
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Initialize projects store | 初始化项目存储
await initProjectsStore()
// Load project data | 加载项目数据
loadProjectById(route.params.id)
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
if (initialPrompt) {
sessionStorage.removeItem('ai-canvas-initial-prompt')
chatInput.value = initialPrompt
// Auto-send the message | 自动发送消息
nextTick(() => {
sendMessage()
})
}
})
// Cleanup on unmount | 卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
// Save project before leaving | 离开前保存项目
saveProject()
})
</script>
<style>
/* Import Vue Flow styles | 引入 Vue Flow 样式 */
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/minimap/dist/style.css';
.canvas-flow {
width: 100%;
height: 100%;
}
</style>