feat: 无限画布 — infinite canvas with generation integration
Backend: - canvases table + owner-scoped CRUD under /admin/api/canvas (list/create/get/save/delete, 50 docs/user, 4MiB cap, JSON validation) Frontend: - /canvas full-screen workspace: DOM+CSS-transform infinite pan/zoom (GPU-composited world layer, viewport culling, rAF-batched pointer moves) - nodes: image (thumb display, natural-size correction), note (uncontrolled contenteditable), generation placeholder with edges - generate on canvas: prompt from panel or selected notes, selected image nodes as references, results auto-placed + bezier edges to sources - multi-canvas management, debounced autosave + keepalive flush, snapshot undo/redo (60 steps), marquee/keyboard interactions
This commit is contained in:
@@ -146,6 +146,8 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
bannedWordRepo := repo.NewBannedWordRepository(db)
|
bannedWordRepo := repo.NewBannedWordRepository(db)
|
||||||
v1Svc.SetBannedWords(bannedWordRepo)
|
v1Svc.SetBannedWords(bannedWordRepo)
|
||||||
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo)
|
||||||
|
canvasRepo := repo.NewCanvasRepository(db)
|
||||||
|
canvasSvc := service.NewCanvasService(canvasRepo)
|
||||||
|
|
||||||
engine := router.New(cfg, authSvc, router.Handlers{
|
engine := router.New(cfg, authSvc, router.Handlers{
|
||||||
Health: handler.NewHealthHandler(),
|
Health: handler.NewHealthHandler(),
|
||||||
@@ -166,6 +168,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
Announcement: handler.NewAnnouncementHandler(announcementSvc),
|
||||||
Payment: handler.NewPaymentHandler(paymentSvc),
|
Payment: handler.NewPaymentHandler(paymentSvc),
|
||||||
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
BannedWords: handler.NewBannedWordsHandler(bannedWordRepo),
|
||||||
|
Canvas: handler.NewCanvasHandler(canvasSvc),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
// Background self-healing sweep (quota recovery, cookie refresh, stale-pending
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type Handlers struct {
|
|||||||
Announcement *handler.AnnouncementHandler
|
Announcement *handler.AnnouncementHandler
|
||||||
Payment *handler.PaymentHandler
|
Payment *handler.PaymentHandler
|
||||||
BannedWords *handler.BannedWordsHandler
|
BannedWords *handler.BannedWordsHandler
|
||||||
|
Canvas *handler.CanvasHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine {
|
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", handlers.Payment.MyOrders)
|
||||||
userAuthed.GET("/pay/orders/:id", handlers.Payment.OrderStatus)
|
userAuthed.GET("/pay/orders/:id", handlers.Payment.OrderStatus)
|
||||||
userAuthed.POST("/pay/orders/:id/continue", handlers.Payment.ContinueOrder)
|
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")
|
authed := engine.Group("/admin/api")
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ func AutoMigrateModels() []any {
|
|||||||
&StatCounter{},
|
&StatCounter{},
|
||||||
&ConcurrencyGroup{},
|
&ConcurrencyGroup{},
|
||||||
&Order{},
|
&Order{},
|
||||||
|
&Canvas{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +290,18 @@ type ConcurrencyGroup struct {
|
|||||||
UpdatedAt time.Time
|
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
|
// StatCounter is a persistent monotonic counter (key → value), independent of the
|
||||||
// event_log (which is retention-pruned / clearable). Used for the dashboard
|
// event_log (which is retention-pruned / clearable). Used for the dashboard
|
||||||
// cumulative cards (total/success/failed/image/video/api) so they never reset.
|
// cumulative cards (total/success/failed/image/video/api) so they never reset.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script setup>
|
||||||
|
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
|
||||||
|
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
|
||||||
|
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { thumbUrl } from '../../api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: { type: Object, required: true },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['imgload', 'note-commit', 'node-action'])
|
||||||
|
|
||||||
|
const imgLoaded = ref(false)
|
||||||
|
const noteEl = ref(null)
|
||||||
|
const editing = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
|
||||||
|
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function onImgLoad(e) {
|
||||||
|
imgLoaded.value = true
|
||||||
|
const img = e.target
|
||||||
|
// 按真实长宽比校正节点尺寸(以长边 512 归一,保持画布视觉密度一致)。
|
||||||
|
if (img.naturalWidth && img.naturalHeight) {
|
||||||
|
const long = Math.max(img.naturalWidth, img.naturalHeight)
|
||||||
|
const k = 512 / long
|
||||||
|
emit('imgload', {
|
||||||
|
w: Math.round(img.naturalWidth * k),
|
||||||
|
h: Math.round(img.naturalHeight * k),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (props.node.type !== 'note') return
|
||||||
|
editing.value = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = noteEl.value
|
||||||
|
if (el) {
|
||||||
|
el.innerText = props.node.text || ''
|
||||||
|
el.focus()
|
||||||
|
// 光标落末尾
|
||||||
|
const sel = window.getSelection()
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
range.collapse(false)
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(range)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitNote() {
|
||||||
|
if (!editing.value) return
|
||||||
|
editing.value = false
|
||||||
|
const text = (noteEl.value?.innerText || '').slice(0, 4000)
|
||||||
|
if (text !== (props.node.text || '')) emit('note-commit', text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双击:笔记 → 进入编辑(自己处理);图片 → 交给父级开预览。
|
||||||
|
function onSelfDblClick(e) {
|
||||||
|
if (props.node.type === 'note') {
|
||||||
|
e.stopPropagation()
|
||||||
|
startEdit()
|
||||||
|
} else if (props.node.type === 'image') {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('node-action', { type: 'preview' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ startEdit, commitNote })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="cv-node"
|
||||||
|
:class="[
|
||||||
|
`cv-node--${node.type}`,
|
||||||
|
{ 'is-selected': selected, 'is-editing': editing, 'is-failed': node.type === 'gen' && node.status === 'failed' },
|
||||||
|
]"
|
||||||
|
:style="{ left: node.x + 'px', top: node.y + 'px', width: node.w + 'px', height: node.h + 'px', zIndex: node.z || 0 }"
|
||||||
|
:data-node-id="node.id"
|
||||||
|
@dblclick="onSelfDblClick"
|
||||||
|
>
|
||||||
|
<!-- 图片节点 -->
|
||||||
|
<template v-if="node.type === 'image'">
|
||||||
|
<img
|
||||||
|
class="cv-node__img"
|
||||||
|
:src="thumbUrl(node.src)"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
alt=""
|
||||||
|
@load="onImgLoad"
|
||||||
|
@error="imgLoaded = true"
|
||||||
|
>
|
||||||
|
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 笔记节点 -->
|
||||||
|
<div
|
||||||
|
v-else-if="node.type === 'note'"
|
||||||
|
ref="noteEl"
|
||||||
|
class="cv-node__note"
|
||||||
|
contenteditable="true"
|
||||||
|
spellcheck="false"
|
||||||
|
@blur="commitNote"
|
||||||
|
@keydown.enter.exact.prevent="commitNote"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 生成占位节点 -->
|
||||||
|
<div v-else class="cv-node__gen">
|
||||||
|
<template v-if="node.status === 'running'">
|
||||||
|
<span class="cv-node__spin" />
|
||||||
|
<span>生成中…</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="cv-node__x">✕</span>
|
||||||
|
<span class="cv-node__err" :title="node.error || ''">生成失败</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cv-node {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: layout style paint;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
.cv-node.is-selected {
|
||||||
|
outline: 2px solid #7c6cff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.cv-node:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-node__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: #14161d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-node__cap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 14px 8px 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #d6d9e2;
|
||||||
|
background: linear-gradient(transparent, rgb(8 9 13 / 0.92));
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-node--note {
|
||||||
|
background: #23262f;
|
||||||
|
border: 1px solid #343947;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.cv-node--note:not(.is-editing) {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.cv-node__note {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #e8eaf0;
|
||||||
|
outline: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.is-editing .cv-node__note {
|
||||||
|
user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-node--gen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1a1d26;
|
||||||
|
border: 1.5px dashed #4a4f61;
|
||||||
|
color: #9aa1b2;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cv-node--gen.is-failed {
|
||||||
|
border-color: #6b3b44;
|
||||||
|
color: #e58c96;
|
||||||
|
}
|
||||||
|
.cv-node__spin {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid #3b4152;
|
||||||
|
border-top-color: #7c6cff;
|
||||||
|
animation: cv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes cv-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.cv-node__err {
|
||||||
|
font-size: 11px;
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<script setup>
|
||||||
|
// 无限画布舞台:视口变换、指针交互(拖拽/平移/框选/缩放)、视口剔除、连线。
|
||||||
|
// 文档(nodes/edges)由父级持有;舞台就地修改节点坐标,结构性变更走事件。
|
||||||
|
//
|
||||||
|
// 性能:世界层单 transform(GPU 合成);pointermove 全部 rAF 合并;
|
||||||
|
// visibleNodes 视口剔除只挂载可见节点;连线纯 SVG path 同层渲染。
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
nodesInViewport,
|
||||||
|
screenToWorld,
|
||||||
|
zoomAt,
|
||||||
|
rectsIntersect,
|
||||||
|
nodeRect,
|
||||||
|
} from '../../utils/canvas'
|
||||||
|
import CanvasNodeView from './CanvasNodeView.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
nodes: { type: Array, required: true },
|
||||||
|
edges: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits([
|
||||||
|
'move-end', // 拖拽结束(父级 push undo)
|
||||||
|
'selection', // 选中变化 -> ids[]
|
||||||
|
'node-action', // 双击图片等 -> { type:'preview', id }
|
||||||
|
'note-commit', // 笔记提交 -> { id, text }
|
||||||
|
'img-size', // 图片尺寸校正 -> { id, w, h }
|
||||||
|
'viewport-rest', // 视口静止 700ms -> viewport
|
||||||
|
'pan-zoom', // 视口变化(用于百分比显示,高频)
|
||||||
|
])
|
||||||
|
|
||||||
|
const stageEl = ref(null)
|
||||||
|
const vp = reactive({ x: 0, y: 0, scale: 1 })
|
||||||
|
const stageSize = reactive({ w: 0, h: 0 })
|
||||||
|
const selection = ref(new Set())
|
||||||
|
const marquee = ref(null)
|
||||||
|
const spaceHeld = ref(false)
|
||||||
|
const panning = ref(false)
|
||||||
|
|
||||||
|
const visibleNodes = computed(() =>
|
||||||
|
nodesInViewport(props.nodes, vp, stageSize.w, stageSize.h),
|
||||||
|
)
|
||||||
|
const nodeById = computed(() => {
|
||||||
|
const m = new Map()
|
||||||
|
for (const n of props.nodes) m.set(n.id, n)
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
const edgePaths = computed(() => {
|
||||||
|
const out = []
|
||||||
|
for (const e of props.edges) {
|
||||||
|
const a = nodeById.value.get(e.from)
|
||||||
|
const b = nodeById.value.get(e.to)
|
||||||
|
if (!a || !b) continue
|
||||||
|
const x1 = a.x + a.w / 2
|
||||||
|
const y1 = a.y + a.h / 2
|
||||||
|
const x2 = b.x + b.w / 2
|
||||||
|
const y2 = b.y + b.h / 2
|
||||||
|
const dx = Math.max(60, Math.abs(x2 - x1) * 0.45)
|
||||||
|
out.push({
|
||||||
|
id: e.id,
|
||||||
|
to: e.to,
|
||||||
|
d: `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const worldStyle = computed(() => ({
|
||||||
|
transform: `translate3d(${vp.x}px, ${vp.y}px, 0) scale(${vp.scale})`,
|
||||||
|
}))
|
||||||
|
const gridStyle = computed(() => {
|
||||||
|
const cell = Math.max(14, 24 * vp.scale)
|
||||||
|
return {
|
||||||
|
backgroundSize: `${cell}px ${cell}px`,
|
||||||
|
backgroundPosition: `${vp.x}px ${vp.y}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 尺寸/键盘监听 ----
|
||||||
|
let ro = null
|
||||||
|
onMounted(() => {
|
||||||
|
ro = new ResizeObserver((entries) => {
|
||||||
|
const r = entries[0].contentRect
|
||||||
|
stageSize.w = r.width
|
||||||
|
stageSize.h = r.height
|
||||||
|
})
|
||||||
|
ro.observe(stageEl.value)
|
||||||
|
stageEl.value.addEventListener('wheel', onWheel, { passive: false })
|
||||||
|
window.addEventListener('keydown', onSpaceDown)
|
||||||
|
window.addEventListener('keyup', onSpaceUp)
|
||||||
|
syncZTop()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
ro?.disconnect()
|
||||||
|
stageEl.value?.removeEventListener('wheel', onWheel)
|
||||||
|
window.removeEventListener('keydown', onSpaceDown)
|
||||||
|
window.removeEventListener('keyup', onSpaceUp)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSpaceDown(e) {
|
||||||
|
if (e.code === 'Space' && !isTypingTarget(e.target) && !e.repeat) {
|
||||||
|
spaceHeld.value = true
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onSpaceUp(e) {
|
||||||
|
if (e.code === 'Space') spaceHeld.value = false
|
||||||
|
}
|
||||||
|
function isTypingTarget(t) {
|
||||||
|
return !!(t && t.closest && t.closest('input, textarea, select, [contenteditable="true"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 坐标:一律换算成舞台本地坐标(offsetX 相对事件目标,不可靠) ----
|
||||||
|
function localXY(e) {
|
||||||
|
const r = stageEl.value.getBoundingClientRect()
|
||||||
|
return { x: e.clientX - r.left, y: e.clientY - r.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitNode(e) {
|
||||||
|
const el = e.target.closest?.('[data-node-id]')
|
||||||
|
return el ? nodeById.value.get(el.dataset.nodeId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 指针状态机 ----
|
||||||
|
let act = null // { mode: 'pan'|'drag'|'marquee', ... }
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
// 文本编辑中完全放行
|
||||||
|
if (e.target.closest?.('.cv-node--note.is-editing')) return
|
||||||
|
const node = hitNode(e)
|
||||||
|
const p = localXY(e)
|
||||||
|
|
||||||
|
const wantPan =
|
||||||
|
e.button === 1 || e.button === 2 || spaceHeld.value || (!node && !e.shiftKey && e.button === 0)
|
||||||
|
if (wantPan) {
|
||||||
|
panning.value = true
|
||||||
|
act = { mode: 'pan', px: p.x, py: p.y, vx: vp.x, vy: vp.y }
|
||||||
|
stageEl.value.setPointerCapture(e.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.button !== 0) return
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
const s = new Set(selection.value)
|
||||||
|
if (s.has(node.id)) s.delete(node.id)
|
||||||
|
else s.add(node.id)
|
||||||
|
setSelection(s)
|
||||||
|
} else if (!selection.value.has(node.id)) {
|
||||||
|
setSelection(new Set([node.id]))
|
||||||
|
}
|
||||||
|
bumpZ(node)
|
||||||
|
const origins = [...selection.value]
|
||||||
|
.map((id) => nodeById.value.get(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((n) => ({ n, x: n.x, y: n.y }))
|
||||||
|
const start = screenToWorld(p.x, p.y, vp)
|
||||||
|
act = { mode: 'drag', start, origins, moved: false, px: p.x, py: p.y }
|
||||||
|
} else {
|
||||||
|
act = { mode: 'marquee', px: p.x, py: p.y, base: new Set(selection.value) }
|
||||||
|
marquee.value = { x: p.x, y: p.y, w: 0, h: 0 }
|
||||||
|
}
|
||||||
|
stageEl.value.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rafPending = false
|
||||||
|
let pendingEvent = null
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!act) return
|
||||||
|
pendingEvent = e
|
||||||
|
if (rafPending) return
|
||||||
|
rafPending = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rafPending = false
|
||||||
|
if (pendingEvent) applyMove(pendingEvent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMove(e) {
|
||||||
|
if (!act) return
|
||||||
|
const p = localXY(e)
|
||||||
|
if (act.mode === 'pan') {
|
||||||
|
vp.x = act.vx + (p.x - act.px)
|
||||||
|
vp.y = act.vy + (p.y - act.py)
|
||||||
|
notifyViewport()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (act.mode === 'drag') {
|
||||||
|
if (!act.moved) {
|
||||||
|
if (Math.hypot(p.x - act.px, p.y - act.py) < 3) return
|
||||||
|
act.moved = true
|
||||||
|
}
|
||||||
|
const cur = screenToWorld(p.x, p.y, vp)
|
||||||
|
const dx = cur.x - act.start.x
|
||||||
|
const dy = cur.y - act.start.y
|
||||||
|
for (const o of act.origins) {
|
||||||
|
o.n.x = Math.round(o.x + dx)
|
||||||
|
o.n.y = Math.round(o.y + dy)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (act.mode === 'marquee') {
|
||||||
|
marquee.value = {
|
||||||
|
x: Math.min(act.px, p.x),
|
||||||
|
y: Math.min(act.py, p.y),
|
||||||
|
w: Math.abs(p.x - act.px),
|
||||||
|
h: Math.abs(p.y - act.py),
|
||||||
|
}
|
||||||
|
const wr = screenRectToWorld(marquee.value)
|
||||||
|
const s = new Set(act.base)
|
||||||
|
for (const n of props.nodes) if (rectsIntersect(nodeRect(n), wr)) s.add(n.id)
|
||||||
|
setSelection(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
if (!act) return
|
||||||
|
const a = act
|
||||||
|
act = null
|
||||||
|
marquee.value = null
|
||||||
|
panning.value = false
|
||||||
|
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
|
||||||
|
if (a.mode === 'drag' && a.moved) emit('move-end')
|
||||||
|
}
|
||||||
|
function onPointerCancel() {
|
||||||
|
if (act?.mode === 'drag' && act.moved) emit('move-end')
|
||||||
|
act = null
|
||||||
|
marquee.value = null
|
||||||
|
panning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const p = localXY(e)
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
Object.assign(vp, zoomAt(vp, p.x, p.y, vp.scale * Math.exp(-e.deltaY * 0.0018)))
|
||||||
|
} else {
|
||||||
|
vp.x -= e.deltaX
|
||||||
|
vp.y -= e.deltaY
|
||||||
|
}
|
||||||
|
notifyViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 视口通知(静止 700ms 后通知父级随文档保存) ----
|
||||||
|
let restTimer = null
|
||||||
|
function notifyViewport() {
|
||||||
|
emit('pan-zoom', { ...vp })
|
||||||
|
clearTimeout(restTimer)
|
||||||
|
restTimer = setTimeout(() => emit('viewport-rest', { x: vp.x, y: vp.y, scale: vp.scale }), 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- z 序 ----
|
||||||
|
let zTop = 1
|
||||||
|
function syncZTop() {
|
||||||
|
for (const n of props.nodes) zTop = Math.max(zTop, n.z || 0)
|
||||||
|
}
|
||||||
|
function bumpZ(node) {
|
||||||
|
zTop += 1
|
||||||
|
node.z = zTop
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 选择 ----
|
||||||
|
function setSelection(s) {
|
||||||
|
selection.value = s
|
||||||
|
emit('selection', [...s])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 对外 API ----
|
||||||
|
function setViewport(v) {
|
||||||
|
Object.assign(vp, v)
|
||||||
|
notifyViewport()
|
||||||
|
}
|
||||||
|
function zoomBy(factor) {
|
||||||
|
const r = stageEl.value?.getBoundingClientRect()
|
||||||
|
const cx = r ? r.width / 2 : 0
|
||||||
|
const cy = r ? r.height / 2 : 0
|
||||||
|
Object.assign(vp, zoomAt(vp, cx, cy, vp.scale * factor))
|
||||||
|
notifyViewport()
|
||||||
|
}
|
||||||
|
function centerOn(x, y) {
|
||||||
|
const r = stageEl.value?.getBoundingClientRect()
|
||||||
|
vp.x = (r ? r.width / 2 : 400) - x * vp.scale
|
||||||
|
vp.y = (r ? r.height / 2 : 300) - y * vp.scale
|
||||||
|
notifyViewport()
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
if (selection.value.size) setSelection(new Set())
|
||||||
|
}
|
||||||
|
function selectedIds() {
|
||||||
|
return [...selection.value]
|
||||||
|
}
|
||||||
|
function selectAll() {
|
||||||
|
setSelection(new Set(props.nodes.map((n) => n.id)))
|
||||||
|
}
|
||||||
|
function screenRectToWorld(r) {
|
||||||
|
const a = screenToWorld(r.x, r.y, vp)
|
||||||
|
const b = screenToWorld(r.x + r.w, r.y + r.h, vp)
|
||||||
|
return {
|
||||||
|
x: Math.min(a.x, b.x),
|
||||||
|
y: Math.min(a.y, b.y),
|
||||||
|
w: Math.abs(b.x - a.x),
|
||||||
|
h: Math.abs(b.y - a.y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({
|
||||||
|
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="stageEl"
|
||||||
|
class="cv-stage"
|
||||||
|
:class="{ 'is-panning': panning || spaceHeld }"
|
||||||
|
:style="gridStyle"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerCancel"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<div class="cv-world" :style="worldStyle">
|
||||||
|
<svg class="cv-edges" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
v-for="p in edgePaths"
|
||||||
|
:key="p.id"
|
||||||
|
:d="p.d"
|
||||||
|
class="cv-edge"
|
||||||
|
:class="{ 'cv-edge--hot': selection.has(p.to) }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<CanvasNodeView
|
||||||
|
v-for="n in visibleNodes"
|
||||||
|
:key="n.id"
|
||||||
|
:node="n"
|
||||||
|
:selected="selection.has(n.id)"
|
||||||
|
@imgload="(s) => emit('img-size', { id: n.id, ...s })"
|
||||||
|
@note-commit="(text) => emit('note-commit', { id: n.id, text })"
|
||||||
|
@node-action="(a) => emit('node-action', { ...a, id: n.id })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="marquee" class="cv-marquee" :style="marquee" />
|
||||||
|
<div class="cv-hint">拖拽空白平移 · Ctrl+滚轮缩放 · Shift+拖拽框选 · 双击笔记编辑 · 按住空格+拖拽平移</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cv-stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
background-color: #0e1015;
|
||||||
|
background-image: radial-gradient(circle, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
.cv-stage.is-panning {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.cv-world {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.cv-edges {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cv-edge {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(140 146 165 / 0.5);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.cv-edge--hot {
|
||||||
|
stroke: #7c6cff;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.cv-marquee {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #7c6cff;
|
||||||
|
background: rgb(124 108 255 / 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.cv-hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(255 255 255 / 0.28);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ const nav = computed(() => {
|
|||||||
const items = [{ to: '/', label: '首页' }]
|
const items = [{ to: '/', label: '首页' }]
|
||||||
if (isAuthed()) {
|
if (isAuthed()) {
|
||||||
items.push({ to: '/user', label: '画图' })
|
items.push({ to: '/user', label: '画图' })
|
||||||
|
items.push({ to: '/canvas', label: '画布' })
|
||||||
items.push({ to: '/logs', label: '图片' })
|
items.push({ to: '/logs', label: '图片' })
|
||||||
}
|
}
|
||||||
items.push({ to: '/docs', label: '文档' })
|
items.push({ to: '/docs', label: '文档' })
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const ImagesView = () => import('./views/ImagesView.vue')
|
|||||||
const LogsView = () => import('./views/LogsView.vue')
|
const LogsView = () => import('./views/LogsView.vue')
|
||||||
const ConfigView = () => import('./views/ConfigView.vue')
|
const ConfigView = () => import('./views/ConfigView.vue')
|
||||||
const ShowcaseView = () => import('./views/ShowcaseView.vue')
|
const ShowcaseView = () => import('./views/ShowcaseView.vue')
|
||||||
|
const CanvasView = () => import('./views/CanvasView.vue')
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,7 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', component: HomeView, meta: { label: '首页' } },
|
{ path: '', component: HomeView, meta: { label: '首页' } },
|
||||||
{ path: 'user', component: PlaygroundView, meta: { label: '画图' } },
|
{ path: 'user', component: PlaygroundView, meta: { label: '画图' } },
|
||||||
|
{ path: 'canvas', component: CanvasView, meta: { label: '无限画布' } },
|
||||||
{ path: 'logs', component: UserLogsView, meta: { label: '图片' } },
|
{ path: 'logs', component: UserLogsView, meta: { label: '图片' } },
|
||||||
{ path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } },
|
{ path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } },
|
||||||
{ path: 'invite', component: InviteView, 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
|
// Pages that require a login. The home page (/) stays public; everything a
|
||||||
// signed-in user touches (画图/记录/设置) and the whole admin area is gated.
|
// 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) {
|
function isProtected(path) {
|
||||||
return path.startsWith('/admin') || PROTECTED.includes(path)
|
return path.startsWith('/admin') || PROTECTED.includes(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,937 @@
|
|||||||
|
<script setup>
|
||||||
|
// 无限画布页面:文档管理(多画布/自动保存)、生成面板、撤销重做、快捷键。
|
||||||
|
// 交互细节全部在 CanvasStage;这里只做数据编排。
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { api, generatedUrl, jsonBody, thumbUrl } from '../api'
|
||||||
|
import { auth } from '../auth'
|
||||||
|
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld } from '../utils/canvas'
|
||||||
|
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
||||||
|
|
||||||
|
const stage = ref(null)
|
||||||
|
|
||||||
|
// ---- 文档状态 ----
|
||||||
|
const canvases = ref([])
|
||||||
|
const currentId = ref('')
|
||||||
|
const title = ref('未命名画布')
|
||||||
|
const doc = reactive({ viewport: { x: 0, y: 0, scale: 1 }, nodes: [], edges: [] })
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const saveState = ref('saved') // saved | dirty | saving | error
|
||||||
|
const scalePct = ref(100)
|
||||||
|
|
||||||
|
// ---- 选择 / 预览 ----
|
||||||
|
const selectionIds = ref([])
|
||||||
|
const previewSrc = ref('')
|
||||||
|
|
||||||
|
// ---- 生成面板 ----
|
||||||
|
const allModels = ref([])
|
||||||
|
const gen = reactive({ modelId: '', prompt: '', ratio: '' })
|
||||||
|
const genBusy = ref(false)
|
||||||
|
const genMsg = ref('')
|
||||||
|
const imageModels = computed(() =>
|
||||||
|
(allModels.value || []).filter((m) => m.enabled !== false && m.type === 'image'),
|
||||||
|
)
|
||||||
|
const model = computed(() => imageModels.value.find((m) => m.id === gen.modelId) || null)
|
||||||
|
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||||||
|
const selectedImages = computed(() =>
|
||||||
|
selectionIds.value
|
||||||
|
.map((id) => doc.nodes.find((n) => n.id === id))
|
||||||
|
.filter((n) => n && n.type === 'image'),
|
||||||
|
)
|
||||||
|
const selectedNotes = computed(() =>
|
||||||
|
selectionIds.value
|
||||||
|
.map((id) => doc.nodes.find((n) => n.id === id))
|
||||||
|
.filter((n) => n && n.type === 'note' && (n.text || '').trim()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- 我的图片插入 ----
|
||||||
|
const pickerOpen = ref(false)
|
||||||
|
const pickerItems = ref([])
|
||||||
|
const pickerLoading = ref(false)
|
||||||
|
|
||||||
|
// ================= 文档加载 / 保存 =================
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
const r = await api('/canvas')
|
||||||
|
if (!r.ok) throw new Error(r.data?.detail || '加载画布列表失败')
|
||||||
|
canvases.value = r.data?.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCanvas(id) {
|
||||||
|
const r = await api(`/canvas/${id}`)
|
||||||
|
if (!r.ok) throw new Error(r.data?.detail || '加载画布失败')
|
||||||
|
const d = normalizeDoc(safeParse(r.data?.data))
|
||||||
|
// 页面刷新会丢掉进行中的生成任务结果(无轮询),如实标记中断。
|
||||||
|
for (const n of d.nodes) {
|
||||||
|
if (n.type === 'gen' && n.status === 'running') {
|
||||||
|
n.status = 'failed'
|
||||||
|
n.error = '页面已刷新,任务中断'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentId.value = r.data.id
|
||||||
|
title.value = r.data.title || '未命名画布'
|
||||||
|
doc.viewport = d.viewport
|
||||||
|
doc.nodes = d.nodes
|
||||||
|
doc.edges = d.edges
|
||||||
|
selectionIds.value = []
|
||||||
|
resetHistory()
|
||||||
|
saveState.value = 'saved'
|
||||||
|
await nextTick()
|
||||||
|
stage.value?.setViewport(d.viewport)
|
||||||
|
stage.value?.syncZTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(s) {
|
||||||
|
try {
|
||||||
|
return typeof s === 'string' ? JSON.parse(s) : s
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
await loadList()
|
||||||
|
if (!canvases.value.length) {
|
||||||
|
const r = await api('/canvas', jsonBody('POST', { title: '我的画布' }))
|
||||||
|
if (!r.ok) throw new Error(r.data?.detail || '创建画布失败')
|
||||||
|
await loadList()
|
||||||
|
}
|
||||||
|
await loadCanvas(canvases.value[0].id)
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = String(e.message || e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 自动保存 ----
|
||||||
|
let saveTimer = null
|
||||||
|
let retryTimer = null
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (!currentId.value) return
|
||||||
|
saveState.value = 'dirty'
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(saveNow, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNow() {
|
||||||
|
if (!currentId.value || saveState.value === 'saving') return
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
saveState.value = 'saving'
|
||||||
|
try {
|
||||||
|
const r = await api(
|
||||||
|
`/canvas/${currentId.value}`,
|
||||||
|
jsonBody('PUT', { title: title.value, data: serializeDoc(doc) }),
|
||||||
|
)
|
||||||
|
if (r.ok) {
|
||||||
|
saveState.value = 'saved'
|
||||||
|
const row = canvases.value.find((c) => c.id === currentId.value)
|
||||||
|
if (row) row.title = title.value
|
||||||
|
} else {
|
||||||
|
saveState.value = 'error'
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = setTimeout(saveNow, 4000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
saveState.value = 'error'
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = setTimeout(saveNow, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSync() {
|
||||||
|
// 页面卸载时的最后努力(fetch keepalive 不保证,但值得试)
|
||||||
|
if (saveState.value === 'dirty' || saveState.value === 'error') {
|
||||||
|
const body = JSON.stringify({ title: title.value, data: serializeDoc(doc) })
|
||||||
|
try {
|
||||||
|
fetch(`/admin/api/canvas/${currentId.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
keepalive: true,
|
||||||
|
})
|
||||||
|
} catch { /* 尽力而为 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 撤销 / 重做 =================
|
||||||
|
|
||||||
|
const undoStack = []
|
||||||
|
const redoStack = []
|
||||||
|
const undoCount = ref(0)
|
||||||
|
const redoCount = ref(0)
|
||||||
|
const HISTORY_CAP = 60
|
||||||
|
let currentSnap = ''
|
||||||
|
|
||||||
|
function syncCounts() {
|
||||||
|
undoCount.value = undoStack.length
|
||||||
|
redoCount.value = redoStack.length
|
||||||
|
}
|
||||||
|
function snapshotContent() {
|
||||||
|
return JSON.stringify({ nodes: doc.nodes, edges: doc.edges })
|
||||||
|
}
|
||||||
|
function resetHistory() {
|
||||||
|
undoStack.length = 0
|
||||||
|
redoStack.length = 0
|
||||||
|
currentSnap = snapshotContent()
|
||||||
|
syncCounts()
|
||||||
|
}
|
||||||
|
function pushHistory() {
|
||||||
|
const s = snapshotContent()
|
||||||
|
if (s === currentSnap) return
|
||||||
|
undoStack.push(currentSnap)
|
||||||
|
if (undoStack.length > HISTORY_CAP) undoStack.shift()
|
||||||
|
redoStack.length = 0
|
||||||
|
currentSnap = s
|
||||||
|
syncCounts()
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
function restore(json) {
|
||||||
|
const d = JSON.parse(json)
|
||||||
|
doc.nodes = d.nodes || []
|
||||||
|
doc.edges = d.edges || []
|
||||||
|
const alive = new Set(doc.nodes.map((n) => n.id))
|
||||||
|
selectionIds.value = selectionIds.value.filter((id) => alive.has(id))
|
||||||
|
stage.value?.syncZTop()
|
||||||
|
}
|
||||||
|
function undo() {
|
||||||
|
if (!undoStack.length) return
|
||||||
|
redoStack.push(currentSnap)
|
||||||
|
const prev = undoStack.pop()
|
||||||
|
restore(prev)
|
||||||
|
currentSnap = snapshotContent()
|
||||||
|
syncCounts()
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
function redo() {
|
||||||
|
if (!redoStack.length) return
|
||||||
|
undoStack.push(currentSnap)
|
||||||
|
const next = redoStack.pop()
|
||||||
|
restore(next)
|
||||||
|
currentSnap = snapshotContent()
|
||||||
|
syncCounts()
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 节点操作 =================
|
||||||
|
|
||||||
|
function addNote() {
|
||||||
|
const c = viewportCenterWorld()
|
||||||
|
const n = {
|
||||||
|
id: newId('t'), type: 'note', text: '',
|
||||||
|
x: Math.round(c.x - 130), y: Math.round(c.y - 70), w: 260, h: 140, z: 0,
|
||||||
|
}
|
||||||
|
doc.nodes.push(n)
|
||||||
|
selectionIds.value = [n.id]
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageNode(src, prompt = '') {
|
||||||
|
const c = viewportCenterWorld()
|
||||||
|
const n = {
|
||||||
|
id: newId('i'), type: 'image', src, prompt,
|
||||||
|
x: Math.round(c.x - 256 + cascade()), y: Math.round(c.y - 256 + cascade()),
|
||||||
|
w: 512, h: 512, z: 0,
|
||||||
|
}
|
||||||
|
doc.nodes.push(n)
|
||||||
|
selectionIds.value = [n.id]
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
let cascadeN = 0
|
||||||
|
function cascade() {
|
||||||
|
cascadeN = (cascadeN + 1) % 8
|
||||||
|
return cascadeN * 24
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportCenterWorld() {
|
||||||
|
const vp = stage.value?.vp
|
||||||
|
const el = document.querySelector('.cv-stage')
|
||||||
|
const w = el ? el.clientWidth : 800
|
||||||
|
const h = el ? el.clientHeight : 600
|
||||||
|
if (!vp) return { x: 0, y: 0 }
|
||||||
|
return screenToWorld(w / 2, h / 2, vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelection() {
|
||||||
|
if (!selectionIds.value.length) return
|
||||||
|
const dead = new Set(selectionIds.value)
|
||||||
|
doc.nodes = doc.nodes.filter((n) => !dead.has(n.id))
|
||||||
|
doc.edges = doc.edges.filter((e) => !dead.has(e.from) && !dead.has(e.to))
|
||||||
|
selectionIds.value = []
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 画布管理 ----
|
||||||
|
|
||||||
|
async function switchCanvas(id) {
|
||||||
|
if (id === currentId.value) return
|
||||||
|
await saveNow()
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await loadCanvas(id)
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = String(e.message || e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newCanvas() {
|
||||||
|
await saveNow()
|
||||||
|
const r = await api('/canvas', jsonBody('POST', { title: `画布 ${canvases.value.length + 1}` }))
|
||||||
|
if (!r.ok) return
|
||||||
|
await loadList()
|
||||||
|
await switchCanvas(r.data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameCanvas() {
|
||||||
|
const t = window.prompt('画布标题', title.value)
|
||||||
|
if (t === null) return
|
||||||
|
title.value = (t.trim() || '未命名画布').slice(0, 120)
|
||||||
|
await saveNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCanvas() {
|
||||||
|
if (!window.confirm(`删除画布「${title.value}」?此操作不可撤销。`)) return
|
||||||
|
const r = await api(`/canvas/${currentId.value}`, { method: 'DELETE' })
|
||||||
|
if (!r.ok) return
|
||||||
|
await loadList()
|
||||||
|
if (canvases.value.length) {
|
||||||
|
await loadCanvas(canvases.value[0].id)
|
||||||
|
} else {
|
||||||
|
const c = await api('/canvas', jsonBody('POST', { title: '我的画布' }))
|
||||||
|
await loadList()
|
||||||
|
if (c.ok) await loadCanvas(c.data.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 我的图片 ----
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
pickerOpen.value = true
|
||||||
|
if (pickerItems.value.length) return
|
||||||
|
pickerLoading.value = true
|
||||||
|
const r = await api('/my-images')
|
||||||
|
pickerItems.value = (r.data?.data || []).filter((f) =>
|
||||||
|
/\.(png|jpe?g|webp|gif)$/i.test(f.name || ''),
|
||||||
|
)
|
||||||
|
pickerLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 生成 =================
|
||||||
|
|
||||||
|
async function refToBase64(name) {
|
||||||
|
const res = await fetch(generatedUrl(name))
|
||||||
|
if (!res.ok) throw new Error('fetch ref failed')
|
||||||
|
const blob = await res.blob()
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader()
|
||||||
|
fr.onload = () => resolve(fr.result)
|
||||||
|
fr.onerror = reject
|
||||||
|
fr.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
return dataUrl.replace(/^data:[^,]*,/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (genBusy.value || !model.value) return
|
||||||
|
const prompt = (gen.prompt || '').trim() || selectedNotes.value.map((n) => n.text.trim()).join('\n')
|
||||||
|
if (!prompt) {
|
||||||
|
genMsg.value = '请输入提示词,或选中一个有内容的笔记节点'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
genBusy.value = true
|
||||||
|
genMsg.value = ''
|
||||||
|
|
||||||
|
const size = ratioToSize(gen.ratio || ratios.value[0])
|
||||||
|
const sources = [
|
||||||
|
...selectedNotes.value,
|
||||||
|
...selectedImages.value.slice(0, 4),
|
||||||
|
]
|
||||||
|
// 落点:参与源节点的包围盒右侧;无源则视口中心
|
||||||
|
let x
|
||||||
|
let y
|
||||||
|
if (sources.length) {
|
||||||
|
let maxX = -Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
for (const s of sources) {
|
||||||
|
maxX = Math.max(maxX, s.x + s.w)
|
||||||
|
minY = Math.min(minY, s.y)
|
||||||
|
}
|
||||||
|
x = maxX + 64
|
||||||
|
y = minY
|
||||||
|
} else {
|
||||||
|
const c = viewportCenterWorld()
|
||||||
|
x = c.x - size.w / 2
|
||||||
|
y = c.y - size.h / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
id: newId('g'), type: 'gen', status: 'running',
|
||||||
|
x: Math.round(x), y: Math.round(y), w: size.w, h: size.h, z: 0,
|
||||||
|
}
|
||||||
|
doc.nodes.push(node)
|
||||||
|
for (const s of sources) {
|
||||||
|
doc.edges.push({ id: newId('e'), from: s.id, to: node.id })
|
||||||
|
}
|
||||||
|
selectionIds.value = [node.id]
|
||||||
|
pushHistory()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = { model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0] }
|
||||||
|
if (selectedImages.value.length) {
|
||||||
|
payload.reference_images = await Promise.all(
|
||||||
|
selectedImages.value.slice(0, 4).map((n) => refToBase64(n.src).catch(() => null)),
|
||||||
|
).then((arr) => arr.filter(Boolean))
|
||||||
|
}
|
||||||
|
const opts = jsonBody('POST', payload)
|
||||||
|
opts.headers['Idempotency-Key'] = node.id
|
||||||
|
const r = await api('/generate', opts)
|
||||||
|
if (r.ok && r.data?.url) {
|
||||||
|
node.type = 'image'
|
||||||
|
node.src = r.data.url.replace(/^\/images\//, '')
|
||||||
|
node.prompt = prompt
|
||||||
|
node.model = model.value.alias || model.value.name || model.value.id
|
||||||
|
delete node.status
|
||||||
|
delete node.error
|
||||||
|
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
|
||||||
|
genMsg.value = `完成 · 扣 ${r.data.charged ?? '?'} 积分 · ${r.data.elapsed_ms ?? '?'}ms`
|
||||||
|
pushHistory()
|
||||||
|
} else if (r.status === 409 || [502, 504, 522, 524].includes(r.status)) {
|
||||||
|
// 网关重发/超时:后端可能仍在生成。节点保持"生成中",用户可稍后删除。
|
||||||
|
genMsg.value = '请求超时或重复提交,任务可能仍在后台执行'
|
||||||
|
} else {
|
||||||
|
node.status = 'failed'
|
||||||
|
node.error = r.data?.detail || `失败 (${r.status})`
|
||||||
|
genMsg.value = node.error
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
node.status = 'failed'
|
||||||
|
node.error = String(e)
|
||||||
|
genMsg.value = String(e)
|
||||||
|
pushHistory()
|
||||||
|
} finally {
|
||||||
|
genBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 舞台事件 =================
|
||||||
|
|
||||||
|
function onSelection(ids) {
|
||||||
|
selectionIds.value = ids
|
||||||
|
}
|
||||||
|
function onMoveEnd() {
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
function onNoteCommit({ id, text }) {
|
||||||
|
const n = doc.nodes.find((x) => x.id === id)
|
||||||
|
if (!n) return
|
||||||
|
n.text = text
|
||||||
|
pushHistory()
|
||||||
|
}
|
||||||
|
function onImgSize({ id, w, h }) {
|
||||||
|
const n = doc.nodes.find((x) => x.id === id)
|
||||||
|
if (!n || w <= 0 || h <= 0) return
|
||||||
|
if (Math.abs(n.w - w) <= 1 && Math.abs(n.h - h) <= 1) return
|
||||||
|
// 保持中心不动的尺寸校正
|
||||||
|
const cx = n.x + n.w / 2
|
||||||
|
const cy = n.y + n.h / 2
|
||||||
|
n.w = w
|
||||||
|
n.h = h
|
||||||
|
n.x = Math.round(cx - w / 2)
|
||||||
|
n.y = Math.round(cy - h / 2)
|
||||||
|
markDirty() // 尺寸校正不进撤销历史(自动行为)
|
||||||
|
}
|
||||||
|
function onViewportRest(v) {
|
||||||
|
if (
|
||||||
|
doc.viewport.x !== v.x || doc.viewport.y !== v.y || doc.viewport.scale !== v.scale
|
||||||
|
) {
|
||||||
|
doc.viewport = { ...v }
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPanZoom(v) {
|
||||||
|
scalePct.value = Math.round(v.scale * 100)
|
||||||
|
}
|
||||||
|
function onNodeAction({ type, id }) {
|
||||||
|
if (type !== 'preview') return
|
||||||
|
const n = doc.nodes.find((x) => x.id === id)
|
||||||
|
if (n?.type === 'image') previewSrc.value = n.src
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 快捷键 =================
|
||||||
|
|
||||||
|
function isTyping(t) {
|
||||||
|
return !!(t && t.closest && t.closest('input, textarea, select, [contenteditable="true"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (isTyping(e.target)) {
|
||||||
|
if (e.key === 'Escape') e.target.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mod = e.ctrlKey || e.metaKey
|
||||||
|
if (mod && e.key.toLowerCase() === 'z') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.shiftKey) redo()
|
||||||
|
else undo()
|
||||||
|
} else if (mod && e.key.toLowerCase() === 'y') {
|
||||||
|
e.preventDefault()
|
||||||
|
redo()
|
||||||
|
} else if (mod && e.key.toLowerCase() === 'a') {
|
||||||
|
e.preventDefault()
|
||||||
|
stage.value?.selectAll()
|
||||||
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault()
|
||||||
|
deleteSelection()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
stage.value?.clearSelection()
|
||||||
|
previewSrc.value = ''
|
||||||
|
pickerOpen.value = false
|
||||||
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
|
stage.value?.zoomBy(1.2)
|
||||||
|
} else if (e.key === '-') {
|
||||||
|
stage.value?.zoomBy(1 / 1.2)
|
||||||
|
} else if (e.key === '0') {
|
||||||
|
stage.value?.setViewport({ x: 0, y: 0, scale: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
stage.value?.selectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 生命周期 =================
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
window.addEventListener('beforeunload', flushSync)
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
const mm = await api('/managed-models')
|
||||||
|
allModels.value = mm.data?.data || []
|
||||||
|
const first = imageModels.value[0]
|
||||||
|
if (first) {
|
||||||
|
gen.modelId = first.id
|
||||||
|
gen.ratio = (first.ratios && first.ratios[0]) || '1:1'
|
||||||
|
}
|
||||||
|
await boot()
|
||||||
|
})
|
||||||
|
|
||||||
|
function onVisibility() {
|
||||||
|
if (document.visibilityState === 'hidden') flushSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
window.removeEventListener('beforeunload', flushSync)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
clearTimeout(saveTimer)
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由离开前确保保存(组件级守卫,避免全局污染)
|
||||||
|
onBeforeRouteLeave(async () => {
|
||||||
|
if (saveState.value === 'dirty' || saveState.value === 'error') await saveNow()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveLabel = computed(() => ({
|
||||||
|
saved: '已保存', dirty: '待保存…', saving: '保存中…', error: '保存失败,重试中',
|
||||||
|
}[saveState.value] || ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cv-page">
|
||||||
|
<!-- 顶栏 -->
|
||||||
|
<div class="cv-top">
|
||||||
|
<div class="cv-top__left">
|
||||||
|
<button class="cv-btn" title="返回" @click="$router.push('/user')">←</button>
|
||||||
|
<select
|
||||||
|
class="cv-select"
|
||||||
|
:value="currentId"
|
||||||
|
@change="switchCanvas($event.target.value)"
|
||||||
|
>
|
||||||
|
<option v-for="c in canvases" :key="c.id" :value="c.id">{{ c.title }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="cv-btn" title="重命名" @click="renameCanvas">✎</button>
|
||||||
|
<button class="cv-btn" title="新建画布" @click="newCanvas">+</button>
|
||||||
|
<button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-top__right">
|
||||||
|
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
||||||
|
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
||||||
|
<span class="cv-sep" />
|
||||||
|
<button class="cv-btn" title="缩小" @click="stage?.zoomBy(1 / 1.2)">−</button>
|
||||||
|
<span class="cv-zoom">{{ scalePct }}%</span>
|
||||||
|
<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>
|
||||||
|
<span class="cv-sep" />
|
||||||
|
<button class="cv-btn" @click="addNote">+ 笔记</button>
|
||||||
|
<button class="cv-btn" @click="openPicker">+ 图片</button>
|
||||||
|
<span class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 舞台 + 生成面板 -->
|
||||||
|
<div class="cv-body">
|
||||||
|
<div class="cv-stagewrap">
|
||||||
|
<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>
|
||||||
|
<CanvasStage
|
||||||
|
v-else
|
||||||
|
ref="stage"
|
||||||
|
:nodes="doc.nodes"
|
||||||
|
:edges="doc.edges"
|
||||||
|
@selection="onSelection"
|
||||||
|
@move-end="onMoveEnd"
|
||||||
|
@note-commit="onNoteCommit"
|
||||||
|
@img-size="onImgSize"
|
||||||
|
@viewport-rest="onViewportRest"
|
||||||
|
@pan-zoom="onPanZoom"
|
||||||
|
@node-action="onNodeAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成面板 -->
|
||||||
|
<aside class="cv-panel">
|
||||||
|
<div class="cv-panel__title">生成到画布</div>
|
||||||
|
<label class="cv-label">模型</label>
|
||||||
|
<select v-model="gen.modelId" class="cv-input" @change="gen.ratio = ratios[0]">
|
||||||
|
<option v-for="m in imageModels" :key="m.id" :value="m.id">
|
||||||
|
{{ m.alias || m.name || m.id }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="cv-label">比例</label>
|
||||||
|
<div class="cv-ratios">
|
||||||
|
<button
|
||||||
|
v-for="r in ratios"
|
||||||
|
:key="r"
|
||||||
|
type="button"
|
||||||
|
class="cv-chip"
|
||||||
|
:class="{ 'is-on': gen.ratio === r }"
|
||||||
|
@click="gen.ratio = r"
|
||||||
|
>{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="cv-label">提示词(留空则使用选中的笔记)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="gen.prompt"
|
||||||
|
class="cv-input cv-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="描述你要生成的画面…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="cv-refs">
|
||||||
|
参考图:选中画布上的图片节点({{ selectedImages.length }}/4)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="cv-gen" :disabled="genBusy || !model" @click="generate">
|
||||||
|
{{ genBusy ? '生成中…' : '生成' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
|
||||||
|
|
||||||
|
<div class="cv-tips">
|
||||||
|
双击笔记可编辑;Delete 删除选中;Ctrl+Z 撤销;
|
||||||
|
生成结果会自动连线到来源节点。
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<div v-if="previewSrc" class="cv-lightbox" @click="previewSrc = ''">
|
||||||
|
<img :src="generatedUrl(previewSrc)" alt="" @click.stop>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 我的图片选择 -->
|
||||||
|
<div v-if="pickerOpen" class="cv-picker" @click.self="pickerOpen = false">
|
||||||
|
<div class="cv-picker__box">
|
||||||
|
<div class="cv-picker__head">
|
||||||
|
<span>插入图片(点击放置到画布)</span>
|
||||||
|
<button class="cv-btn" @click="pickerOpen = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="pickerLoading" class="cv-loading">加载中…</div>
|
||||||
|
<div v-else-if="!pickerItems.length" class="cv-loading">还没有生成过图片</div>
|
||||||
|
<div v-else class="cv-picker__grid">
|
||||||
|
<img
|
||||||
|
v-for="f in pickerItems"
|
||||||
|
:key="f.name"
|
||||||
|
:src="thumbUrl(f.name)"
|
||||||
|
loading="lazy"
|
||||||
|
alt=""
|
||||||
|
@click="addImageNode(f.name); pickerOpen = false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cv-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.14);
|
||||||
|
background: rgb(255 255 255 / 0.04);
|
||||||
|
color: #c6cbd8;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cv-btn:hover:not(:disabled) {
|
||||||
|
background: rgb(255 255 255 / 0.09);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.cv-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.cv-btn--danger:hover:not(:disabled) {
|
||||||
|
border-color: #6b3b44;
|
||||||
|
color: #e58c96;
|
||||||
|
}
|
||||||
|
.cv-select,
|
||||||
|
.cv-input {
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.14);
|
||||||
|
background: #0e1015;
|
||||||
|
color: #e8eaf0;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.cv-select:focus,
|
||||||
|
.cv-input:focus {
|
||||||
|
border-color: #7c6cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0e1015;
|
||||||
|
}
|
||||||
|
.cv-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
background: #12141b;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cv-top__left,
|
||||||
|
.cv-top__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cv-select {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.cv-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgb(255 255 255 / 0.12);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
.cv-zoom {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1b2;
|
||||||
|
min-width: 44px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cv-save {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7f8798;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.cv-save[data-state='error'] {
|
||||||
|
color: #e58c96;
|
||||||
|
}
|
||||||
|
.cv-save[data-state='saved'] {
|
||||||
|
color: #6fae8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.cv-stagewrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cv-loading,
|
||||||
|
.cv-error {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #9aa1b2;
|
||||||
|
background: #0e1015;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.cv-error {
|
||||||
|
color: #e58c96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-panel {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
background: #12141b;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cv-panel__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e8eaf0;
|
||||||
|
}
|
||||||
|
.cv-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7f8798;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.cv-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.cv-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
.cv-ratios {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cv-chip {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.14);
|
||||||
|
background: transparent;
|
||||||
|
color: #b6bccb;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cv-chip.is-on {
|
||||||
|
border-color: #7c6cff;
|
||||||
|
color: #a99dff;
|
||||||
|
background: rgb(124 108 255 / 0.12);
|
||||||
|
}
|
||||||
|
.cv-refs {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7f8798;
|
||||||
|
border: 1px dashed rgb(255 255 255 / 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.cv-gen {
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #7c6cff, #5d8bff);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cv-gen:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.cv-genmsg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9aa1b2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.cv-tips {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #5f6675;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(4 5 8 / 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
.cv-lightbox img {
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-picker {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(4 5 8 / 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
.cv-picker__box {
|
||||||
|
width: min(720px, 92vw);
|
||||||
|
max-height: 80vh;
|
||||||
|
background: #161922;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.cv-picker__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e8eaf0;
|
||||||
|
}
|
||||||
|
.cv-picker__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cv-picker__grid img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #0e1015;
|
||||||
|
}
|
||||||
|
.cv-picker__grid img:hover {
|
||||||
|
outline: 2px solid #7c6cff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user