auto-save 2026-05-27 14:53 (+1, ~6)
This commit is contained in:
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"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,
|
"files_changed": 3,
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:docs: record width-first layout deployment",
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:docs: record width-first layout deployment",
|
||||||
@@ -3192,6 +3185,13 @@
|
|||||||
"message": "auto-save 2026-05-27 14:42 (~2)",
|
"message": "auto-save 2026-05-27 14:42 (~2)",
|
||||||
"hash": "a699899",
|
"hash": "a699899",
|
||||||
"files_changed": 2
|
"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>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<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">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-27 · 修复刷新后画布记录被本地缓存覆盖</h3>
|
<h3>2026-05-27 · 修复刷新后画布记录被本地缓存覆盖</h3>
|
||||||
|
|||||||
@@ -140,12 +140,11 @@
|
|||||||
* Video node component | 视频节点组件
|
* Video node component | 视频节点组件
|
||||||
* Displays and manages video content
|
* 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 { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
import { NIcon, NSpin } from 'naive-ui'
|
import { NIcon, NSpin } from 'naive-ui'
|
||||||
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
|
import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5'
|
||||||
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
|
||||||
import { useVideoGeneration } from '../../hooks/useApi'
|
|
||||||
import NodeHandleMenu from './NodeHandleMenu.vue'
|
import NodeHandleMenu from './NodeHandleMenu.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -156,9 +155,6 @@ const props = defineProps({
|
|||||||
// Vue Flow instance
|
// Vue Flow instance
|
||||||
const { updateNodeInternals } = useVueFlow()
|
const { updateNodeInternals } = useVueFlow()
|
||||||
|
|
||||||
// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数
|
|
||||||
const { pollVideoTask } = useVideoGeneration()
|
|
||||||
|
|
||||||
// Hover state | 悬浮状态
|
// Hover state | 悬浮状态
|
||||||
const showActions = ref(false)
|
const showActions = ref(false)
|
||||||
const showHandleMenu = ref(false)
|
const showHandleMenu = ref(false)
|
||||||
@@ -173,61 +169,6 @@ const operations = [
|
|||||||
{ type: 'videoConfig', label: '生视频', icon: VideocamOutline }
|
{ 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 | 处理菜单选择
|
// Handle menu select | 处理菜单选择
|
||||||
const handleSelect = (item) => {
|
const handleSelect = (item) => {
|
||||||
const currentNode = nodes.value.find(n => n.id === props.id)
|
const currentNode = nodes.value.find(n => n.id === props.id)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
useChat,
|
useChat,
|
||||||
useImageGeneration,
|
useImageGeneration,
|
||||||
useVideoGeneration,
|
useVideoGeneration,
|
||||||
|
readVideoTask,
|
||||||
useApi
|
useApi
|
||||||
} from './useApi'
|
} from './useApi'
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const parseVideoTaskId = (pollTaskId) => {
|
|||||||
|
|
||||||
export const readVideoTask = async (pollTaskId) => {
|
export const readVideoTask = async (pollTaskId) => {
|
||||||
const { jobId, videoId } = parseVideoTaskId(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 job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
|
||||||
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
const item = (job.generated_videos || []).find(v => v.id === videoId)
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ import {
|
|||||||
} from '@vicons/ionicons5'
|
} 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 { 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 { loadAllModels } from '../stores/models'
|
||||||
import { useChat, useWorkflowOrchestrator } from '../hooks'
|
import { useChat, useWorkflowOrchestrator, readVideoTask } from '../hooks'
|
||||||
import { useModelStore } from '../stores/pinia'
|
import { useModelStore } from '../stores/pinia'
|
||||||
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
|
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
|
||||||
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
|
import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows'
|
||||||
@@ -436,6 +436,10 @@ const projectName = computed(() => {
|
|||||||
|
|
||||||
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
|
const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing')
|
||||||
|
|
||||||
|
const videoTaskSyncing = new Set()
|
||||||
|
let videoTaskSyncTimer = null
|
||||||
|
let videoTaskSyncToastShown = false
|
||||||
|
|
||||||
// Project dropdown options | 项目下拉选项
|
// Project dropdown options | 项目下拉选项
|
||||||
const projectOptions = [
|
const projectOptions = [
|
||||||
{ label: '重命名', key: 'rename' },
|
{ 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 | 处理连接
|
// Handle connection | 处理连接
|
||||||
const onConnect = (params) => {
|
const onConnect = (params) => {
|
||||||
// Check connection types | 检查连接类型
|
// 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 | 初始化
|
// Initialize | 初始化
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
checkMobile()
|
checkMobile()
|
||||||
@@ -963,6 +1077,7 @@ onMounted(async () => {
|
|||||||
// Load project data | 加载项目数据
|
// Load project data | 加载项目数据
|
||||||
loadProjectById(route.params.id)
|
loadProjectById(route.params.id)
|
||||||
refreshMyWorkflows()
|
refreshMyWorkflows()
|
||||||
|
startVideoTaskSync()
|
||||||
|
|
||||||
// Check for initial prompt from home page | 检查来自首页的初始提示词
|
// Check for initial prompt from home page | 检查来自首页的初始提示词
|
||||||
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
|
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
|
||||||
@@ -979,6 +1094,7 @@ onMounted(async () => {
|
|||||||
// Cleanup on unmount | 卸载时清理
|
// Cleanup on unmount | 卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', checkMobile)
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
stopVideoTaskSync()
|
||||||
// Save project before leaving | 离开前保存项目
|
// Save project before leaving | 离开前保存项目
|
||||||
saveProject()
|
saveProject()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user