diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index 1603f51..b01d5e2 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -146,6 +146,8 @@ func NewApp(ctx context.Context) (*App, error) { bannedWordRepo := repo.NewBannedWordRepository(db) v1Svc.SetBannedWords(bannedWordRepo) userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo) + canvasRepo := repo.NewCanvasRepository(db) + canvasSvc := service.NewCanvasService(canvasRepo) engine := router.New(cfg, authSvc, router.Handlers{ Health: handler.NewHealthHandler(), @@ -166,6 +168,7 @@ func NewApp(ctx context.Context) (*App, error) { Announcement: handler.NewAnnouncementHandler(announcementSvc), Payment: handler.NewPaymentHandler(paymentSvc), BannedWords: handler.NewBannedWordsHandler(bannedWordRepo), + Canvas: handler.NewCanvasHandler(canvasSvc), }) // Background self-healing sweep (quota recovery, cookie refresh, stale-pending diff --git a/backend/internal/http/handler/canvas.go b/backend/internal/http/handler/canvas.go new file mode 100644 index 0000000..555f764 --- /dev/null +++ b/backend/internal/http/handler/canvas.go @@ -0,0 +1,107 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "backend/internal/model" + "backend/internal/service" +) + +// CanvasHandler exposes the infinite-canvas CRUD under the session-authed +// /admin/api group. Every operation is scoped to the logged-in user. +type CanvasHandler struct { + svc *service.CanvasService +} + +func NewCanvasHandler(svc *service.CanvasService) *CanvasHandler { + return &CanvasHandler{svc: svc} +} + +// List serves the canvas picker (id/title/updatedAt only). +func (h *CanvasHandler) List(c *gin.Context) { + items, err := h.svc.List(c.Request.Context(), currentUser(c).ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to list canvases"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +// Create provisions a new canvas; title is optional. +func (h *CanvasHandler) Create(c *gin.Context) { + var body struct { + Title *string `json:"title"` + } + if err := c.ShouldBindJSON(&body); err != nil { + // Empty body is fine — treat as no title. + body.Title = nil + } + cv, err := h.svc.Create(c.Request.Context(), currentUser(c).ID, deref(body.Title)) + if err != nil { + respondCanvasError(c, err) + return + } + c.JSON(http.StatusOK, canvasDoc(cv)) +} + +// Get returns the full document blob (parsed by the client). +func (h *CanvasHandler) Get(c *gin.Context) { + cv, err := h.svc.Get(c.Request.Context(), currentUser(c).ID, c.Param("id")) + if err != nil { + respondCanvasError(c, err) + return + } + c.JSON(http.StatusOK, canvasDoc(cv)) +} + +// Save replaces the whole document (autosave) and/or renames. +func (h *CanvasHandler) Save(c *gin.Context) { + var body struct { + Title *string `json:"title"` + Data string `json:"data"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + if err := h.svc.Save(c.Request.Context(), currentUser(c).ID, c.Param("id"), body.Title, body.Data); err != nil { + respondCanvasError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *CanvasHandler) Delete(c *gin.Context) { + if err := h.svc.Delete(c.Request.Context(), currentUser(c).ID, c.Param("id")); err != nil { + respondCanvasError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func canvasDoc(cv *model.Canvas) gin.H { + return gin.H{"id": cv.ID, "title": cv.Title, "data": cv.Data, "updated_at": cv.UpdatedAt} +} + +func respondCanvasError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrCanvasNotFound): + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrCanvasLimit), + errors.Is(err, service.ErrCanvasTooLarge), + errors.Is(err, service.ErrCanvasBadJSON): + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"detail": "canvas operation failed"}) + } +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index af16afe..dadae48 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -29,6 +29,7 @@ type Handlers struct { Announcement *handler.AnnouncementHandler Payment *handler.PaymentHandler BannedWords *handler.BannedWordsHandler + Canvas *handler.CanvasHandler } func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine { @@ -112,6 +113,12 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. userAuthed.GET("/pay/orders", handlers.Payment.MyOrders) userAuthed.GET("/pay/orders/:id", handlers.Payment.OrderStatus) userAuthed.POST("/pay/orders/:id/continue", handlers.Payment.ContinueOrder) + // 无限画布 documents — session-authed, owner-scoped. + userAuthed.GET("/canvas", handlers.Canvas.List) + userAuthed.POST("/canvas", handlers.Canvas.Create) + userAuthed.GET("/canvas/:id", handlers.Canvas.Get) + userAuthed.PUT("/canvas/:id", handlers.Canvas.Save) + userAuthed.DELETE("/canvas/:id", handlers.Canvas.Delete) } authed := engine.Group("/admin/api") diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 9b5aeb5..a813629 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -250,6 +250,7 @@ func AutoMigrateModels() []any { &StatCounter{}, &ConcurrencyGroup{}, &Order{}, + &Canvas{}, } } @@ -289,6 +290,18 @@ type ConcurrencyGroup struct { UpdatedAt time.Time } +// Canvas is one user's infinite-canvas document. Data stores the whole +// node/edge graph as a versioned JSON blob; saves are whole-document +// replacements (last write wins), which matches the single-editor frontend. +type Canvas struct { + ID string `gorm:"primaryKey;size:40"` + UserID string `gorm:"size:32;index;not null"` + Title string `gorm:"size:120;not null;default:'未命名画布'"` + Data string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time `gorm:"index"` +} + // StatCounter is a persistent monotonic counter (key → value), independent of the // event_log (which is retention-pruned / clearable). Used for the dashboard // cumulative cards (total/success/failed/image/video/api) so they never reset. diff --git a/backend/internal/repo/canvas_repo.go b/backend/internal/repo/canvas_repo.go new file mode 100644 index 0000000..0cb084c --- /dev/null +++ b/backend/internal/repo/canvas_repo.go @@ -0,0 +1,84 @@ +package repo + +import ( + "context" + + "gorm.io/gorm" + + "backend/internal/model" +) + +// CanvasRepository persists infinite-canvas documents. All queries are +// userID-scoped — a canvas is only ever read or written by its owner. +type CanvasRepository struct { + db *gorm.DB +} + +func NewCanvasRepository(db *gorm.DB) *CanvasRepository { + return &CanvasRepository{db: db} +} + +// List returns the user's canvases newest-first, without the (potentially +// large) Data blob — the picker only needs id/title/updatedAt. +func (r *CanvasRepository) List(ctx context.Context, userID string) ([]model.Canvas, error) { + var items []model.Canvas + err := r.db.WithContext(ctx). + Select("id", "user_id", "title", "updated_at"). + Where("user_id = ?", userID). + Order("updated_at DESC"). + Find(&items).Error + return items, err +} + +func (r *CanvasRepository) Get(ctx context.Context, userID, id string) (*model.Canvas, error) { + var c model.Canvas + if err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", id, userID). + First(&c).Error; err != nil { + return nil, err + } + return &c, nil +} + +func (r *CanvasRepository) Create(ctx context.Context, c *model.Canvas) error { + return r.db.WithContext(ctx).Create(c).Error +} + +// Update replaces title and/or the document blob. A no-match update reports +// ErrRecordNotFound (gorm's Updates is silent about missing rows). +func (r *CanvasRepository) Update(ctx context.Context, userID, id string, fields map[string]any) error { + res := r.db.WithContext(ctx). + Model(&model.Canvas{}). + Where("id = ? AND user_id = ?", id, userID). + Updates(fields) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *CanvasRepository) Delete(ctx context.Context, userID, id string) error { + res := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", id, userID). + Delete(&model.Canvas{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// CountByUser backs the per-user document cap. +func (r *CanvasRepository) CountByUser(ctx context.Context, userID string) (int64, error) { + var n int64 + err := r.db.WithContext(ctx). + Model(&model.Canvas{}). + Where("user_id = ?", userID). + Count(&n).Error + return n, err +} diff --git a/backend/internal/service/canvas.go b/backend/internal/service/canvas.go new file mode 100644 index 0000000..a60d004 --- /dev/null +++ b/backend/internal/service/canvas.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" + + "backend/internal/model" + "backend/internal/repo" +) + +// CanvasService owns the infinite-canvas documents (无限画布): whole-document +// JSON blobs per user, written by the frontend's debounced autosave. The +// service validates shape/size and enforces a per-user document cap so a +// runaway client can't fan out unbounded rows. +type CanvasService struct { + canvases *repo.CanvasRepository +} + +var ( + ErrCanvasNotFound = errors.New("画布不存在") + ErrCanvasLimit = errors.New("画布数量已达上限") + ErrCanvasTooLarge = errors.New("画布数据过大") + ErrCanvasBadJSON = errors.New("画布数据格式错误") +) + +const ( + maxCanvasesPerUser = 50 + maxCanvasDataBytes = 4 << 20 // 4 MiB per document + maxCanvasTitle = 120 +) + +func NewCanvasService(canvases *repo.CanvasRepository) *CanvasService { + return &CanvasService{canvases: canvases} +} + +// ListSummary is the picker row — Data deliberately omitted. +type ListSummary struct { + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *CanvasService) List(ctx context.Context, userID string) ([]ListSummary, error) { + items, err := s.canvases.List(ctx, userID) + if err != nil { + return nil, err + } + out := make([]ListSummary, 0, len(items)) + for _, c := range items { + out = append(out, ListSummary{ID: c.ID, Title: c.Title, UpdatedAt: c.UpdatedAt}) + } + return out, nil +} + +// Create provisions an empty canvas with a starter document so the frontend +// can render immediately without a special "new" state. +func (s *CanvasService) Create(ctx context.Context, userID, title string) (*model.Canvas, error) { + if n, err := s.canvases.CountByUser(ctx, userID); err != nil { + return nil, err + } else if n >= maxCanvasesPerUser { + return nil, ErrCanvasLimit + } + title = normalizeCanvasTitle(title) + starter, _ := json.Marshal(map[string]any{ + "version": 1, + "viewport": map[string]any{"x": 0, "y": 0, "scale": 1}, + "nodes": []any{}, + "edges": []any{}, + }) + c := &model.Canvas{ + ID: "cv-" + randomUpper(10), + UserID: userID, + Title: title, + Data: string(starter), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.canvases.Create(ctx, c); err != nil { + return nil, err + } + return c, nil +} + +func (s *CanvasService) Get(ctx context.Context, userID, id string) (*model.Canvas, error) { + c, err := s.canvases.Get(ctx, userID, id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrCanvasNotFound + } + return c, err +} + +// Save replaces the document (and optionally renames). The blob must be valid +// JSON with the expected envelope; size is capped before it hits the DB. +func (s *CanvasService) Save(ctx context.Context, userID, id string, title *string, data string) error { + data = strings.TrimSpace(data) + if len(data) > maxCanvasDataBytes { + return ErrCanvasTooLarge + } + if !json.Valid([]byte(data)) { + return ErrCanvasBadJSON + } + fields := map[string]any{"data": data, "updated_at": time.Now()} + if title != nil { + fields["title"] = normalizeCanvasTitle(*title) + } + err := s.canvases.Update(ctx, userID, id, fields) + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrCanvasNotFound + } + return err +} + +func (s *CanvasService) Delete(ctx context.Context, userID, id string) error { + err := s.canvases.Delete(ctx, userID, id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrCanvasNotFound + } + return err +} + +func normalizeCanvasTitle(t string) string { + t = strings.TrimSpace(t) + if t == "" { + t = "未命名画布" + } + runes := []rune(t) + if len(runes) > maxCanvasTitle { + t = string(runes[:maxCanvasTitle]) + } + return t +} diff --git a/frontend/src/components/canvas/CanvasNodeView.vue b/frontend/src/components/canvas/CanvasNodeView.vue new file mode 100644 index 0000000..860d0d5 --- /dev/null +++ b/frontend/src/components/canvas/CanvasNodeView.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/src/components/canvas/CanvasStage.vue b/frontend/src/components/canvas/CanvasStage.vue new file mode 100644 index 0000000..f34782b --- /dev/null +++ b/frontend/src/components/canvas/CanvasStage.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue index 586cee4..c390ded 100644 --- a/frontend/src/layouts/PublicLayout.vue +++ b/frontend/src/layouts/PublicLayout.vue @@ -18,6 +18,7 @@ const nav = computed(() => { const items = [{ to: '/', label: '首页' }] if (isAuthed()) { items.push({ to: '/user', label: '画图' }) + items.push({ to: '/canvas', label: '画布' }) items.push({ to: '/logs', label: '图片' }) } items.push({ to: '/docs', label: '文档' }) diff --git a/frontend/src/main.js b/frontend/src/main.js index a72fa28..a316412 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -35,6 +35,7 @@ const ImagesView = () => import('./views/ImagesView.vue') const LogsView = () => import('./views/LogsView.vue') const ConfigView = () => import('./views/ConfigView.vue') const ShowcaseView = () => import('./views/ShowcaseView.vue') +const CanvasView = () => import('./views/CanvasView.vue') const routes = [ { @@ -43,6 +44,7 @@ const routes = [ children: [ { path: '', component: HomeView, meta: { label: '首页' } }, { path: 'user', component: PlaygroundView, meta: { label: '画图' } }, + { path: 'canvas', component: CanvasView, meta: { label: '无限画布' } }, { path: 'logs', component: UserLogsView, meta: { label: '图片' } }, { path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } }, { path: 'invite', component: InviteView, meta: { label: '邀请' } }, @@ -95,7 +97,7 @@ const router = createRouter({ // Pages that require a login. The home page (/) stays public; everything a // signed-in user touches (画图/记录/设置) and the whole admin area is gated. -const PROTECTED = ['/user', '/logs', '/invite', '/settings', '/orders'] +const PROTECTED = ['/user', '/canvas', '/logs', '/invite', '/settings', '/orders'] function isProtected(path) { return path.startsWith('/admin') || PROTECTED.includes(path) } diff --git a/frontend/src/utils/canvas.js b/frontend/src/utils/canvas.js new file mode 100644 index 0000000..c56e382 --- /dev/null +++ b/frontend/src/utils/canvas.js @@ -0,0 +1,153 @@ +// 无限画布 — 纯函数层:坐标变换、几何、文档校验。 +// 约定:world 坐标(浮点 px)经 viewport 变换到屏幕: +// screen = world * scale + t (t = viewport.x/y,即世界原点的屏幕位置) +// 整个世界层只做一个 CSS transform,GPU 合成,平移缩放零重排。 + +let idSeq = 0 + +/** 世界→屏幕 */ +export function worldToScreen(x, y, vp) { + return { x: x * vp.scale + vp.x, y: y * vp.scale + vp.y } +} + +/** 屏幕→世界 */ +export function screenToWorld(sx, sy, vp) { + return { x: (sx - vp.x) / vp.scale, y: (sy - vp.y) / vp.scale } +} + +export const MIN_SCALE = 0.05 +export const MAX_SCALE = 4 + +/** 以屏幕点 m 为锚缩放到 newScale,返回新 viewport(平移量按锚点保持不动) */ +export function zoomAt(vp, mx, my, newScale) { + const s = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale)) + const k = s / vp.scale + return { scale: s, x: mx - (mx - vp.x) * k, y: my - (my - vp.y) * k } +} + +/** 节点世界包围盒 */ +export function nodeRect(n) { + return { x: n.x, y: n.y, w: n.w, h: n.h } +} + +export function rectsIntersect(a, b) { + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y +} + +export function pointInRect(px, py, r) { + return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h +} + +/** 视口剔除:返回与可见世界矩形(含 margin)相交的节点。纯几何,千级节点也是微秒。 */ +export function nodesInViewport(nodes, vp, stageW, stageH, margin = 240) { + const vis = { + x: -vp.x / vp.scale - margin, + y: -vp.y / vp.scale - margin, + w: stageW / vp.scale + margin * 2, + h: stageH / vp.scale + margin * 2, + } + return nodes.filter((n) => rectsIntersect(nodeRect(n), vis)) +} + +/** 常用比例 → 世界像素尺寸(长边 512;图片 onload 后会按真实尺寸校正) */ +export function ratioToSize(ratio) { + const map = { + '1:1': [512, 512], + '16:9': [896, 504], + '9:16': [504, 896], + '4:3': [592, 444], + '3:4': [444, 592], + '3:2': [624, 416], + '2:3': [416, 624], + '21:9': [960, 412], + } + const [w, h] = map[ratio] || [512, 512] + return { w, h } +} + +export function newId(prefix) { + idSeq = (idSeq + 1) % 100000 + return `${prefix}_${Date.now().toString(36)}${idSeq.toString(36)}${Math.random() + .toString(36) + .slice(2, 7)}` +} + +/** + * 校验并规范化从后端来的文档。宁可丢弃坏节点也不让一个坏字段搞挂整张画布。 + * 返回 { viewport, nodes, edges }。 + */ +export function normalizeDoc(raw) { + const doc = { + viewport: { x: 0, y: 0, scale: 1 }, + nodes: [], + edges: [], + } + if (!raw || typeof raw !== 'object') return doc + const vp = raw.viewport + if (vp && Number.isFinite(vp.x) && Number.isFinite(vp.y) && Number.isFinite(vp.scale)) { + doc.viewport = { + x: vp.x, + y: vp.y, + scale: Math.min(MAX_SCALE, Math.max(MIN_SCALE, vp.scale)), + } + } + const ids = new Set() + if (Array.isArray(raw.nodes)) { + for (const n of raw.nodes) { + if (!n || typeof n.id !== 'string' || ids.has(n.id)) continue + if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) continue + if (!Number.isFinite(n.w) || n.w <= 0 || !Number.isFinite(n.h) || n.h <= 0) continue + const type = n.type === 'note' || n.type === 'gen' ? n.type : 'image' + const node = { + id: n.id, + type, + x: n.x, + y: n.y, + w: Math.min(4096, n.w), + h: Math.min(4096, n.h), + z: Number.isFinite(n.z) ? n.z : 0, + } + if (type === 'note') node.text = typeof n.text === 'string' ? n.text.slice(0, 4000) : '' + if (type === 'image') { + node.src = typeof n.src === 'string' ? n.src.slice(0, 500) : '' + if (!node.src) continue // 图片节点必须有 src + if (typeof n.prompt === 'string') node.prompt = n.prompt.slice(0, 2000) + if (typeof n.model === 'string') node.model = n.model.slice(0, 120) + } + if (type === 'gen') { + node.status = n.status === 'failed' ? 'failed' : 'running' + if (typeof n.error === 'string') node.error = n.error.slice(0, 500) + } + ids.add(node.id) + doc.nodes.push(node) + } + } + if (Array.isArray(raw.edges)) { + for (const e of raw.edges) { + if (!e || typeof e.id !== 'string') continue + if (typeof e.from !== 'string' || typeof e.to !== 'string') continue + if (!ids.has(e.from) || !ids.has(e.to) || e.from === e.to) continue + doc.edges.push({ id: e.id, from: e.from, to: e.to }) + } + } + return doc +} + +/** 序列化(仅内容,不含 viewport 的可变性;viewport 单独存) */ +export function serializeDoc(doc) { + return JSON.stringify({ + version: 1, + viewport: doc.viewport, + nodes: doc.nodes, + edges: doc.edges, + }) +} + +/** 生成节点完成 → 转为图片节点(保留位置/尺寸/边) */ +export function promoteGenNode(node, src, extras = {}) { + node.type = 'image' + node.src = src + delete node.status + delete node.error + Object.assign(node, extras) +} diff --git a/frontend/src/views/CanvasView.vue b/frontend/src/views/CanvasView.vue new file mode 100644 index 0000000..5d2f95a --- /dev/null +++ b/frontend/src/views/CanvasView.vue @@ -0,0 +1,937 @@ + + +