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

@@ -1,12 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "4a22ca0",
"message": "docs: record width-first layout deployment",
"ts": "2026-05-20T19:01:07+08:00",
"type": "commit"
},
{
"files_changed": 3,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 3 项未提交变更 · 最近提交docs: record width-first layout deployment",
@@ -3192,6 +3185,13 @@
"message": "auto-save 2026-05-27 14:42 (~2)",
"hash": "a699899",
"files_changed": 2
},
{
"ts": "2026-05-27T14:47:45+08:00",
"type": "commit",
"message": "auto-save 2026-05-27 14:47 (~2)",
"hash": "22398c1",
"files_changed": 2
}
]
}

View File

@@ -0,0 +1,29 @@
- generic [active] [ref=e1]:
- main [ref=e2]:
- iframe [ref=e3]:
- generic [active] [ref=f1e1]:
- button "Settings":
- img
- generic [ref=e4]:
- generic:
- generic:
- generic:
- generic:
- generic:
- img
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e8]:
- img [ref=e9]
- textbox [ref=e12]
- generic [ref=e14]:
- img [ref=e15]
- textbox [ref=e19]
- button [ref=e20]:
- img [ref=e21]
- generic [ref=e24] [cursor=pointer]:
- checkbox "保持登录" [checked] [ref=e25]
- generic [ref=e26]: 保持登录
- button [ref=e27]:
- img [ref=e28]
- alert [ref=e30]

View File

@@ -1277,6 +1277,18 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-27 · 视频完成后自动回填画布节点</h3>
<span class="tag violet">Canvas</span>
<span class="tag amber">Video</span>
</header>
<div class="body">
<p><strong>问题:</strong>画布视频任务提交后,完成状态主要依赖单个 <code>VideoNode.vue</code> 组件内部长轮询。如果组件轮询中断、页面后台挂起或节点未稳定挂载,服务端已经生成好的视频不会立刻写回当前画布,用户需要刷新页面后才看到结果。</p>
<p><strong>改动:</strong><code>web/canvas-app/src/hooks/useApi.js</code> 新增 <code>readVideoTask</code>,用于按 <code>skg:&lt;jobId&gt;:&lt;videoId&gt;</code> 读取服务端当前视频状态;<code>web/canvas-app/src/views/Canvas.vue</code> 增加页面级待完成视频同步器,每 5 秒扫描带 <code>taskId</code> 且没有 <code>url</code> 的视频节点,完成后自动写入视频 URL、清掉 <code>taskId</code> 并保存画布;<code>VideoNode.vue</code> 只负责展示节点状态,不再承担任务同步。</p>
<p><strong>影响:</strong>视频排队、生成中和完成状态会在当前打开的画布里自动更新;刷新页面仍可恢复未完成任务,但不再是看到视频结果的必要步骤。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-27 · 修复刷新后画布记录被本地缓存覆盖</h3>

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()
})