feat: add personal canvas workflows

This commit is contained in:
2026-05-26 11:18:28 +08:00
parent bbd1f08f7c
commit 5290812353
7 changed files with 698 additions and 33 deletions

View File

@@ -42,11 +42,55 @@
</div>
<!-- My workflows | 我的工作流 -->
<div v-else class="empty-state">
<n-icon :size="36" class="text-gray-500">
<FolderOpenOutline />
</n-icon>
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p>
<div v-else class="my-workflows">
<div class="my-toolbar">
<button class="save-current-btn" @click="$emit('save-current')" title="保存当前工作流">
<n-icon :size="15"><BookmarkOutline /></n-icon>
<span>保存当前</span>
</button>
<button class="refresh-btn" @click="$emit('refresh-workflows')" title="刷新我的工作流">
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
<div v-if="loadingMyWorkflows" class="empty-state">
<n-icon :size="30" class="text-gray-500">
<RefreshOutline />
</n-icon>
<p class="text-gray-500 text-sm mt-2">正在加载...</p>
</div>
<div v-else-if="myWorkflows.length" class="workflow-grid">
<div
v-for="workflow in myWorkflows"
:key="workflow.id"
class="workflow-card my-workflow-card"
@click="handleAddWorkflow(workflow)"
>
<button
class="delete-workflow-btn"
title="删除工作流"
@click.stop="$emit('delete-workflow', workflow)"
>
<n-icon :size="13"><TrashOutline /></n-icon>
</button>
<div class="card-cover">
<img v-if="workflow.thumbnail" :src="workflow.thumbnail" :alt="workflow.name" class="cover-img" />
<n-icon v-else :size="34" class="cover-icon">
<BookmarkOutline />
</n-icon>
</div>
<div class="card-title">{{ workflow.name }}</div>
<div class="card-meta">{{ formatWorkflowMeta(workflow) }}</div>
</div>
</div>
<div v-else class="empty-state">
<n-icon :size="36" class="text-gray-500">
<FolderOpenOutline />
</n-icon>
<p class="text-gray-500 text-sm mt-2">暂无自定义工作流</p>
</div>
</div>
</div>
</div>
@@ -69,15 +113,23 @@ import {
BookOutline,
PersonOutline,
CartOutline,
ChatbubbleOutline
ChatbubbleOutline,
BookmarkOutline,
RefreshOutline,
TrashOutline
} from '@vicons/ionicons5'
import { WORKFLOW_TEMPLATES } from '../config/workflows'
const props = defineProps({
show: Boolean
show: Boolean,
myWorkflows: {
type: Array,
default: () => []
},
loadingMyWorkflows: Boolean
})
const emit = defineEmits(['update:show', 'add-workflow'])
const emit = defineEmits(['update:show', 'add-workflow', 'save-current', 'delete-workflow', 'refresh-workflows'])
// Active tab | 当前标签
const activeTab = ref('public')
@@ -113,6 +165,11 @@ const handleAddWorkflow = (workflow) => {
visible.value = false
}
const formatWorkflowMeta = (workflow) => {
const count = workflow.workflowData?.nodes?.length || 0
return `${count} 个节点`
}
// Handle click outside | 点击外部关闭
const handleClickOutside = () => {
visible.value = false
@@ -216,6 +273,53 @@ const vClickOutside = {
padding: 16px;
}
.my-workflows {
display: flex;
flex-direction: column;
gap: 14px;
}
.my-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.save-current-btn,
.refresh-btn,
.delete-workflow-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.save-current-btn {
gap: 6px;
height: 30px;
padding: 0 10px;
border-radius: 8px;
font-size: 12px;
}
.refresh-btn {
width: 30px;
height: 30px;
border-radius: 8px;
}
.save-current-btn:hover,
.refresh-btn:hover,
.delete-workflow-btn:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
/* Workflow grid | 工作流网格 */
.workflow-grid {
display: grid;
@@ -229,6 +333,10 @@ const vClickOutside = {
transition: transform 0.2s;
}
.my-workflow-card {
position: relative;
}
.workflow-card:hover {
transform: translateY(-2px);
}
@@ -269,6 +377,31 @@ const vClickOutside = {
white-space: nowrap;
}
.card-meta {
margin-top: 3px;
font-size: 11px;
color: var(--text-secondary);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-workflow-btn {
position: absolute;
right: 6px;
top: 6px;
width: 24px;
height: 24px;
border-radius: 7px;
opacity: 0;
z-index: 2;
}
.my-workflow-card:hover .delete-workflow-btn {
opacity: 1;
}
/* Empty state | 空状态 */
.empty-state {
display: flex;

View File

@@ -0,0 +1,160 @@
/**
* Personal workflow store | 个人工作流状态管理
*/
import { ref } from 'vue'
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
export const myWorkflows = ref([])
export const workflowSyncStatus = ref('idle')
export const workflowSyncError = ref('')
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(text || `${path} ${response.status}`)
}
return response.json()
}
const secondsToDate = (value) => {
if (value instanceof Date) return value
const num = Number(value || 0)
return new Date(num > 100000000000 ? num : num * 1000)
}
const dateToSeconds = (value) => {
if (value instanceof Date) return value.getTime() / 1000
const parsed = new Date(value)
return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000
}
const workflowFromApi = (item) => ({
id: item.id,
name: item.name || '未命名工作流',
description: item.description || '',
thumbnail: item.thumbnail || '',
workflowData: item.workflow_data || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
},
ownerId: item.owner_id || '',
ownerName: item.owner_name || '',
version: item.version || 1,
createdAt: secondsToDate(item.created_at),
updatedAt: secondsToDate(item.updated_at)
})
const runtimeKeys = [
'base64',
'maskData',
'loading',
'error',
'taskId',
'progress',
'status',
'thumbnail',
'outputContent',
'executed',
'revisedPrompt',
'generatedImages',
'generatedVideo'
]
const cleanNodeForWorkflow = (node) => {
const data = { ...(node.data || {}) }
for (const key of runtimeKeys) delete data[key]
if (node.type === 'image') {
data.url = ''
}
if (node.type === 'video') {
data.url = ''
data.duration = 0
}
if (node.type === 'llmConfig') {
data.outputContent = ''
}
delete data.createdAt
delete data.updatedAt
return {
id: node.id,
type: node.type,
position: {
x: Number(node.position?.x || 0),
y: Number(node.position?.y || 0)
},
data
}
}
const cleanEdgeForWorkflow = (edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || 'right',
targetHandle: edge.targetHandle || 'left',
type: edge.type,
data: edge.data || {}
})
export const cleanCanvasForWorkflow = (canvasData) => ({
nodes: (canvasData?.nodes || []).map(cleanNodeForWorkflow),
edges: (canvasData?.edges || []).map(cleanEdgeForWorkflow),
viewport: canvasData?.viewport || { x: 100, y: 50, zoom: 0.8 }
})
export const loadMyWorkflows = async () => {
try {
workflowSyncStatus.value = 'syncing'
const response = await requestJson('/canvas-workflows')
myWorkflows.value = (response.items || []).map(workflowFromApi)
workflowSyncStatus.value = 'synced'
workflowSyncError.value = ''
return myWorkflows.value
} catch (err) {
workflowSyncStatus.value = 'error'
workflowSyncError.value = err.message || '工作流加载失败'
throw err
}
}
export const saveMyWorkflow = async ({ name, description = '', canvasData, sourceProjectId = '' }) => {
const now = new Date()
const payload = {
name: (name || '').trim() || '未命名工作流',
description,
thumbnail: '',
workflow_data: cleanCanvasForWorkflow(canvasData),
created_at: dateToSeconds(now),
updated_at: dateToSeconds(now),
source: 'canvas',
source_project_id: sourceProjectId
}
workflowSyncStatus.value = 'syncing'
const response = await requestJson('/canvas-workflows', {
method: 'POST',
body: JSON.stringify(payload)
})
const item = workflowFromApi(response.item)
myWorkflows.value = [item, ...myWorkflows.value.filter(workflow => workflow.id !== item.id)]
workflowSyncStatus.value = 'synced'
workflowSyncError.value = ''
return item
}
export const deleteMyWorkflow = async (id) => {
await requestJson(`/canvas-workflows/${encodeURIComponent(id)}`, { method: 'DELETE' })
myWorkflows.value = myWorkflows.value.filter(workflow => workflow.id !== id)
}

View File

@@ -240,11 +240,28 @@
</template>
</n-modal>
<!-- Save Workflow Modal | 保存工作流弹窗 -->
<n-modal v-model:show="showSaveWorkflowModal" preset="dialog" title="保存当前工作流">
<n-input v-model:value="workflowNameValue" placeholder="请输入工作流名称" />
<template #action>
<n-button @click="showSaveWorkflowModal = false">取消</n-button>
<n-button type="primary" :loading="isSavingWorkflow" @click="confirmSaveWorkflow">保存</n-button>
</template>
</n-modal>
<!-- Download Modal | 下载弹窗 -->
<DownloadModal v-model:show="showDownloadModal" />
<!-- Workflow Panel | 工作流面板 -->
<WorkflowPanel v-model:show="showWorkflowPanel" @add-workflow="handleAddWorkflow" />
<WorkflowPanel
v-model:show="showWorkflowPanel"
:my-workflows="myWorkflows"
:loading-my-workflows="loadingMyWorkflows"
@add-workflow="handleAddWorkflow"
@save-current="openSaveWorkflowModal"
@delete-workflow="handleDeleteWorkflow"
@refresh-workflows="refreshMyWorkflows"
/>
</div>
</template>
@@ -285,6 +302,7 @@ import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
@@ -397,7 +415,10 @@ const showRenameModal = ref(false)
const showDeleteModal = ref(false)
const showDownloadModal = ref(false)
const showWorkflowPanel = ref(false)
const showSaveWorkflowModal = ref(false)
const renameValue = ref('')
const workflowNameValue = ref('')
const isSavingWorkflow = ref(false)
// Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => {
@@ -413,6 +434,8 @@ const projectName = computed(() => {
return project?.name || '未命名项目'
})
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
// Project dropdown options | 项目下拉选项
const projectOptions = [
{ label: '重命名', key: 'rename' },
@@ -472,42 +495,55 @@ const addNewNode = async (type) => {
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
const viewportCenterPosition = () => ({
x: -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom,
y: -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)
const insertWorkflowNodes = (workflowNodes, workflowEdges, workflowName, { reposition = false } = {}) => {
const sourceNodes = (workflowNodes || []).filter(node => node?.type)
if (!sourceNodes.length) {
window.$message?.warning('这个工作流没有可插入的节点')
return
}
// Calculate viewport center position | 计算视口中心位置
const center = viewportCenterPosition()
const startPosition = { x: center.x - 300, y: center.y - 200 }
const minX = Math.min(...sourceNodes.map(node => Number(node.position?.x || 0)))
const minY = Math.min(...sourceNodes.map(node => Number(node.position?.y || 0)))
// Start batch operation manually | 手动开始批量操作
startBatchOperation()
// Add nodes to canvas in batch | 批量将节点添加到画布
const nodeSpecs = newNodes.map(node => ({
const nodeSpecs = sourceNodes.map(node => ({
type: node.type,
position: node.position,
position: reposition
? {
x: startPosition.x + Number(node.position?.x || 0) - minX,
y: startPosition.y + Number(node.position?.y || 0) - minY
}
: 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]
sourceNodes.forEach((node, index) => {
if (node.id) idMap[node.id] = nodeIds[index]
})
// Add edges to canvas in batch | 批量将边添加到画布
const edgeSpecs = newEdges.map(edge => ({
const edgeSpecs = (workflowEdges || []).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
}))
})).filter(edge => edge.source && edge.target)
// Add edges (autoBatch=false to use manual batch) | 添加边autoBatch=false 以使用手动批量)
addEdges(edgeSpecs, false)
@@ -523,7 +559,76 @@ const handleAddWorkflow = ({ workflow, options }) => {
})
}, 100)
window.$message?.success(`已添加工作流: ${workflow.name}`)
window.$message?.success(`已添加工作流: ${workflowName}`)
}
// Handle add workflow from panel | 处理从面板添加工作流
const handleAddWorkflow = ({ workflow, options }) => {
if (typeof workflow?.createNodes === 'function') {
const center = viewportCenterPosition()
const startPosition = { x: center.x - 300, y: center.y - 200 }
const { nodes: newNodes, edges: newEdges } = workflow.createNodes(startPosition, options)
insertWorkflowNodes(newNodes, newEdges, workflow.name, { reposition: false })
return
}
const workflowData = workflow?.workflowData || workflow?.workflow_data
insertWorkflowNodes(workflowData?.nodes, workflowData?.edges, workflow?.name || '我的工作流', { reposition: true })
}
const refreshMyWorkflows = async () => {
try {
await loadMyWorkflows()
} catch (err) {
window.$message?.error(err.message || '工作流加载失败')
}
}
const openSaveWorkflowModal = () => {
if (!nodes.value.length) {
window.$message?.warning('当前画布没有可保存的节点')
return
}
workflowNameValue.value = `${projectName.value} 工作流`
showSaveWorkflowModal.value = true
}
const confirmSaveWorkflow = async () => {
const name = workflowNameValue.value.trim()
if (!name) {
window.$message?.warning('请填写工作流名称')
return
}
try {
isSavingWorkflow.value = true
await saveMyWorkflow({
name,
canvasData: {
nodes: nodes.value,
edges: edges.value,
viewport: viewport.value || canvasViewport.value
},
sourceProjectId: String(route.params.id || '')
})
showSaveWorkflowModal.value = false
window.$message?.success('已保存到我的工作流')
} catch (err) {
window.$message?.error(err.message || '工作流保存失败')
} finally {
isSavingWorkflow.value = false
}
}
const handleDeleteWorkflow = async (workflow) => {
if (!workflow?.id) return
if (!window.confirm(`确定删除工作流「${workflow.name}」吗?`)) return
try {
await deleteMyWorkflow(workflow.id)
window.$message?.success('已删除工作流')
} catch (err) {
window.$message?.error(err.message || '工作流删除失败')
}
}
// Handle connection | 处理连接
@@ -841,6 +946,12 @@ watch(
}
)
watch(showWorkflowPanel, (visible) => {
if (visible && workflowSyncStatus.value === 'idle') {
refreshMyWorkflows()
}
})
// Initialize | 初始化
onMounted(async () => {
checkMobile()
@@ -851,6 +962,7 @@ onMounted(async () => {
// Load project data | 加载项目数据
loadProjectById(route.params.id)
refreshMyWorkflows()
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')