Files
20260512-skg-tk/web/canvas-app/src/stores/projects.js
kang b56d5177e5 fix(canvas): persist video uploads and fix media cache/polling
- VideoNode upload now goes through backend (/jobs/upload via uploadCanvasVideo)
  for a stable reloadable URL instead of a session-only blob: that leaked and
  broke on reload; cleanNodeForStorage also strips blob: URLs
- useCachedMediaUrl: record real blob.size (chunked videos reported 0, making the
  LRU byte cap a no-op); guard the catch path with the race token
- useApi: send credentials when reading reference images; drop the node-level
  video poll that duplicated the Canvas-level syncPendingVideoNodes loop
- request.js: 60s timeout (was ~8.3h) + withCredentials
- remove dead getVideoTaskStatus/pollVideoTask that ignored taskId

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00

514 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Projects store | 项目状态管理
* Manages projects with localStorage persistence
*/
import { ref, computed, watch } from 'vue'
// Storage key | 存储键
const STORAGE_KEY = 'ai-canvas-projects'
// Generate unique ID | 生成唯一ID
const generateId = () => `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Projects list | 项目列表
export const projects = ref([])
// Current project ID | 当前项目ID
export const currentProjectId = ref(null)
export const projectSyncStatus = ref('idle')
export const projectSyncError = ref('')
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const remoteSaveTimers = new Map()
const remoteSaveSignatures = new Map()
let initPromise = null
let remoteAvailable = false
// Current project | 当前项目
export const currentProject = computed(() => {
return projects.value.find(p => p.id === currentProjectId.value) || null
})
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 secondsToDate = (value) => {
if (value instanceof Date) return value
const num = Number(value || 0)
return new Date(num > 100000000000 ? num : num * 1000)
}
const projectFromApi = (item) => ({
id: item.id,
name: item.name || '未命名项目',
thumbnail: item.thumbnail || '',
visibility: item.visibility || 'private',
ownerId: item.owner_id || '',
ownerName: item.owner_name || '',
ownerEmail: item.owner_email || '',
ownerProvider: item.owner_provider || '',
version: item.version || 1,
createdAt: secondsToDate(item.created_at),
updatedAt: secondsToDate(item.updated_at),
canvasData: item.canvas_data || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
})
const projectToApi = (project) => ({
id: project.id,
name: project.name || '未命名项目',
thumbnail: project.thumbnail || '',
visibility: project.visibility || 'private',
canvas_data: cleanProjectForStorage(project).canvasData || {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
},
created_at: dateToSeconds(project.createdAt),
updated_at: dateToSeconds(project.updatedAt),
source: 'canvas'
})
const remoteProjectSignature = (project) => {
const payload = projectToApi(project)
delete payload.updated_at
return JSON.stringify(payload)
}
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 mergeProjectLists = (localItems, remoteItems) => {
const byId = new Map()
for (const item of remoteItems) byId.set(item.id, item)
for (const item of localItems) {
const existing = byId.get(item.id)
if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) {
byId.set(item.id, item)
}
}
return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt))
}
/**
* Load projects from localStorage | 从 localStorage 加载项目
*/
export const loadProjects = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings back to Date objects | 将日期字符串转换回 Date 对象
projects.value = parsed.map(p => ({
...p,
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt)
}))
}
} catch (err) {
console.error('Failed to load projects:', err)
projects.value = []
}
}
const saveRemoteProjectNow = async (project) => {
if (!project?.id) return null
const signature = remoteProjectSignature(project)
if (remoteSaveSignatures.get(project.id) === signature) return null
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
method: 'PUT',
body: JSON.stringify(projectToApi(project))
})
remoteSaveSignatures.set(project.id, signature)
return response.item ? projectFromApi(response.item) : null
}
const scheduleRemoteSave = (project, delay = 2000) => {
if (!remoteAvailable || !project?.id) return
if (remoteSaveTimers.has(project.id)) {
clearTimeout(remoteSaveTimers.get(project.id))
}
remoteSaveTimers.set(project.id, setTimeout(async () => {
remoteSaveTimers.delete(project.id)
try {
projectSyncStatus.value = 'syncing'
await saveRemoteProjectNow(project)
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
} catch (err) {
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Failed to sync project:', err)
}
}, delay))
}
const importLocalProjectsToServer = async (localItems) => {
if (!localItems.length) return []
const payload = { projects: localItems.map(projectToApi) }
const response = await requestJson('/canvas-projects/import', {
method: 'POST',
body: JSON.stringify(payload)
})
return (response.items || []).map(projectFromApi)
}
export const loadRemoteProjects = async () => {
try {
projectSyncStatus.value = 'syncing'
const localItems = [...projects.value]
const response = await requestJson('/canvas-projects')
remoteAvailable = true
const remoteItems = (response.items || []).map(projectFromApi)
const importedItems = await importLocalProjectsToServer(localItems)
const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems])
projects.value = merged
saveProjects({ remote: false })
projectSyncStatus.value = 'synced'
projectSyncError.value = ''
return merged
} catch (err) {
remoteAvailable = false
projectSyncStatus.value = 'error'
projectSyncError.value = err.message || '项目同步失败'
console.warn('Remote project sync unavailable:', err)
return projects.value
}
}
/**
* Clean node data for storage | 清理节点数据用于存储
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
*/
const cleanNodeForStorage = (node) => {
if (!node.data) return node
const cleanedData = { ...node.data }
// Remove base64 data | 移除 base64 数据
if (cleanedData.base64) {
delete cleanedData.base64
}
// If url is a base64 data URL, keep it only if it's from external source | 如果 url 是 base64只有外部来源才保留
if (cleanedData.url?.startsWith?.('data:')) {
// For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage
delete cleanedData.url
}
// blob: object URLs are session-only and break on reload — never persist them | blob: 仅会话内有效,重载即失效,不持久化
if (cleanedData.url?.startsWith?.('blob:')) {
delete cleanedData.url
}
// Remove mask data | 移除蒙版数据
if (cleanedData.maskData) {
delete cleanedData.maskData
}
return { ...node, data: cleanedData }
}
/**
* Clean project for storage | 清理项目用于存储
*/
const cleanProjectForStorage = (project) => {
return {
...project,
canvasData: project.canvasData ? {
...project.canvasData,
nodes: project.canvasData.nodes?.map(cleanNodeForStorage) || []
} : project.canvasData,
// Remove base64 thumbnails | 移除 base64 缩略图
thumbnail: project.thumbnail?.startsWith?.('data:') ? '' : project.thumbnail
}
}
/**
* Save projects to localStorage | 保存项目到 localStorage
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
*/
export const saveProjects = ({ remote = false } = {}) => {
// Always clean data before saving | 保存前始终清理数据
const cleanedProjects = projects.value.map(cleanProjectForStorage)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedProjects))
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, attempting aggressive cleanup...')
// Remove thumbnails and limit old projects | 移除缩略图并限制旧项目
const minimalProjects = cleanedProjects.map((project, index) => ({
...project,
thumbnail: '', // Remove all thumbnails | 移除所有缩略图
// Keep only essential canvas data for older projects | 旧项目只保留基本画布数据
canvasData: index > 10 ? { nodes: [], edges: [], viewport: project.canvasData?.viewport } : project.canvasData
}))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(minimalProjects))
console.log('Saved with aggressive cleanup')
window.$message?.warning('存储空间不足,已自动清理部分数据')
} catch (retryErr) {
console.error('Still failed after aggressive cleanup:', retryErr)
// Last resort: only keep first 5 projects | 最后手段只保留前5个项目
try {
const essentialProjects = minimalProjects.slice(0, 5)
localStorage.setItem(STORAGE_KEY, JSON.stringify(essentialProjects))
projects.value = projects.value.slice(0, 5)
window.$message?.warning('存储空间严重不足,已保留最近 5 个项目')
} catch (finalErr) {
console.error('Cannot save even minimal data:', finalErr)
window.$message?.error('存储失败,请清理浏览器存储空间')
}
}
} else {
console.error('Failed to save projects:', err)
}
}
if (remote) {
for (const project of projects.value) scheduleRemoteSave(project)
}
}
/**
* Create a new project | 创建新项目
* @param {string} name - Project name | 项目名称
* @returns {string} - New project ID | 新项目ID
*/
export const createProject = (name = '未命名项目') => {
const id = generateId()
const now = new Date()
const newProject = {
id,
name,
thumbnail: '',
createdAt: now,
updatedAt: now,
// Canvas data | 画布数据
canvasData: {
nodes: [],
edges: [],
viewport: { x: 100, y: 50, zoom: 0.8 }
}
}
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return id
}
/**
* Update project | 更新项目
* @param {string} id - Project ID | 项目ID
* @param {object} data - Update data | 更新数据
*/
export const updateProject = (id, data) => {
const index = projects.value.findIndex(p => p.id === id)
if (index === -1) return false
projects.value[index] = {
...projects.value[index],
...data,
updatedAt: new Date()
}
// Move to top of list | 移动到列表顶部
const [updated] = projects.value.splice(index, 1)
projects.value = [updated, ...projects.value]
saveProjects()
scheduleRemoteSave(updated)
return true
}
/**
* Update project canvas data | 更新项目画布数据
* @param {string} id - Project ID | 项目ID
* @param {object} canvasData - Canvas data (nodes, edges, viewport) | 画布数据
*/
export const updateProjectCanvas = (id, canvasData) => {
const project = projects.value.find(p => p.id === id)
if (!project) return false
project.canvasData = {
...project.canvasData,
...canvasData
}
project.updatedAt = new Date()
// Auto-update thumbnail from last edited image/video node | 自动从最后编辑的图片/视频节点更新缩略图
if (canvasData.nodes) {
const mediaNodes = canvasData.nodes
.filter(node => (node.type === 'image' || node.type === 'video') && node.data?.url)
.sort((a, b) => {
// Sort by last updated time | 按最后更新时间排序
const aTime = a.data?.updatedAt || a.data?.createdAt || 0
const bTime = b.data?.updatedAt || b.data?.createdAt || 0
return bTime - aTime
})
if (mediaNodes.length > 0) {
const latestNode = mediaNodes[0]
// Use thumbnail for video nodes, url for image nodes | 视频节点使用缩略图,图片节点使用 URL
if (latestNode.type === 'video') {
project.thumbnail = latestNode.data.thumbnail || latestNode.data.url
} else {
project.thumbnail = latestNode.data.url
}
}
}
saveProjects()
scheduleRemoteSave(project)
return true
}
/**
* Get project canvas data | 获取项目画布数据
* @param {string} id - Project ID | 项目ID
* @returns {object|null} - Canvas data or null | 画布数据或空
*/
export const getProjectCanvas = (id) => {
const project = projects.value.find(p => p.id === id)
return project?.canvasData || null
}
/**
* Delete project | 删除项目
* @param {string} id - Project ID | 项目ID
*/
export const deleteProject = (id) => {
projects.value = projects.value.filter(p => p.id !== id)
saveProjects()
if (remoteAvailable) {
requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' })
.catch(err => console.warn('Failed to delete remote project:', err))
}
}
/**
* Duplicate project | 复制项目
* @param {string} id - Source project ID | 源项目ID
* @returns {string|null} - New project ID or null | 新项目ID或空
*/
export const duplicateProject = (id) => {
const source = projects.value.find(p => p.id === id)
if (!source) return null
const newId = generateId()
const now = new Date()
const newProject = {
...JSON.parse(JSON.stringify(source)), // Deep clone | 深拷贝
id: newId,
name: `${source.name} (副本)`,
createdAt: now,
updatedAt: now
}
projects.value = [newProject, ...projects.value]
saveProjects()
scheduleRemoteSave(newProject, 0)
return newId
}
/**
* Rename project | 重命名项目
* @param {string} id - Project ID | 项目ID
* @param {string} name - New name | 新名称
*/
export const renameProject = (id, name) => {
return updateProject(id, { name })
}
/**
* Update project thumbnail | 更新项目缩略图
* @param {string} id - Project ID | 项目ID
* @param {string} thumbnail - Thumbnail URL (base64 or URL) | 缩略图URL
*/
export const updateProjectThumbnail = (id, thumbnail) => {
return updateProject(id, { thumbnail })
}
/**
* Get sorted projects | 获取排序后的项目列表
* @param {string} sortBy - Sort field (updatedAt, createdAt, name) | 排序字段
* @param {string} order - Sort order (asc, desc) | 排序顺序
*/
export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
return computed(() => {
const sorted = [...projects.value]
sorted.sort((a, b) => {
let valueA = a[sortBy]
let valueB = b[sortBy]
if (valueA instanceof Date) {
valueA = valueA.getTime()
valueB = valueB.getTime()
}
if (typeof valueA === 'string') {
valueA = valueA.toLowerCase()
valueB = valueB.toLowerCase()
}
if (order === 'asc') {
return valueA > valueB ? 1 : -1
} else {
return valueA < valueB ? 1 : -1
}
})
return sorted
})
}
/**
* Initialize projects store | 初始化项目存储
*/
export const initProjectsStore = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
loadProjects()
await loadRemoteProjects()
return projects.value
})()
return initPromise
}
// Export for debugging | 导出用于调试
if (typeof window !== 'undefined') {
window.__aiCanvasProjects = {
projects,
loadProjects,
saveProjects,
createProject,
deleteProject
}
}