From 27d58cfb5d169cd100d1ebb367942aff809953bc Mon Sep 17 00:00:00 2001 From: chiyi Date: Thu, 8 Oct 2026 17:45:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=20Memphis=20?= =?UTF-8?q?=E5=8C=96=E5=B9=B6=E8=A1=A5=E5=85=A8=E7=BC=96=E8=BE=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 POST /admin/api/my-images 上传接口:图片存入用户 owner 目录并生成缩略图,复用 /images 服务与 my-images picker - 本地图片三种入画布路径:上传按钮 / 拖入舞台(落点即拖放 位置) / Ctrl+V 粘贴 - 节点选中后显示悬浮工具条(预览/复制/置顶/删除)与右下角 缩放手柄;图片等比缩放,便签自由缩放;Ctrl+D 复制选中(含 相互连线);双击空白建笔记;按 1 / ⤢ 按钮适应全部内容 - 整套画布 UI 换 Memphis:纸张底+墨点网格、黑边硬影节点、 黄色贴纸便签、橙色连线/选中态、虚线框选、顶栏/生成面板/ 预览灯箱/图片选择器全部换墨边 chunky 样式,随站点明暗主题 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/http/handler/user_generation.go | 150 ++++--- backend/internal/http/router/router.go | 1 + backend/internal/service/upload.go | 50 +++ .../src/components/canvas/CanvasNodeView.vue | 110 +++-- .../src/components/canvas/CanvasStage.vue | 125 +++++- frontend/src/views/CanvasView.vue | 375 +++++++++++++----- 6 files changed, 625 insertions(+), 186 deletions(-) create mode 100644 backend/internal/service/upload.go diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 9e43c20..f7b746e 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -1,7 +1,13 @@ package handler import ( + "bytes" "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" "net/http" "strings" "sync" @@ -9,6 +15,7 @@ import ( "backend/internal/service" "github.com/gin-gonic/gin" + _ "golang.org/x/image/webp" ) type UserGenerationHandler struct { @@ -91,6 +98,45 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": items}) } +// UploadMyImage stores one user-supplied image (multipart field "file") under +// the caller's owner directory, so it can be placed on the infinite canvas and +// shows up in the 我的图片 picker alongside generated outputs. Returns the +// storage key ("owner/name") the frontend feeds to generatedUrl/thumbUrl. +func (h *UserGenerationHandler) UploadMyImage(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 20<<20) + f, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请选择图片文件"}) + return + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, 15<<20+1)) + if err != nil || len(data) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "读取文件失败"}) + return + } + if len(data) > 15<<20 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "图片不能超过 15MB"}) + return + } + _, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "不是有效的图片文件(支持 png/jpg/webp/gif)"}) + return + } + key, err := h.admin.SaveOwnedImage(c.Request.Context(), service.OwnerDir(user), format, data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"name": key}) +} + // DeleteMyFile removes ONE of the caller's own generated files (plus its // thumbnail) and blanks the log rows referencing it, so the 画图台 grid and // 创作记录 gallery stop showing it. ?file= is the storage key (owner/name). @@ -430,13 +476,13 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { "reference_mode": "frame", }, { - "key": "gemini-veo3.1", - "label": "Veo 3.1", - "type": "video", - "provider": "adobe", - "durations": []string{"4s", "6s", "8s"}, - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p", "1080p"}, + "key": "gemini-veo3.1", + "label": "Veo 3.1", + "type": "video", + "provider": "adobe", + "durations": []string{"4s", "6s", "8s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p", "1080p"}, }, { "key": "adobe-seedance-2.0-fast", @@ -461,13 +507,13 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { "reference_mode": "style", }, { - "key": "seedance-2.0-fast", - "label": "Seedance 2.0 Fast (Creative Fabrica)", - "type": "video", - "provider": "creativefabrica", - "durations": []string{"14s"}, - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, + "key": "seedance-2.0-fast", + "label": "Seedance 2.0 Fast (Creative Fabrica)", + "type": "video", + "provider": "creativefabrica", + "durations": []string{"14s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, // Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE), // 没有首尾帧,也不收视频/音频参考。 "max_reference_images": 9, @@ -476,13 +522,13 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { "max_audios": 0, }, { - "key": "seedance-2.0", - "label": "Seedance 2.0 (Creative Fabrica)", - "type": "video", - "provider": "creativefabrica", - "durations": []string{"10s"}, - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, + "key": "seedance-2.0", + "label": "Seedance 2.0 (Creative Fabrica)", + "type": "video", + "provider": "creativefabrica", + "durations": []string{"10s"}, + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, // Creative Fabrica 上游只有普通参考图(VIDEO_FRAME_TYPE_REFERENCE), // 没有首尾帧,也不收视频/音频参考。 "max_reference_images": 9, @@ -729,26 +775,26 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "description": "Seedance 2.0", }, { - "id": "seedance-2.0-fast", - "provider": "creativefabrica", - "type": "video", - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, + "id": "seedance-2.0-fast", + "provider": "creativefabrica", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, // 一次性账号:积分刚好够一次生成,时长固定 14 秒。 - "durations": []string{"14s"}, + "durations": []string{"14s"}, // Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。 "max_reference_images": 9, "reference_mode": "asset", "description": "Seedance 2.0 Fast (Creative Fabrica)", }, { - "id": "seedance-2.0", - "provider": "creativefabrica", - "type": "video", - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, + "id": "seedance-2.0", + "provider": "creativefabrica", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, // 一次性账号:积分刚好够一次生成,时长固定 10 秒。 - "durations": []string{"10s"}, + "durations": []string{"10s"}, // Creative Fabrica 上游只有普通参考图,没有首尾帧,也不收视频/音频参考。 "max_reference_images": 9, "reference_mode": "asset", @@ -766,11 +812,11 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "description": "Runway Gen-4 Turbo video (图生视频)", }, { - "id": "grok-video", - "provider": "grok", - "type": "video", - "ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"}, - "resolutions": []string{"720p"}, + "id": "grok-video", + "provider": "grok", + "type": "video", + "ratios": []string{"2:3", "3:2", "1:1", "9:16", "16:9"}, + "resolutions": []string{"720p"}, // Console 视频吃 1–15 秒的整数时长,所以按秒计价(同 seedance); // 参考图只有 1 张首帧(上游只有 image 字段,没有尾帧)。 "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, @@ -801,13 +847,13 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "description": "Leonardo Seedream 4.5 (生图 / 图生图)", }, { - "id": "seedance-2.0-不卡人脸", - "provider": "leonardo", - "type": "video", - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, - "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, - "per_second": true, + "id": "seedance-2.0-不卡人脸", + "provider": "leonardo", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"720p"}, + "durations": []string{"4s", "5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + "per_second": true, // 参考资产总上限 8 = 4 图 + 1 音频 + 3 视频(分类上限在服务端校验)。 "max_reference_images": 8, "reference_mode": "style", @@ -826,14 +872,14 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) "description": "Leonardo Seedance 2.0 Fast (私有生成 / 图音视频参考)", }, { - "id": "minimax-h3", - "provider": "leonardo", - "type": "video", - "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"1440p"}, + "id": "minimax-h3", + "provider": "leonardo", + "type": "video", + "ratios": []string{"16:9", "9:16"}, + "resolutions": []string{"1440p"}, // 上游吃 5–15 秒整数时长,所以按秒计价(后台只填一个 /s 价)。 - "durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, - "per_second": true, + "durations": []string{"5s", "6s", "7s", "8s", "9s", "10s", "11s", "12s", "13s", "14s", "15s"}, + "per_second": true, // 参考资产总上限 8 = 5 图 + 3 音频(分类上限在服务端校验)。 "max_reference_images": 8, "reference_mode": "style", diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index dadae48..8ff660b 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -105,6 +105,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. userAuthed.POST("/test", handlers.UserGen.Test) userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs) userAuthed.GET("/my-images", handlers.UserGen.MyImages) + userAuthed.POST("/my-images", handlers.UserGen.UploadMyImage) userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile) userAuthed.GET("/announcement", handlers.Announcement.Get) userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen) diff --git a/backend/internal/service/upload.go b/backend/internal/service/upload.go new file mode 100644 index 0000000..3281589 --- /dev/null +++ b/backend/internal/service/upload.go @@ -0,0 +1,50 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "strings" + "time" + + _ "golang.org/x/image/webp" +) + +// SaveOwnedImage stores a user-uploaded image under their owner directory so it +// behaves like a generated output: it shows up in the 我的图片 picker, can be +// dropped onto the infinite canvas, and is served by /images//. +// format must be a decoder-registered image format ("png", "jpeg", ...). +func (s *AdminReadService) SaveOwnedImage(ctx context.Context, owner, format string, data []byte) (string, error) { + owner = strings.TrimSpace(owner) + if owner == "" || s.store == nil || !s.store.Configured() { + return "", errors.New("storage not configured") + } + ext := strings.ToLower(strings.TrimSpace(format)) + contentType := "" + switch ext { + case "jpeg": + ext = "jpg" + contentType = "image/jpeg" + case "png", "gif", "webp": + contentType = "image/" + ext + default: + return "", errors.New("unsupported image format") + } + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return "", errors.New("invalid image data") + } + key := fmt.Sprintf("%s/up_%d%s.%s", owner, time.Now().Unix(), randomUpper(6), ext) + if err := s.store.Put(ctx, key, data, contentType); err != nil { + return "", err + } + // Best-effort thumbnail, same convention as generated outputs. + if thumb, terr := makeThumbnail(data); terr == nil { + _ = s.store.Put(ctx, ThumbKey(key), thumb, "image/jpeg") + } + return key, nil +} diff --git a/frontend/src/components/canvas/CanvasNodeView.vue b/frontend/src/components/canvas/CanvasNodeView.vue index 4c1b7f1..383f1ff 100644 --- a/frontend/src/components/canvas/CanvasNodeView.vue +++ b/frontend/src/components/canvas/CanvasNodeView.vue @@ -101,6 +101,14 @@ defineExpose({ startEdit, commitNote }) > + + +
+ + + + +