feat(canvas): 画布 Memphis 化并补全编辑功能

- 后端新增 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>
This commit is contained in:
2026-10-08 17:45:41 +08:00
co-authored by Devin
parent d0506b0d2e
commit 27d58cfb5d
6 changed files with 625 additions and 186 deletions
@@ -1,7 +1,13 @@
package handler package handler
import ( import (
"bytes"
"errors" "errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
@@ -9,6 +15,7 @@ import (
"backend/internal/service" "backend/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
) )
type UserGenerationHandler struct { type UserGenerationHandler struct {
@@ -91,6 +98,45 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items}) 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 // DeleteMyFile removes ONE of the caller's own generated files (plus its
// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and // thumbnail) and blanks the log rows referencing it, so the 画图台 grid and
// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name). // 创作记录 gallery stop showing it. ?file= is the storage key (owner/name).
+1
View File
@@ -105,6 +105,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
userAuthed.POST("/test", handlers.UserGen.Test) userAuthed.POST("/test", handlers.UserGen.Test)
userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs) userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs)
userAuthed.GET("/my-images", handlers.UserGen.MyImages) userAuthed.GET("/my-images", handlers.UserGen.MyImages)
userAuthed.POST("/my-images", handlers.UserGen.UploadMyImage)
userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile) userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile)
userAuthed.GET("/announcement", handlers.Announcement.Get) userAuthed.GET("/announcement", handlers.Announcement.Get)
userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen) userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen)
+50
View File
@@ -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/<owner>/<name>.
// 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
}
@@ -101,6 +101,14 @@ defineExpose({ startEdit, commitNote })
> >
<!-- 连线出口:拖到目标节点创建连线(舞台接管手势) --> <!-- 连线出口:拖到目标节点创建连线(舞台接管手势) -->
<span v-if="node.type !== 'gen'" class="cv-port" data-port="out" /> <span v-if="node.type !== 'gen'" class="cv-port" data-port="out" />
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
<div v-if="selected && node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
<button v-if="node.type === 'image'" title="预览" @click.stop="emit('node-action', { type: 'preview' })">⤢</button>
<button title="复制 (Ctrl+D)" @click.stop="emit('node-action', { type: 'duplicate' })">⧉</button>
<button title="置顶" @click.stop="emit('node-action', { type: 'front' })">⤒</button>
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
</div>
<!-- 图片节点 --> <!-- 图片节点 -->
<template v-if="node.type === 'image'"> <template v-if="node.type === 'image'">
<img <img
@@ -144,17 +152,19 @@ defineExpose({ startEdit, commitNote })
<style scoped> <style scoped>
.cv-node { .cv-node {
position: absolute; position: absolute;
border-radius: 10px; border-radius: 14px;
border: 2.5px solid var(--ink);
overflow: hidden; overflow: hidden;
contain: layout style paint; contain: layout style paint;
user-select: none; user-select: none;
touch-action: none; touch-action: none;
cursor: grab; cursor: grab;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35); background: var(--surface);
box-shadow: 5px 5px 0 var(--shadow-ink);
transition: box-shadow 0.15s ease;
} }
.cv-node.is-selected { .cv-node.is-selected {
outline: 2px solid #7c6cff; box-shadow: 7px 7px 0 var(--m-orange);
outline-offset: 2px;
} }
.cv-node:active { .cv-node:active {
cursor: grabbing; cursor: grabbing;
@@ -165,7 +175,7 @@ defineExpose({ startEdit, commitNote })
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
background: #14161d; background: var(--surface-2);
} }
.cv-node__cap { .cv-node__cap {
@@ -173,20 +183,22 @@ defineExpose({ startEdit, commitNote })
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 14px 8px 5px; padding: 5px 9px;
font-size: 11px; font-size: 11px;
font-weight: 600;
line-height: 1.35; line-height: 1.35;
color: #d6d9e2; color: var(--fg);
background: linear-gradient(transparent, rgb(8 9 13 / 0.92)); background: var(--surface);
border-top: 2.5px solid var(--ink);
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
} }
/* 笔记 = 孟菲斯黄贴纸 */
.cv-node--note { .cv-node--note {
background: #23262f; background: var(--m-yellow);
border: 1px solid #343947;
cursor: text; cursor: text;
} }
.cv-node--note:not(.is-editing) { .cv-node--note:not(.is-editing) {
@@ -197,8 +209,8 @@ defineExpose({ startEdit, commitNote })
height: 100%; height: 100%;
padding: 10px 12px; padding: 10px 12px;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.55;
color: #e8eaf0; color: #1a1a1a;
outline: none; outline: none;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -216,21 +228,23 @@ defineExpose({ startEdit, commitNote })
gap: 10px; gap: 10px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #1a1d26; background: var(--surface);
border: 1.5px dashed #4a4f61; border-style: dashed;
color: #9aa1b2; color: var(--fg-2);
font-size: 13px; font-size: 13px;
font-weight: 600;
} }
.cv-node--gen.is-failed { .cv-node--gen.is-failed {
border-color: #6b3b44; border-color: #e5484d;
color: #e58c96; color: #e5484d;
background: #ffe4e6;
} }
.cv-node__spin { .cv-node__spin {
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
border: 2.5px solid #3b4152; border: 3px solid var(--ink-soft);
border-top-color: #7c6cff; border-top-color: var(--m-orange);
animation: cv-spin 0.9s linear infinite; animation: cv-spin 0.9s linear infinite;
} }
@keyframes cv-spin { @keyframes cv-spin {
@@ -246,21 +260,67 @@ defineExpose({ startEdit, commitNote })
.cv-port { .cv-port {
position: absolute; position: absolute;
right: -8px; right: -5px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 13px; width: 15px;
height: 13px; height: 15px;
border-radius: 50%; border-radius: 50%;
background: #7c6cff; background: var(--m-orange);
border: 2.5px solid #0e1015; border: 2.5px solid var(--ink);
opacity: 0; opacity: 0;
transition: opacity 0.12s; transition: opacity 0.12s;
cursor: crosshair; cursor: crosshair;
z-index: 2; z-index: 3;
} }
.cv-node:hover .cv-port, .cv-node:hover .cv-port,
.cv-node.is-selected .cv-port { .cv-node.is-selected .cv-port {
opacity: 1; opacity: 1;
} }
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
.cv-size {
position: absolute;
right: 4px;
bottom: 4px;
width: 15px;
height: 15px;
border-radius: 4px;
background: var(--m-blue);
border: 2.5px solid var(--ink);
cursor: nwse-resize;
z-index: 3;
}
/* 选中悬浮工具条:贴节点内上缘(节点 overflow:hidden,外侧会被裁) */
.cv-tools {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 4px;
z-index: 4;
}
.cv-tools button {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2.5px solid var(--ink);
background: var(--m-yellow);
color: #1a1a1a;
font-size: 12px;
line-height: 1;
cursor: pointer;
box-shadow: 2.5px 2.5px 0 var(--shadow-ink);
display: grid;
place-items: center;
padding: 0;
}
.cv-tools button:hover {
transform: translate(1px, 1px);
box-shadow: 1.5px 1.5px 0 var(--shadow-ink);
}
.cv-tools__del {
background: #ffb4ab !important;
}
</style> </style>
+107 -18
View File
@@ -27,6 +27,7 @@ const emit = defineEmits([
'img-size', // 图片真实尺寸校正 -> { id, w, h } 'img-size', // 图片真实尺寸校正 -> { id, w, h }
'viewport-rest', // 视口静止 700ms -> viewport 'viewport-rest', // 视口静止 700ms -> viewport
'pan-zoom', // 视口变化(用于百分比显示,高频) 'pan-zoom', // 视口变化(用于百分比显示,高频)
'blank-dblclick', // 双击空白 -> { x, y } 世界坐标(父级建笔记)
]) ])
const stageEl = ref(null) const stageEl = ref(null)
@@ -186,6 +187,25 @@ function onPointerDown(e) {
// 文本编辑中完全放行 // 文本编辑中完全放行
if (e.target.closest?.('.cv-node--note.is-editing')) return if (e.target.closest?.('.cv-node--note.is-editing')) return
// 0) 缩放手柄:右下角墨边方块,拖动能改节点尺寸
const sizeGrip = e.target.closest?.('[data-resize]')
if (sizeGrip && e.button === 0) {
const node = hitNode(e)
if (node) {
clearEdgeSel()
setSelection(new Set([node.id]))
const start = screenToWorld(localXY(e).x, localXY(e).y, vp)
act = {
mode: 'resize', node,
startX: start.x, startY: start.y,
w0: node.w, h0: node.h,
aspect: node.h > 0 ? node.w / node.h : 1,
}
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
return
}
}
// 1) 连线出口:拖到目标节点建边 // 1) 连线出口:拖到目标节点建边
const port = e.target.closest?.('[data-port]') const port = e.target.closest?.('[data-port]')
if (port && e.button === 0) { if (port && e.button === 0) {
@@ -292,6 +312,20 @@ function applyMove(e) {
} }
return return
} }
if (act.mode === 'resize') {
const cur = screenToWorld(p.x, p.y, vp)
const n = act.node
if (n.type === 'note') {
n.w = Math.max(140, Math.round(act.w0 + cur.x - act.startX))
n.h = Math.max(90, Math.round(act.h0 + cur.y - act.startY))
} else {
// 图片锁长宽比:以宽度为准推导高度
const w = Math.max(96, act.w0 + cur.x - act.startX)
n.w = Math.round(w)
n.h = Math.round(w / act.aspect)
}
return
}
if (act.mode === 'link' && linkLine.value) { if (act.mode === 'link' && linkLine.value) {
const cur = screenToWorld(p.x, p.y, vp) const cur = screenToWorld(p.x, p.y, vp)
linkLine.value.x2 = cur.x linkLine.value.x2 = cur.x
@@ -320,6 +354,7 @@ function onPointerUp(e) {
panning.value = false panning.value = false
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ } try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
if (a.mode === 'drag' && a.moved) emit('move-end') if (a.mode === 'drag' && a.moved) emit('move-end')
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
if (a.mode === 'link') { if (a.mode === 'link') {
const target = hitNode(e) const target = hitNode(e)
if (target && target.id !== a.fromId) emit('link-create', { from: a.fromId, to: target.id }) if (target && target.id !== a.fromId) emit('link-create', { from: a.fromId, to: target.id })
@@ -328,6 +363,7 @@ function onPointerUp(e) {
} }
function onPointerCancel() { function onPointerCancel() {
if (act?.mode === 'drag' && act.moved) emit('move-end') if (act?.mode === 'drag' && act.moved) emit('move-end')
if (act?.mode === 'resize') emit('move-end')
act = null act = null
marquee.value = null marquee.value = null
linkLine.value = null linkLine.value = null
@@ -390,6 +426,14 @@ function setSelection(s) {
selection.value = s selection.value = s
emit('selection', [...s]) emit('selection', [...s])
} }
// 父级复制/成组操作后同步舞台内部选择集
function selectIds(ids) {
setSelection(new Set(ids))
}
function bumpNode(id) {
const n = nodeById.value.get(id)
if (n) bumpZ(n)
}
// ---- 对外 API ---- // ---- 对外 API ----
function setViewport(v) { function setViewport(v) {
@@ -412,6 +456,37 @@ function centerOn(x, y) {
function clearSelection() { function clearSelection() {
if (selection.value.size) setSelection(new Set()) if (selection.value.size) setSelection(new Set())
} }
// 双击空白:交给父级在落点建笔记
function onDblClick(e) {
if (e.target.closest?.('[data-node-id]') || e.target.closest?.('.cv-edge-hit')) return
const p = localXY(e)
const w = screenToWorld(p.x, p.y, vp)
emit('blank-dblclick', { x: w.x, y: w.y })
}
// 适应内容:把所有节点缩放进视口(留 80px 边距,最大放到 1.2x)
function fitToContent() {
const r = stageEl.value?.getBoundingClientRect()
const sw = r ? r.width : 800
const sh = r ? r.height : 600
const nodes = props.nodes
if (!nodes.length) {
setViewport({ x: 0, y: 0, scale: 1 })
return
}
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
for (const n of nodes) {
x1 = Math.min(x1, n.x); y1 = Math.min(y1, n.y)
x2 = Math.max(x2, n.x + n.w); y2 = Math.max(y2, n.y + n.h)
}
const pad = 80
const bw = Math.max(1, x2 - x1 + pad * 2)
const bh = Math.max(1, y2 - y1 + pad * 2)
const s = Math.min(sw / bw, sh / bh, 1.2)
vp.scale = s
vp.x = sw / 2 - (x1 - pad + bw / 2) * s
vp.y = sh / 2 - (y1 - pad + bh / 2) * s
notifyViewport()
}
function selectedIds() { function selectedIds() {
return [...selection.value] return [...selection.value]
} }
@@ -430,7 +505,7 @@ function screenRectToWorld(r) {
} }
defineExpose({ defineExpose({
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp, setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
cancelLink, consumeSelectedEdges, hasSelectedEdges, cancelLink, consumeSelectedEdges, hasSelectedEdges, fitToContent, selectIds, bumpNode,
}) })
</script> </script>
@@ -444,6 +519,7 @@ defineExpose({
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
@pointercancel="onPointerCancel" @pointercancel="onPointerCancel"
@dblclick="onDblClick"
@contextmenu.prevent @contextmenu.prevent
> >
<div class="cv-world" :style="worldStyle"> <div class="cv-world" :style="worldStyle">
@@ -484,8 +560,12 @@ defineExpose({
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
touch-action: none; touch-action: none;
background-color: #0e1015; background-color: var(--app-bg);
background-image: radial-gradient(circle, rgb(255 255 255 / 0.07) 1px, transparent 1px); background-image: radial-gradient(
circle,
color-mix(in srgb, var(--ink) 18%, transparent) 1.3px,
transparent 1.3px
);
} }
.cv-stage.is-panning { .cv-stage.is-panning {
cursor: grab; cursor: grab;
@@ -509,17 +589,17 @@ defineExpose({
} }
.cv-edge { .cv-edge {
fill: none; fill: none;
stroke: rgb(140 146 165 / 0.5); stroke: var(--ink-soft);
stroke-width: 1.5;
}
.cv-edge--hot {
stroke: #7c6cff;
stroke-width: 2; stroke-width: 2;
} }
.cv-edge--sel { .cv-edge--hot {
stroke: #ff7b8f; stroke: var(--m-orange);
stroke-width: 2.5; stroke-width: 2.5;
filter: drop-shadow(0 0 3px rgb(255 123 143 / 0.6)); }
.cv-edge--sel {
stroke: var(--m-orange);
stroke-width: 3.5;
stroke-dasharray: 10 6;
} }
.cv-edge-hit { .cv-edge-hit {
fill: none; fill: none;
@@ -530,17 +610,17 @@ defineExpose({
} }
.cv-linkline { .cv-linkline {
fill: none; fill: none;
stroke: #a99dff; stroke: var(--m-orange);
stroke-width: 2; stroke-width: 2.5;
stroke-dasharray: 7 6; stroke-dasharray: 8 6;
pointer-events: none; pointer-events: none;
} }
.cv-marquee { .cv-marquee {
position: absolute; position: absolute;
border: 1px solid #7c6cff; border: 2px dashed var(--ink);
background: rgb(124 108 255 / 0.12); background: color-mix(in srgb, var(--m-yellow) 35%, transparent);
pointer-events: none; pointer-events: none;
border-radius: 2px; border-radius: 4px;
} }
.cv-hint { .cv-hint {
position: absolute; position: absolute;
@@ -548,8 +628,17 @@ defineExpose({
bottom: 10px; bottom: 10px;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 11px; font-size: 11px;
color: rgb(255 255 255 / 0.28); font-weight: 600;
color: var(--fg-2);
background: var(--surface);
border: 2px solid var(--ink);
border-radius: 999px;
padding: 3px 14px;
box-shadow: 3px 3px 0 var(--shadow-ink);
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
max-width: 92%;
overflow: hidden;
text-overflow: ellipsis;
} }
</style> </style>
+283 -90
View File
@@ -49,6 +49,9 @@ const selectedNotes = computed(() =>
const pickerOpen = ref(false) const pickerOpen = ref(false)
const pickerItems = ref([]) const pickerItems = ref([])
const pickerLoading = ref(false) const pickerLoading = ref(false)
const fileInput = ref(null)
const uploadBusy = ref(false)
const dropHover = ref(false)
// ================= 文档加载 / 保存 ================= // ================= 文档加载 / 保存 =================
@@ -285,18 +288,21 @@ function redo() {
// ================= 节点操作 ================= // ================= 节点操作 =================
function addNote() { function addNote() {
const c = viewportCenterWorld() addNoteAt(viewportCenterWorld())
}
function addNoteAt(w) {
const n = { const n = {
id: newId('t'), type: 'note', text: '', id: newId('t'), type: 'note', text: '',
x: Math.round(c.x - 130), y: Math.round(c.y - 70), w: 260, h: 140, z: 0, x: Math.round(w.x - 130), y: Math.round(w.y - 70), w: 260, h: 140, z: 0,
} }
doc.nodes.push(n) doc.nodes.push(n)
selectionIds.value = [n.id] selectionIds.value = [n.id]
pushHistory() pushHistory()
} }
function addImageNode(src, prompt = '') { function addImageNode(src, prompt = '', at = null) {
const c = viewportCenterWorld() const c = at || viewportCenterWorld()
const n = { const n = {
id: newId('i'), type: 'image', src, prompt, id: newId('i'), type: 'image', src, prompt,
x: Math.round(c.x - 256 + cascade()), y: Math.round(c.y - 256 + cascade()), x: Math.round(c.x - 256 + cascade()), y: Math.round(c.y - 256 + cascade()),
@@ -322,6 +328,32 @@ function viewportCenterWorld() {
return screenToWorld(w / 2, h / 2, vp) return screenToWorld(w / 2, h / 2, vp)
} }
// ---- 复制选中节点(带相互连线,偏移落位) ----
function duplicateSelection() {
const sel = doc.nodes.filter((n) => selectionIds.value.includes(n.id) && n.type !== 'gen')
if (!sel.length) return
const idMap = {}
const clones = sel.map((n) => {
const c = {
...n,
id: newId(n.type === 'note' ? 't' : 'i'),
x: n.x + 36,
y: n.y + 36,
}
idMap[n.id] = c.id
return c
})
doc.nodes.push(...clones)
for (const e of [...doc.edges]) {
if (idMap[e.from] && idMap[e.to]) {
doc.edges.push({ id: newId('e'), from: idMap[e.from], to: idMap[e.to] })
}
}
selectionIds.value = clones.map((c) => c.id)
stage.value?.selectIds?.(selectionIds.value)
pushHistory()
}
function deleteSelection() { function deleteSelection() {
const dead = new Set(selectionIds.value) const dead = new Set(selectionIds.value)
const deadEdges = stage.value?.consumeSelectedEdges() || [] const deadEdges = stage.value?.consumeSelectedEdges() || []
@@ -403,6 +435,60 @@ async function openPicker() {
pickerLoading.value = false pickerLoading.value = false
} }
// ---- 上传本地图片(按钮/拖拽/粘贴三条路径都汇到这里) ----
async function uploadImageFile(file, at = null) {
if (!file || !file.type?.startsWith('image/')) return
uploadBusy.value = true
try {
const fd = new FormData()
fd.append('file', file)
const r = await api('/my-images', { method: 'POST', body: fd })
if (r.ok && r.data?.name) {
pickerItems.value.unshift({ name: r.data.name })
addImageNode(r.data.name, '', at)
} else {
genMsg.value = r.data?.detail || `上传失败 (${r.status})`
}
} catch (e) {
genMsg.value = String(e)
} finally {
uploadBusy.value = false
}
}
function onFilePicked(e) {
const files = [...(e.target.files || [])]
e.target.value = ''
files.forEach((f) => uploadImageFile(f))
}
// 拖拽本地图片落到画布:落点即拖放位置
function onDrop(e) {
dropHover.value = false
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith('image/'))
if (!files.length) return
const el = document.querySelector('.cv-stage')
const rect = el?.getBoundingClientRect()
const vp = stage.value?.vp
const at =
rect && vp ? screenToWorld(e.clientX - rect.left, e.clientY - rect.top, vp) : null
files.forEach((f) => uploadImageFile(f, at))
}
function onPaste(e) {
if (isTyping(e.target)) return
for (const it of e.clipboardData?.items || []) {
if (it.type?.startsWith('image/')) {
const f = it.getAsFile()
if (f) {
e.preventDefault()
uploadImageFile(f)
return
}
}
}
}
// ================= 生成 ================= // ================= 生成 =================
async function refToBase64(name) { async function refToBase64(name) {
@@ -540,9 +626,20 @@ function onPanZoom(v) {
scalePct.value = Math.round(v.scale * 100) scalePct.value = Math.round(v.scale * 100)
} }
function onNodeAction({ type, id }) { function onNodeAction({ type, id }) {
if (type !== 'preview') return
const n = doc.nodes.find((x) => x.id === id) const n = doc.nodes.find((x) => x.id === id)
if (n?.type === 'image') previewSrc.value = n.src if (!n) return
if (type === 'preview') {
if (n.type === 'image') previewSrc.value = n.src
} else if (type === 'duplicate') {
selectionIds.value = [id]
duplicateSelection()
} else if (type === 'delete') {
selectionIds.value = [id]
deleteSelection()
} else if (type === 'front') {
stage.value?.bumpNode?.(id)
markDirty()
}
} }
// ================= 快捷键 ================= // ================= 快捷键 =================
@@ -567,6 +664,9 @@ function onKeydown(e) {
} else if (mod && e.key.toLowerCase() === 'a') { } else if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault() e.preventDefault()
stage.value?.selectAll() stage.value?.selectAll()
} else if (mod && e.key.toLowerCase() === 'd') {
e.preventDefault()
duplicateSelection()
} else if (e.key === 'Delete' || e.key === 'Backspace') { } else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault() e.preventDefault()
deleteSelection() deleteSelection()
@@ -581,6 +681,8 @@ function onKeydown(e) {
stage.value?.zoomBy(1 / 1.2) stage.value?.zoomBy(1 / 1.2)
} else if (e.key === '0') { } else if (e.key === '0') {
stage.value?.setViewport({ x: 0, y: 0, scale: 1 }) stage.value?.setViewport({ x: 0, y: 0, scale: 1 })
} else if (e.key === '1') {
stage.value?.fitToContent?.()
} }
} }
@@ -592,6 +694,7 @@ function selectAll() {
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', onKeydown) window.addEventListener('keydown', onKeydown)
window.addEventListener('paste', onPaste)
window.addEventListener('beforeunload', flushSync) window.addEventListener('beforeunload', flushSync)
document.addEventListener('visibilitychange', onVisibility) document.addEventListener('visibilitychange', onVisibility)
const mm = await api('/managed-models') const mm = await api('/managed-models')
@@ -610,6 +713,7 @@ function onVisibility() {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown) window.removeEventListener('keydown', onKeydown)
window.removeEventListener('paste', onPaste)
window.removeEventListener('beforeunload', flushSync) window.removeEventListener('beforeunload', flushSync)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
clearTimeout(saveTimer) clearTimeout(saveTimer)
@@ -655,17 +759,28 @@ const saveLabel = computed(() => ({
<button class="cv-btn" title="缩小" @click="stage?.zoomBy(1 / 1.2)">−</button> <button class="cv-btn" title="缩小" @click="stage?.zoomBy(1 / 1.2)">−</button>
<span class="cv-zoom">{{ scalePct }}%</span> <span class="cv-zoom">{{ scalePct }}%</span>
<button class="cv-btn" title="放大" @click="stage?.zoomBy(1.2)">+</button> <button class="cv-btn" title="放大" @click="stage?.zoomBy(1.2)">+</button>
<button class="cv-btn" title="重置视图" @click="stage?.setViewport({ x: 0, y: 0, scale: 1 })">⌂</button> <button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: 1 })">⌂</button>
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
<span class="cv-sep" /> <span class="cv-sep" />
<button class="cv-btn" @click="addNote">+ 笔记</button> <button class="cv-btn cv-btn--accent" @click="addNote">+ 笔记</button>
<button class="cv-btn" @click="openPicker">+ 图片</button> <button class="cv-btn cv-btn--accent" @click="openPicker">+ 图片</button>
<button class="cv-btn" :disabled="uploadBusy" title="上传本地图片(也可直接拖入/粘贴)" @click="fileInput?.click()">
{{ uploadBusy ? '上传中…' : '⤒ 上传' }}
</button>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
<span class="cv-save" :data-state="saveState">{{ saveLabel }}</span> <span class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
</div> </div>
</div> </div>
<!-- 舞台 + 生成面板 --> <!-- 舞台 + 生成面板 -->
<div class="cv-body"> <div class="cv-body">
<div class="cv-stagewrap"> <div
class="cv-stagewrap"
@dragover.prevent="dropHover = true"
@dragleave="dropHover = false"
@drop.prevent="onDrop"
>
<div v-if="dropHover" class="cv-drop">松开把图片放进画布</div>
<div v-if="loading" class="cv-loading">加载中…</div> <div v-if="loading" class="cv-loading">加载中…</div>
<div v-else-if="loadError" class="cv-error">{{ loadError }} <button class="cv-btn" @click="boot">重试</button></div> <div v-else-if="loadError" class="cv-error">{{ loadError }} <button class="cv-btn" @click="boot">重试</button></div>
<CanvasStage <CanvasStage
@@ -681,6 +796,7 @@ const saveLabel = computed(() => ({
@viewport-rest="onViewportRest" @viewport-rest="onViewportRest"
@pan-zoom="onPanZoom" @pan-zoom="onPanZoom"
@node-action="onNodeAction" @node-action="onNodeAction"
@blank-dblclick="addNoteAt"
/> />
</div> </div>
@@ -727,8 +843,8 @@ const saveLabel = computed(() => ({
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div> <div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
<div class="cv-tips"> <div class="cv-tips">
拖节点右侧圆点可连线(点击连线选中,Delete 删除);双击笔记编辑; 双击空白建笔记 · 拖右侧圆点连线 · 选中后右下角缩放 · Ctrl+D 复制 ·
生成结果会自动连线到来源节点;Ctrl+Z 撤销。 本地图片直接拖入或 Ctrl+V 粘贴 · 按 1 适应全部内容
</div> </div>
</aside> </aside>
</div> </div>
@@ -743,8 +859,11 @@ const saveLabel = computed(() => ({
<div class="cv-picker__box"> <div class="cv-picker__box">
<div class="cv-picker__head"> <div class="cv-picker__head">
<span>插入图片(点击放置到画布)</span> <span>插入图片(点击放置到画布)</span>
<div class="cv-picker__acts">
<button class="cv-btn cv-btn--accent" @click="fileInput?.click()">⤒ 上传本地</button>
<button class="cv-btn" @click="pickerOpen = false">✕</button> <button class="cv-btn" @click="pickerOpen = false">✕</button>
</div> </div>
</div>
<div v-if="pickerLoading" class="cv-loading">加载中…</div> <div v-if="pickerLoading" class="cv-loading">加载中…</div>
<div v-else-if="!pickerItems.length" class="cv-loading">还没有生成过图片</div> <div v-else-if="!pickerItems.length" class="cv-loading">还没有生成过图片</div>
<div v-else class="cv-picker__grid"> <div v-else class="cv-picker__grid">
@@ -764,41 +883,53 @@ const saveLabel = computed(() => ({
</template> </template>
<style scoped> <style scoped>
/* ===== 画布:Memphis/neo-brutalism 全屏工作台(随站点明暗主题走变量) ===== */
.cv-btn { .cv-btn {
padding: 5px 10px; padding: 5px 11px;
border-radius: 8px; border-radius: 9px;
border: 1px solid rgb(255 255 255 / 0.14); border: 2.5px solid var(--ink);
background: rgb(255 255 255 / 0.04); background: var(--surface);
color: #c6cbd8; color: var(--fg);
font-size: 13px; font-size: 13px;
font-weight: 600;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
box-shadow: 3px 3px 0 var(--shadow-ink);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
} }
.cv-btn:hover:not(:disabled) { .cv-btn:hover:not(:disabled) {
background: rgb(255 255 255 / 0.09); background: var(--m-yellow);
color: #fff; }
.cv-btn:active:not(:disabled) {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 var(--shadow-ink);
} }
.cv-btn:disabled { .cv-btn:disabled {
opacity: 0.4; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
.cv-btn--danger:hover:not(:disabled) { .cv-btn--danger:hover:not(:disabled) {
border-color: #6b3b44; background: #ffb4ab;
color: #e58c96; }
.cv-btn--accent {
background: var(--m-blue);
}
.cv-btn--accent:hover:not(:disabled) {
background: var(--m-mint);
} }
.cv-select, .cv-select,
.cv-input { .cv-input {
padding: 6px 9px; padding: 6px 10px;
border-radius: 8px; border-radius: 9px;
border: 1px solid rgb(255 255 255 / 0.14); border: 2.5px solid var(--ink);
background: #0e1015; background: var(--surface);
color: #e8eaf0; color: var(--fg);
font-size: 13px; font-size: 13px;
outline: none; outline: none;
} }
.cv-select:focus, .cv-select:focus,
.cv-input:focus { .cv-input:focus {
border-color: #7c6cff; box-shadow: 3px 3px 0 var(--m-orange);
} }
.cv-page { .cv-page {
@@ -808,50 +939,62 @@ const saveLabel = computed(() => ({
z-index: 50; z-index: 50;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0e1015; background: var(--app-bg);
} }
.cv-top { .cv-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 8px 14px; padding: 9px 14px;
border-bottom: 1px solid rgb(255 255 255 / 0.08); border-bottom: 3px solid var(--ink);
background: #12141b; background: var(--surface);
flex-wrap: wrap; flex-wrap: wrap;
} }
.cv-top__left, .cv-top__left,
.cv-top__right { .cv-top__right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 7px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.cv-select { .cv-select {
max-width: 220px; max-width: 220px;
} }
.cv-sep { .cv-sep {
width: 1px; width: 2.5px;
height: 18px; height: 20px;
background: rgb(255 255 255 / 0.12); border-radius: 2px;
background: var(--ink);
opacity: 0.25;
margin: 0 4px; margin: 0 4px;
} }
.cv-zoom { .cv-zoom {
font-size: 12px; font-size: 12px;
color: #9aa1b2; font-weight: 700;
min-width: 44px; color: var(--fg);
min-width: 46px;
text-align: center; text-align: center;
background: var(--m-yellow);
border: 2px solid var(--ink);
border-radius: 999px;
padding: 1px 8px;
} }
.cv-save { .cv-save {
font-size: 11px; font-size: 11px;
color: #7f8798; font-weight: 700;
color: var(--fg-3);
margin-left: 8px; margin-left: 8px;
} }
.cv-save[data-state='error'] {
color: #e58c96;
}
.cv-save[data-state='saved'] { .cv-save[data-state='saved'] {
color: #6fae8c; color: #15803d;
}
.cv-save[data-state='dirty'],
.cv-save[data-state='saving'] {
color: var(--m-orange);
}
.cv-save[data-state='error'] {
color: #dc2626;
} }
.cv-body { .cv-body {
@@ -864,6 +1007,21 @@ const saveLabel = computed(() => ({
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.cv-drop {
position: absolute;
inset: 10px;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: var(--fg);
border: 3px dashed var(--ink);
border-radius: 18px;
background: color-mix(in srgb, var(--m-blue) 55%, transparent);
pointer-events: none;
}
.cv-loading, .cv-loading,
.cv-error { .cv-error {
position: absolute; position: absolute;
@@ -872,33 +1030,40 @@ const saveLabel = computed(() => ({
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
color: #9aa1b2; color: var(--fg-2);
background: #0e1015; font-weight: 600;
background: var(--app-bg);
z-index: 5; z-index: 5;
} }
.cv-error { .cv-error {
color: #e58c96; color: #dc2626;
} }
/* 右侧生成面板 = 白纸卡片 */
.cv-panel { .cv-panel {
width: 260px; width: 264px;
flex-shrink: 0; flex-shrink: 0;
border-left: 1px solid rgb(255 255 255 / 0.08); border-left: 3px solid var(--ink);
background: #12141b; background: var(--surface);
padding: 14px; padding: 16px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 9px;
overflow-y: auto; overflow-y: auto;
} }
.cv-panel__title { .cv-panel__title {
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 800;
color: #e8eaf0; color: var(--fg);
display: inline-block;
background: linear-gradient(transparent 55%, var(--m-yellow) 55%);
align-self: flex-start;
padding: 0 2px;
} }
.cv-label { .cv-label {
font-size: 11px; font-size: 11px;
color: #7f8798; font-weight: 700;
color: var(--fg-3);
margin-top: 4px; margin-top: 4px;
} }
.cv-input { .cv-input {
@@ -914,35 +1079,49 @@ const saveLabel = computed(() => ({
flex-wrap: wrap; flex-wrap: wrap;
} }
.cv-chip { .cv-chip {
padding: 3px 10px; padding: 3px 11px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.14); border: 2.5px solid var(--ink);
background: transparent; background: var(--surface);
color: #b6bccb; color: var(--fg);
font-size: 12px; font-size: 12px;
font-weight: 700;
cursor: pointer; cursor: pointer;
} }
.cv-chip:hover {
background: var(--m-blue);
}
.cv-chip.is-on { .cv-chip.is-on {
border-color: #7c6cff; background: var(--m-yellow);
color: #a99dff; box-shadow: 2.5px 2.5px 0 var(--shadow-ink);
background: rgb(124 108 255 / 0.12);
} }
.cv-refs { .cv-refs {
font-size: 11px; font-size: 11px;
color: #7f8798; font-weight: 600;
border: 1px dashed rgb(255 255 255 / 0.14); color: var(--fg-2);
border-radius: 8px; border: 2px dashed var(--ink-soft);
border-radius: 9px;
padding: 8px 10px; padding: 8px 10px;
} }
.cv-gen { .cv-gen {
padding: 9px; padding: 10px;
border-radius: 9px; border-radius: 11px;
border: none; border: 2.5px solid var(--ink);
background: linear-gradient(135deg, #7c6cff, #5d8bff); background: var(--m-orange);
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 800;
cursor: pointer; cursor: pointer;
box-shadow: 4px 4px 0 var(--shadow-ink);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.cv-gen:hover:not(:disabled) {
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--shadow-ink);
}
.cv-gen:active:not(:disabled) {
transform: translate(3px, 3px);
box-shadow: 1px 1px 0 var(--shadow-ink);
} }
.cv-gen:disabled { .cv-gen:disabled {
opacity: 0.5; opacity: 0.5;
@@ -950,28 +1129,30 @@ const saveLabel = computed(() => ({
} }
.cv-genmsg { .cv-genmsg {
font-size: 11px; font-size: 11px;
color: #9aa1b2; color: var(--fg-2);
word-break: break-all; word-break: break-all;
} }
.cv-nomodels { .cv-nomodels {
font-size: 11px; font-size: 11px;
color: #d8a657; font-weight: 600;
color: #92400e;
line-height: 1.6; line-height: 1.6;
border: 1px dashed rgb(216 166 87 / 0.4); border: 2px dashed #f59e0b;
border-radius: 8px; border-radius: 9px;
background: var(--m-yellow);
padding: 8px 10px; padding: 8px 10px;
} }
.cv-tips { .cv-tips {
font-size: 11px; font-size: 11px;
color: #5f6675; color: var(--fg-faint);
line-height: 1.6; line-height: 1.7;
margin-top: auto; margin-top: auto;
} }
.cv-lightbox { .cv-lightbox {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgb(4 5 8 / 0.9); background: rgb(17 17 17 / 0.75);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -981,13 +1162,15 @@ const saveLabel = computed(() => ({
.cv-lightbox img { .cv-lightbox img {
max-width: 92vw; max-width: 92vw;
max-height: 92vh; max-height: 92vh;
border-radius: 8px; border-radius: 12px;
border: 3px solid #fff;
box-shadow: 10px 10px 0 var(--m-orange);
} }
.cv-picker { .cv-picker {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgb(4 5 8 / 0.6); background: rgb(17 17 17 / 0.45);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -996,36 +1179,46 @@ const saveLabel = computed(() => ({
.cv-picker__box { .cv-picker__box {
width: min(720px, 92vw); width: min(720px, 92vw);
max-height: 80vh; max-height: 80vh;
background: #161922; background: var(--surface);
border: 1px solid rgb(255 255 255 / 0.1); border: 3px solid var(--ink);
border-radius: 12px; border-radius: 16px;
padding: 14px; box-shadow: 8px 8px 0 var(--shadow-ink);
padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
} }
.cv-picker__head { .cv-picker__head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 13px; gap: 10px;
color: #e8eaf0; font-size: 14px;
font-weight: 800;
color: var(--fg);
}
.cv-picker__acts {
display: flex;
gap: 8px;
} }
.cv-picker__grid { .cv-picker__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px; gap: 10px;
overflow-y: auto; overflow-y: auto;
} }
.cv-picker__grid img { .cv-picker__grid img {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
border-radius: 8px; border-radius: 10px;
border: 2.5px solid var(--ink);
cursor: pointer; cursor: pointer;
background: #0e1015; background: var(--surface-2);
transition: transform 0.12s ease, box-shadow 0.12s ease;
} }
.cv-picker__grid img:hover { .cv-picker__grid img:hover {
outline: 2px solid #7c6cff; transform: translate(-2px, -2px);
box-shadow: 4px 4px 0 var(--m-orange);
} }
</style> </style>