auto-save 2026-05-27 14:53 (+1, ~6)

This commit is contained in:
2026-05-27 14:53:13 +08:00
parent 22398c1483
commit 3c146d64a0
8 changed files with 168 additions and 68 deletions

View File

@@ -140,12 +140,11 @@
* Video node component | 视频节点组件
* Displays and manages video content
*/
import { ref, nextTick, watch, onMounted } from 'vue'
import { ref, nextTick } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NSpin } from 'naive-ui'
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
import { useVideoGeneration } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
@@ -156,9 +155,6 @@ const props = defineProps({
// Vue Flow instance
const { updateNodeInternals } = useVueFlow()
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
const { pollVideoTask } = useVideoGeneration()
// Hover state | 悬浮状态
const showActions = ref(false)
const showHandleMenu = ref(false)
@@ -173,61 +169,6 @@ const operations = [
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
]
// Polling state | 轮询状态
const isPolling = ref(false)
// Watch for taskId changes and start polling | 监听 taskId 变化并开始轮询
watch(() => props.data?.taskId, (taskId) => {
if (taskId && !props.data?.url && !isPolling.value) {
startPolling(taskId)
}
})
// 页面刷新后恢复轮询 | Resume polling after page refresh
onMounted(() => {
const { taskId, url } = props.data || {}
if (taskId && !url && !isPolling.value) {
startPolling(taskId)
}
})
// Start polling for video result | 开始轮询获取视频结果
const startPolling = async (taskId) => {
if (isPolling.value) return
isPolling.value = true
try {
const result = await pollVideoTask(taskId, (attempt, percentage) => {
// 更新进度
updateNode(props.id, {
progress: percentage,
attempt
})
})
// 轮询成功,更新视频节点
updateNode(props.id, {
url: result.url,
loading: false,
progress: 100,
label: '视频生成',
taskId: null // 清除 taskId
})
window.$message?.success('视频生成成功')
} catch (err) {
// 轮询失败
updateNode(props.id, {
loading: false,
error: err.message || '生成失败',
label: '生成失败',
taskId: null // 清除 taskId
})
window.$message?.error(err.message || '视频生成失败')
} finally {
isPolling.value = false
}
}
// Handle menu select | 处理菜单选择
const handleSelect = (item) => {
const currentNode = nodes.value.find(n => n.id === props.id)

View File

@@ -18,6 +18,7 @@ export {
useChat,
useImageGeneration,
useVideoGeneration,
readVideoTask,
useApi
} from './useApi'

View File

@@ -91,6 +91,7 @@ const parseVideoTaskId = (pollTaskId) => {
export const readVideoTask = async (pollTaskId) => {
const { jobId, videoId } = parseVideoTaskId(pollTaskId)
// Canvas-level video sync reads one snapshot at a time instead of owning a long node-local poll.
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
const item = (job.generated_videos || []).find(v => v.id === videoId)
if (!item) {

View File

@@ -299,7 +299,7 @@ import {
} 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 { useChat, useWorkflowOrchestrator, readVideoTask } 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'
@@ -436,6 +436,10 @@ const projectName = computed(() => {
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
const videoTaskSyncing = new Set()
let videoTaskSyncTimer = null
let videoTaskSyncToastShown = false
// Project dropdown options | 项目下拉选项
const projectOptions = [
{ label: '重命名', key: 'rename' },
@@ -631,6 +635,106 @@ const handleDeleteWorkflow = async (workflow) => {
}
}
const pendingVideoNodes = () => nodes.value.filter(node =>
node.type === 'video' && node.data?.taskId && !node.data?.url
)
const syncPendingVideoNodes = async () => {
const pending = pendingVideoNodes()
if (!pending.length) return
for (const node of pending) {
const taskId = node.data?.taskId
const syncKey = `${node.id}:${taskId}`
if (!taskId || videoTaskSyncing.has(syncKey)) continue
videoTaskSyncing.add(syncKey)
readVideoTask(taskId)
.then((snapshot) => {
const item = snapshot.video
if (item.status === 'completed') {
updateNode(node.id, {
url: snapshot.url,
loading: false,
progress: 100,
label: '视频生成',
taskId: null,
error: '',
syncError: '',
updatedAt: Date.now()
})
window.$message?.success('视频生成成功,已自动回填到画布')
return
}
if (item.status === 'failed') {
updateNode(node.id, {
loading: false,
error: item.error || '视频生成失败',
label: '生成失败',
taskId: null,
queueMessage: '',
syncError: '',
updatedAt: Date.now()
})
window.$message?.error(item.error || '视频生成失败')
return
}
updateNode(node.id, {
loading: true,
progress: item.progress || node.data?.progress || 0,
queuePosition: item.queue_position || 0,
queueSize: item.queue_size || 0,
queueMessage: item.queue_message || '',
error: '',
syncError: '',
updatedAt: Date.now()
})
})
.catch((err) => {
if (err.terminal) {
updateNode(node.id, {
loading: false,
error: err.message || '视频任务不存在',
label: '生成失败',
taskId: null,
syncError: '',
updatedAt: Date.now()
})
return
}
updateNode(node.id, {
loading: true,
syncError: err.message || '视频状态同步暂时中断',
updatedAt: Date.now()
})
})
.finally(() => {
videoTaskSyncing.delete(syncKey)
})
}
if (!videoTaskSyncToastShown) {
videoTaskSyncToastShown = true
window.$message?.info('视频生成中,完成后会自动显示在画布里')
}
}
const startVideoTaskSync = () => {
if (videoTaskSyncTimer) return
syncPendingVideoNodes()
videoTaskSyncTimer = window.setInterval(syncPendingVideoNodes, 5000)
}
const stopVideoTaskSync = () => {
if (!videoTaskSyncTimer) return
window.clearInterval(videoTaskSyncTimer)
videoTaskSyncTimer = null
videoTaskSyncing.clear()
}
// Handle connection | 处理连接
const onConnect = (params) => {
// Check connection types | 检查连接类型
@@ -952,6 +1056,16 @@ watch(showWorkflowPanel, (visible) => {
}
})
watch(
() => nodes.value
.filter(node => node.type === 'video')
.map(node => `${node.id}:${node.data?.taskId || ''}:${node.data?.url || ''}`)
.join('|'),
() => {
if (pendingVideoNodes().length) syncPendingVideoNodes()
}
)
// Initialize | 初始化
onMounted(async () => {
checkMobile()
@@ -963,6 +1077,7 @@ onMounted(async () => {
// Load project data | 加载项目数据
loadProjectById(route.params.id)
refreshMyWorkflows()
startVideoTaskSync()
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
@@ -979,6 +1094,7 @@ onMounted(async () => {
// Cleanup on unmount | 卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
stopVideoTaskSync()
// Save project before leaving | 离开前保存项目
saveProject()
})