import { Check, Clapperboard, CloudUpload, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react"; import type { ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { App, Dropdown, Input } from "antd"; import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { resolveBackendApiUrl } from "@/stores/use-config-store"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { MediaPlaceholder } from "@/components/ui/product/media-placeholder"; import { cn } from "@/lib/utils"; import { useSyncProgressStore } from "@/stores/use-sync-progress-store"; import { hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync"; type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string }; const projectPreviewMediaCache = new WeakMap(); export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) { return ( ); } export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) { const { message } = App.useApp(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const renameProject = useCanvasStore((state) => state.renameProject); const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds); const editingId = useCanvasUiStore((state) => state.editingProjectId); const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle); const startEditing = useCanvasUiStore((state) => state.startEditingProject); const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle); const stopEditing = useCanvasUiStore((state) => state.stopEditingProject); const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId); const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds); const editing = editingId === project.id; const selected = selectedIds.includes(project.id); const open = () => navigate(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`); const saveTitle = async () => { stopEditing(); if (!hasRemoteUserDataSyncSession()) { renameProject(project.id, editingTitle); return; } try { await loadCanvasProjectForEditing(project.id); renameProject(project.id, editingTitle); await saveRemoteUserDataNow(project.id); } catch (error) { message.error(error instanceof Error ? error.message : "重命名失败"); } }; const compact = variant === "recent"; return (
(!editing || readOnly) && open()}>
{!compact && !readOnly ? ( event.stopPropagation()}> toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /> ) : null}
{editing && !readOnly ? ( event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus /> ) : ( )} {editing && !readOnly ? (
event.stopPropagation()}>
) : !readOnly ? (
event.stopPropagation()}> domEvent.stopPropagation(), items: [ { key: "export", icon: , label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "画布") }, { type: "divider" }, { key: "delete", danger: true, icon: , label: "删除", onClick: () => setDeleteIds([project.id]) }, ], }} >
) : null}
{projectName || "自由画布"}
{footer ? (
event.stopPropagation()}> {footer}
) : null}
); } export function ProjectPreview({ project, preferLatestImage = false }: { project: Pick; preferLatestImage?: boolean }) { const syncProgress = useSyncProgressStore((state) => state.syncingProjects[project.id]); const isSyncing = Boolean(syncProgress && (syncProgress.phase === "uploading" || syncProgress.phase === "saving")); const media = projectPreviewMedia(project.nodes, preferLatestImage); const content = media ? (
{media.node.type === CanvasNodeType.Video ? (
) : ( } loadingFallback={} /> )}
) : !project.nodes.length ? (
空白画布 等待第一幕
) : (
{buildNodePreviewLayout(project.nodes.slice(0, 8)).map(({ node, style }) => { const presentation = getNodePresentation(node); return ( {presentation.icon} {node.title || presentation.label} ); })}
); return (
{content} {isSyncing && syncProgress ? (
e.stopPropagation()}>
云端同步中
{syncProgress.total > 0 ? ( <>
媒体上传 {syncProgress.completed}/{syncProgress.total}
) : (
正在写入云端结构...
)}
请勿关闭或刷新浏览器
) : null}
); } export function projectPreviewMedia(nodes: CanvasNodeData[], preferLatestImage = false) { let cached = projectPreviewMediaCache.get(nodes); if (!cached) { let firstMedia: ProjectPreviewMedia | undefined; let firstImage: ProjectPreviewMedia | undefined; let latestMedia: ProjectPreviewMedia | undefined; let latestImage: ProjectPreviewMedia | undefined; for (const node of nodes) { if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) continue; const url = getNodeMediaUrl(node); if (!isPreviewUrl(url)) continue; const media = { node, url, storageKey: node.metadata?.storageKey }; firstMedia ||= media; latestMedia = media; if (node.type === CanvasNodeType.Image) { firstImage ||= media; latestImage = media; } } cached = { first: firstImage || firstMedia, latest: latestImage || latestMedia }; projectPreviewMediaCache.set(nodes, cached); } return preferLatestImage ? cached.latest : cached.first; } function getNodeMediaUrl(node: CanvasNodeData) { const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey); if (resourceId) return resourceFileUrl(resourceId); return resolveBackendApiUrl(node.metadata?.previewContent || node.metadata?.content || ""); } function buildNodePreviewLayout(nodes: CanvasNodeData[]) { const minX = Math.min(...nodes.map((node) => node.position.x)); const minY = Math.min(...nodes.map((node) => node.position.y)); const maxX = Math.max(...nodes.map((node) => node.position.x + node.width)); const maxY = Math.max(...nodes.map((node) => node.position.y + node.height)); const spanX = Math.max(maxX - minX, 1); const spanY = Math.max(maxY - minY, 1); return nodes.map((node) => { const left = 6 + ((node.position.x - minX) / spanX) * 70; const top = 8 + ((node.position.y - minY) / spanY) * 66; const width = Math.min(92 - left, Math.max(16, Math.min(38, (node.width / spanX) * 78))); const height = Math.min(94 - top, Math.max(13, Math.min(24, (node.height / spanY) * 72))); return { node, style: { left: `${left}%`, top: `${top}%`, width: `${width}%`, height: `${height}%` } }; }); } function getNodePresentation(node: CanvasNodeData) { switch (node.type) { case CanvasNodeType.Text: return { label: "文本", icon: }; case CanvasNodeType.Script: return { label: "分镜脚本", icon: }; case CanvasNodeType.Image: return { label: "图片", icon: }; case CanvasNodeType.Video: return { label: "视频", icon: