diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index d48d74e3d..d3aed32c3 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -128,6 +128,27 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"resource": resource}) }) + r.GET("/resources/:id/oss-url", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + resource, err := svc.Resource(user.ID, c.Param("id")) + if err != nil { + fail(c, http.StatusNotFound, err) + return + } + ossURL, err := svc.DirectResourceURL(user.ID, resource.ID) + if err != nil { + failService(c, err) + return + } + // 签名地址只用于当前复制动作,禁止浏览器或中间代理缓存。 + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + ok(c, gin.H{"url": ossURL}) + }) r.GET("/resources/:id/file", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 1321f6e86..7b4c70c5b 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -69,7 +69,7 @@ func (s *Service) DirectResourceURL(userID string, id string) (string, error) { return "", BadAuthRequest("资源尚未上传完成") } if resource.Provider == "local" { - return "", nil + return "", BadAuthRequest("当前资源未存储在 OSS") } setting, err := s.ossSettingForResource(userID, resource) if err != nil { diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 4df865d73..ff09da4a4 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -1,11 +1,12 @@ import { AnimatePresence, useReducedMotion } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { ChevronRight, Clapperboard, Clipboard, Copy, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Maximize2, MessageSquare, Music2, PanelTop, Plus, Redo2, Settings2, Trash2, Type, Undo2, Upload, Video } from "lucide-react"; +import { ChevronRight, Clapperboard, Clipboard, Copy, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, MessageSquare, Music2, PanelTop, Plus, Redo2, Settings2, Trash2, Type, Undo2, Upload, Video } from "lucide-react"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { CanvasCreateCommandGrid, type CanvasCreateCommand } from "@/components/canvas/canvas-create-command-grid"; import { canvasThemes } from "@/lib/canvas-theme"; +import { resourceIdFromStorageKey } from "@/services/api/resources"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData, type CanvasWorkspaceMode, type ContextMenuState, type Position } from "@/types/canvas"; @@ -34,6 +35,7 @@ type CanvasNodeContextMenuProps = { onEditText: () => void; onGenerateImage: () => void; onCopyContent: () => void; + onCopyOssUrl: () => void; onToggleFrame: () => void; }; @@ -62,6 +64,7 @@ export function CanvasNodeContextMenu({ onEditText, onGenerateImage, onCopyContent, + onCopyOssUrl, onToggleFrame, }: CanvasNodeContextMenuProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; @@ -104,6 +107,7 @@ export function CanvasNodeContextMenu({ const canOpenPreview = Boolean(isImage && hasNodeContent); const canOpenEditor = Boolean(node && (isText || isImage || isVideo)); const canGenerateFromText = Boolean(isText && hasNodeContent); + const canCopyOssUrl = Boolean((isImage || isVideo) && resourceIdFromStorageKey(node?.metadata?.storageKey)); const position = getContextMenuPosition(menu); return ( @@ -146,6 +150,7 @@ export function CanvasNodeContextMenu({ } label={isFrame ? "复制背板及内容" : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} {isText ? } label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} + {isImage || isVideo ? } label="复制 OSS 地址" disabled={!canCopyOssUrl} onClick={() => runAction(onCopyOssUrl)} /> : null} } label={isFrame ? "创建背板副本" : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} /> } label={isFrame ? "删除背板" : "删除节点"} danger onClick={() => runAction(onDelete)} /> diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx index 0f9d94b9e..03ccd3d7a 100644 --- a/web/src/pages/canvas/canvas-project-context-menu.tsx +++ b/web/src/pages/canvas/canvas-project-context-menu.tsx @@ -28,6 +28,7 @@ type CanvasProjectContextMenuProps = { onEditText: (node: CanvasNodeData) => void; onGenerateImage: (node: CanvasNodeData) => void; onCopyContent: (node: CanvasNodeData | null) => void; + onCopyOssUrl: (node: CanvasNodeData | null) => void; onToggleFrame: (node: CanvasNodeData) => void; }; @@ -81,6 +82,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props if (node) props.onGenerateImage(node); }} onCopyContent={() => props.onCopyContent(node)} + onCopyOssUrl={() => props.onCopyOssUrl(node)} onToggleFrame={() => { if (node?.type === CanvasNodeType.Frame) props.onToggleFrame(node); }} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index aaff37610..352cab18f 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -3,6 +3,8 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { useParams, useSearchParams } from "react-router"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { uploadMediaFile } from "@/services/file-storage"; +import { getResourceOSSUrl } from "@/services/api/resources"; +import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; @@ -914,6 +916,20 @@ function InfiniteCanvasPage() { [message], ); + const copyNodeOssUrlToClipboard = useCallback( + async (node: CanvasNodeData | null) => { + try { + const ossURL = await getResourceOSSUrl(node?.metadata?.storageKey); + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(ossURL); + else if (!copyToClipboard(ossURL)) throw new Error("当前浏览器不支持写入剪贴板"); + message.success("临时 OSS 地址已复制"); + } catch (error) { + message.error(error instanceof Error ? error.message : "复制 OSS 地址失败"); + } + }, + [message], + ); + const handleCanvasContextMenu = useCallback( (event: ReactMouseEvent) => { const target = event.target instanceof Element ? event.target : null; @@ -1439,6 +1455,7 @@ function InfiniteCanvasPage() { onEditText={openTextEditor} onGenerateImage={generateImageFromTextNode} onCopyContent={(node) => { void copyNodeContentToClipboard(node); }} + onCopyOssUrl={(node) => { void copyNodeOssUrlToClipboard(node); }} onToggleFrame={(node) => toggleFrameCollapsed(node.id)} /> diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index d46c399cd..d89c4f2b2 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -115,6 +115,19 @@ export function getResource(id: string): Promise { return task; } +export async function getResourceOSSUrl(storageKey?: string) { + const id = resourceIdFromStorageKey(storageKey); + if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); + try { + const data = await request<{ url: string }>(api.get(`/resources/${encodeURIComponent(id)}/oss-url`)); + if (!data.url) throw new Error("后端未返回 OSS 地址"); + return data.url; + } catch (error) { + if (axios.isAxiosError>(error)) throw new Error(error.response?.data.msg || error.message || "获取 OSS 地址失败"); + throw error; + } +} + function resourceCacheKey(id: string) { return `${getActiveUserScope()}:${id}`; }