auto-save 2026-05-27 14:53 (+1, ~6)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
29
.playwright-cli/page-2026-05-27T06-52-23-524Z.yml
Normal file
29
.playwright-cli/page-2026-05-27T06-52-23-524Z.yml
Normal 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]
|
||||
0
.playwright-cli/page-2026-05-27T06-52-55-049Z.yml
Normal file
0
.playwright-cli/page-2026-05-27T06-52-55-049Z.yml
Normal 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:<jobId>:<videoId></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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useChat,
|
||||
useImageGeneration,
|
||||
useVideoGeneration,
|
||||
readVideoTask,
|
||||
useApi
|
||||
} from './useApi'
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user