feat(canvas): 媒体地址 - 图片和视频右键支持复制私有 OSS 签名地址

This commit is contained in:
ddCat
2026-07-23 20:10:58 +08:00
parent b832668813
commit fca7c95b64
6 changed files with 60 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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({
<MenuButton icon={<Copy className="size-4" />} label={isFrame ? "复制背板及内容" : "复制节点"} shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
{isImage ? <MenuButton icon={<ImageIcon className="size-4" />} label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
{isText ? <MenuButton icon={<Clipboard className="size-4" />} label="复制文本" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
{isImage || isVideo ? <MenuButton icon={<Link2 className="size-4" />} label="复制 OSS 地址" disabled={!canCopyOssUrl} onClick={() => runAction(onCopyOssUrl)} /> : null}
<MenuButton icon={<Copy className="size-4" />} label={isFrame ? "创建背板副本" : "创建参数变体"} shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
<MenuButton icon={<Clipboard className="size-4" />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
<MenuButton icon={<Trash2 className="size-4" />} label={isFrame ? "删除背板" : "删除节点"} danger onClick={() => runAction(onDelete)} />

View File

@@ -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);
}}

View File

@@ -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)}
/>

View File

@@ -115,6 +115,19 @@ export function getResource(id: string): Promise<RemoteResource> {
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<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取 OSS 地址失败");
throw error;
}
}
function resourceCacheKey(id: string) {
return `${getActiveUserScope()}:${id}`;
}