feat(canvas): 媒体地址 - 图片和视频右键支持复制私有 OSS 签名地址
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user