From d13ab83c4e21d9c3a3a18df323d0eeb2898297b7 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 2 Sep 2026 21:55:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(asset-library):=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BA=93=20-=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E3=80=81=E5=88=86=E9=A1=B5=E3=80=81=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E9=A2=84=E8=A7=88=E5=92=8C=E6=89=B9=E9=87=8F=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/migrate-sqlite-postgres/main.go | 1 + backend/internal/database/migrations.go | 11 +- backend/internal/database/schema.go | 1 + backend/internal/handler/user_data.go | 113 ++++++++ backend/internal/model/models_project.go | 12 + backend/internal/repository/asset_library.go | 212 ++++++++++++++ .../internal/repository/asset_library_test.go | 102 +++++++ backend/internal/repository/repository.go | 4 +- backend/internal/service/asset_library.go | 203 ++++++++++++++ backend/internal/service/user_data.go | 15 +- .../content/docs/backend/backend-database.mdx | 4 +- web/src/components/layout/workspace-page.tsx | 4 +- web/src/lib/asset-storage-revision.ts | 4 +- .../pages/assets/asset-batch-upload-modal.tsx | 93 +++++++ web/src/pages/assets/index.tsx | 261 ++++++++++++++++-- web/src/services/api/user-data.ts | 58 +++- web/src/stores/use-asset-store.ts | 1 + web/src/styles/globals.css | 25 +- 18 files changed, 1087 insertions(+), 37 deletions(-) create mode 100644 backend/internal/repository/asset_library.go create mode 100644 backend/internal/repository/asset_library_test.go create mode 100644 backend/internal/service/asset_library.go create mode 100644 web/src/pages/assets/asset-batch-upload-modal.tsx diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 4a78c0054..3ad31829a 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -257,6 +257,7 @@ func migrations() []tableMigration { migrateTable[model.ResourceDeletionJob]("resource_deletion_jobs"), migrateTable[model.AnnouncementImageDraft]("announcement_image_drafts"), migrateTable[model.Asset]("assets"), + migrateTable[model.AssetFolder]("asset_folders"), migrateTable[model.ProjectAssetLink]("project_asset_links"), migrateTable[model.ProjectAssetFolder]("project_asset_folders"), migrateTable[model.ProjectAssetCandidate]("project_asset_candidates"), diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 5e9c1fec9..0c6a93709 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -10,13 +10,14 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 5 +const CurrentSchemaVersion int64 = 6 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" const assetTaxonomyCandidateIdentityChecksum = "sha256:asset-taxonomy-candidate-identity-v3-20260831-r1" const resourceUploadKeyChecksum = "sha256:resource-upload-key-v4-20260901" const paymentTopupChecksum = "sha256:payment-topup-v5-20260902" +const assetLibraryFoldersChecksum = "sha256:asset-library-folders-v6-20260902" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -48,6 +49,7 @@ var schemaMigrations = []migration{ {version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3}, {version: 4, name: "resource_upload_key", checksum: resourceUploadKeyChecksum, apply: migrateSchemaV4}, {version: 5, name: "payment_topup", checksum: paymentTopupChecksum, apply: migrateSchemaV5}, + {version: 6, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV6}, } func migrateSchemaV2(tx *gorm.DB) error { @@ -123,6 +125,13 @@ func migrateSchemaV5(tx *gorm.DB) error { return nil } +func migrateSchemaV6(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { + return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err) + } + return nil +} + func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 9e59fd87a..301c93e60 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -59,6 +59,7 @@ func Models() []any { &model.ResourceDeletionJob{}, &model.AnnouncementImageDraft{}, &model.Asset{}, + &model.AssetFolder{}, &model.ProjectAssetLink{}, &model.ProjectAssetFolder{}, &model.ProjectAssetCandidate{}, diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index ffa8c4409..e6ee4df98 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -345,6 +345,32 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if _, paged := c.GetQuery("page"); paged || hasUserAssetPageFilters(c) { + page, pageErr := strconv.Atoi(c.DefaultQuery("page", "1")) + if pageErr != nil || page < 1 { + fail(c, http.StatusBadRequest, service.BadAuthRequest("页码必须是正整数")) + return + } + pageSize, pageSizeErr := strconv.Atoi(c.DefaultQuery("page_size", "40")) + if pageSizeErr != nil || pageSize < 1 { + fail(c, http.StatusBadRequest, service.BadAuthRequest("每页数量必须是正整数")) + return + } + var folderID *string + if value, present := c.GetQuery("folder_id"); present { + folderID = &value + } + assets, pageErr := svc.UserAssetsPage(user.ID, page, pageSize, service.UserAssetPageFilter{ + Kind: c.Query("kind"), Category: c.Query("category"), FolderID: folderID, + Uncategorized: c.Query("uncategorized") == "1", Status: c.Query("status"), Query: c.Query("q"), + }) + if pageErr != nil { + failService(c, pageErr) + return + } + ok(c, assets) + return + } assets, err := svc.UserAssetSummaries(user.ID) if err != nil { failService(c, err) @@ -352,6 +378,84 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"assets": assets}) }) + r.GET("/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + folders, err := svc.AssetFolders(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"folders": folders}) + }) + r.POST("/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.CreateAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.CreateAssetFolder(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.PATCH("/asset-folders/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.UpdateAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.UpdateAssetFolder(user.ID, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.DELETE("/asset-folders/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteAssetFolder(user.ID, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id")}) + }) + r.PATCH("/assets/folder", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.MoveUserAssetsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.MoveUserAssetsToFolder(user.ID, req); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"assetIds": req.AssetIDs, "folderId": req.FolderID}) + }) r.GET("/user-data/snapshot", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -494,6 +598,15 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { }) } +func hasUserAssetPageFilters(c *gin.Context) bool { + for _, key := range []string{"page_size", "kind", "category", "folder_id", "uncategorized", "status", "q"} { + if _, present := c.GetQuery(key); present { + return true + } + } + return false +} + func resourceResponseETag(resource *model.Resource) string { value := strings.Trim(strings.TrimSpace(resource.ETag), `"`) if value == "" { diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 986584583..c55657f52 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -75,6 +75,7 @@ type AnnouncementImageDraft struct { type Asset struct { ID string `json:"id" gorm:"primaryKey;size:80"` UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` + FolderID string `json:"folderId,omitempty" gorm:"index;size:36"` Kind string `json:"kind" gorm:"index;size:24"` Category AssetCategory `json:"category" gorm:"index;size:32"` Status AssetVersionStatus `json:"status" gorm:"index;size:24"` @@ -85,6 +86,17 @@ type Asset struct { UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"` } +// AssetFolder 是用户素材库的一层自定义分类;业务分类仍由 Asset.Category 表达。 +type AssetFolder struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_asset_folders_user_name,priority:1"` + Name string `json:"name" gorm:"size:80"` + NameKey string `json:"-" gorm:"size:80;uniqueIndex:idx_asset_folders_user_name,priority:2"` + Position int `json:"position" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type ProjectAssetLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1;index:idx_project_asset_links_project_folder_position,priority:1"` diff --git a/backend/internal/repository/asset_library.go b/backend/internal/repository/asset_library.go new file mode 100644 index 000000000..409ebbfe8 --- /dev/null +++ b/backend/internal/repository/asset_library.go @@ -0,0 +1,212 @@ +package repository + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +type UserAssetPageFilter struct { + Kind string + Category string + FolderID *string + Uncategorized bool + Status string + Query string +} + +type UserAssetFacetRow struct { + Key string + Count int64 +} + +func (r *Repository) UserAssetsPage(userID string, page int, pageSize int, filter UserAssetPageFilter) ([]model.Asset, int64, error) { + var assets []model.Asset + var total int64 + query := userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), filter, true) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Order("updated_at desc, id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&assets).Error + return assets, total, err +} + +func (r *Repository) UserAssetFacets(userID string, status string) ([]UserAssetFacetRow, []UserAssetFacetRow, []UserAssetFacetRow, error) { + base := func() *gorm.DB { + return userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), UserAssetPageFilter{Status: status}, false) + } + var kindRows []UserAssetFacetRow + if err := base().Select("kind AS key, COUNT(*) AS count").Group("kind").Scan(&kindRows).Error; err != nil { + return nil, nil, nil, err + } + var categoryRows []UserAssetFacetRow + if err := base().Select("category AS key, COUNT(*) AS count").Group("category").Scan(&categoryRows).Error; err != nil { + return nil, nil, nil, err + } + var folderRows []UserAssetFacetRow + if err := base().Select("folder_id AS key, COUNT(*) AS count").Group("folder_id").Scan(&folderRows).Error; err != nil { + return nil, nil, nil, err + } + return kindRows, categoryRows, folderRows, nil +} + +func userAssetFilteredQuery(query *gorm.DB, filter UserAssetPageFilter, includeSearch bool) *gorm.DB { + if value := strings.TrimSpace(filter.Kind); value != "" { + query = query.Where("kind = ?", value) + } + if value := strings.TrimSpace(filter.Category); value != "" { + query = query.Where("category = ?", value) + } + if filter.Uncategorized { + query = query.Where("folder_id = ''") + } else if filter.FolderID != nil { + query = query.Where("folder_id = ?", strings.TrimSpace(*filter.FolderID)) + } + switch strings.TrimSpace(filter.Status) { + case "active": + query = query.Where("status <> ?", model.AssetVersionStatusArchived) + case "archived": + query = query.Where("status = ?", model.AssetVersionStatusArchived) + case "": + default: + query = query.Where("status = ?", strings.TrimSpace(filter.Status)) + } + if includeSearch { + if value := strings.ToLower(strings.TrimSpace(filter.Query)); value != "" { + pattern := "%" + value + "%" + query = query.Where("LOWER(title) LIKE ? OR LOWER(payload_json) LIKE ?", pattern, pattern) + } + } + return query +} + +func (r *Repository) AssetFolders(userID string) ([]model.AssetFolder, error) { + var folders []model.AssetFolder + err := r.db.Where("user_id = ?", userID).Order("position asc, created_at asc").Find(&folders).Error + return folders, err +} + +func (r *Repository) AssetFolderForUser(userID string, folderID string) (*model.AssetFolder, error) { + var folder model.AssetFolder + if err := r.db.First(&folder, "id = ? AND user_id = ?", folderID, userID).Error; err != nil { + return nil, err + } + return &folder, nil +} + +func (r *Repository) AssetFolderNameExists(userID string, nameKey string, excludeID string) (bool, error) { + query := r.db.Model(&model.AssetFolder{}).Where("user_id = ? AND name_key = ?", userID, nameKey) + if excludeID != "" { + query = query.Where("id <> ?", excludeID) + } + var count int64 + err := query.Count(&count).Error + return count > 0, err +} + +func (r *Repository) NextAssetFolderPosition(userID string) (int, error) { + var row struct{ Maximum int } + err := r.db.Model(&model.AssetFolder{}).Select("COALESCE(MAX(position), -1) AS maximum").Where("user_id = ?", userID).Scan(&row).Error + return row.Maximum + 1, err +} + +func (r *Repository) CreateAssetFolder(folder *model.AssetFolder) error { + return r.db.Create(folder).Error +} + +func (r *Repository) UpdateAssetFolder(folder *model.AssetFolder) error { + result := r.db.Model(&model.AssetFolder{}).Where("id = ? AND user_id = ?", folder.ID, folder.UserID).Updates(map[string]any{ + "name": folder.Name, "name_key": folder.NameKey, "updated_at": folder.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) MoveUserAssetsToFolder(userID string, assetIDs []string, folderID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return moveUserAssetsToFolder(tx, userID, assetIDs, folderID) + }) +} + +func (r *Repository) DeleteAssetFolder(userID string, folderID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var folder model.AssetFolder + if err := tx.First(&folder, "id = ? AND user_id = ?", folderID, userID).Error; err != nil { + return err + } + var assets []model.Asset + if err := tx.Where("user_id = ? AND folder_id = ?", userID, folderID).Find(&assets).Error; err != nil { + return err + } + ids := make([]string, len(assets)) + for index := range assets { + ids[index] = assets[index].ID + } + if err := moveUserAssetsToFolder(tx, userID, ids, ""); err != nil { + return err + } + result := tx.Delete(&model.AssetFolder{}, "id = ? AND user_id = ?", folderID, userID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func moveUserAssetsToFolder(tx *gorm.DB, userID string, assetIDs []string, folderID string) error { + if len(assetIDs) == 0 { + return nil + } + var assets []model.Asset + if err := tx.Where("user_id = ? AND id IN ?", userID, assetIDs).Find(&assets).Error; err != nil { + return err + } + if len(assets) != len(assetIDs) { + return gorm.ErrRecordNotFound + } + now := time.Now().UTC() + for index := range assets { + payloadJSON, err := assetPayloadWithFolder(assets[index].PayloadJSON, folderID, now) + if err != nil { + return err + } + if err := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", assets[index].ID, userID).Updates(map[string]any{ + "folder_id": folderID, "payload_json": payloadJSON, "updated_at": now, + }).Error; err != nil { + return err + } + } + return nil +} + +func assetPayloadWithFolder(payloadJSON string, folderID string, updatedAt time.Time) (string, error) { + var payload map[string]any + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + return "", err + } + if folderID == "" { + delete(payload, "folderId") + } else { + payload["folderId"] = folderID + } + payload["updatedAt"] = updatedAt.Format(time.RFC3339Nano) + encoded, err := json.Marshal(payload) + return string(encoded), err +} + +func IsAssetFolderNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/repository/asset_library_test.go b/backend/internal/repository/asset_library_test.go new file mode 100644 index 000000000..dc1311b6d --- /dev/null +++ b/backend/internal/repository/asset_library_test.go @@ -0,0 +1,102 @@ +package repository + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newAssetLibraryTestRepository(t *testing.T) (*Repository, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:asset-library-test-%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { + t.Fatal(err) + } + return New(db), db +} + +func TestUserAssetsPagePaginatesAndIsolatesUsers(t *testing.T) { + repo, db := newAssetLibraryTestRepository(t) + now := time.Now().UTC() + for _, asset := range []model.Asset{ + {ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1","title":"海边"}`, CreatedAt: now, UpdatedAt: now}, + {ID: "asset-2", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "室内", PayloadJSON: `{"id":"asset-2","title":"室内"}`, CreatedAt: now, UpdatedAt: now.Add(time.Second)}, + {ID: "asset-3", UserID: "user-1", Kind: "text", Category: model.AssetCategoryOther, Status: model.AssetVersionStatusConfirmed, Title: "提示词", PayloadJSON: `{"id":"asset-3","title":"提示词"}`, CreatedAt: now, UpdatedAt: now.Add(2 * time.Second)}, + {ID: "asset-4", UserID: "user-2", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "他人素材", PayloadJSON: `{"id":"asset-4","title":"他人素材"}`, CreatedAt: now, UpdatedAt: now}, + } { + if err := db.Create(&asset).Error; err != nil { + t.Fatal(err) + } + } + + assets, total, err := repo.UserAssetsPage("user-1", 2, 1, UserAssetPageFilter{Kind: "image", Status: "active"}) + if err != nil { + t.Fatal(err) + } + if total != 2 || len(assets) != 1 || assets[0].ID != "asset-1" { + t.Fatalf("page result = total %d, assets %#v; want total 2 and asset-1", total, assets) + } +} + +func TestDeleteAssetFolderMovesAssetsToUncategorized(t *testing.T) { + repo, db := newAssetLibraryTestRepository(t) + now := time.Now().UTC() + folder := model.AssetFolder{ID: "folder-1", UserID: "user-1", Name: "灵感", NameKey: "灵感", Position: 0, CreatedAt: now, UpdatedAt: now} + asset := model.Asset{ID: "asset-1", UserID: "user-1", FolderID: folder.ID, Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1","folderId":"folder-1"}`, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&folder).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&asset).Error; err != nil { + t.Fatal(err) + } + + if err := repo.DeleteAssetFolder("user-1", folder.ID); err != nil { + t.Fatal(err) + } + var moved model.Asset + if err := db.First(&moved, "id = ?", asset.ID).Error; err != nil { + t.Fatal(err) + } + if moved.FolderID != "" || strings.Contains(moved.PayloadJSON, "folderId") { + t.Fatalf("asset after folder deletion = %#v", moved) + } +} + +func TestMoveUserAssetsToFolderRollsBackWhenAnyAssetIsForeign(t *testing.T) { + repo, db := newAssetLibraryTestRepository(t) + now := time.Now().UTC() + folder := model.AssetFolder{ID: "folder-1", UserID: "user-1", Name: "灵感", NameKey: "灵感", Position: 0, CreatedAt: now, UpdatedAt: now} + assets := []model.Asset{ + {ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "海边", PayloadJSON: `{"id":"asset-1"}`, CreatedAt: now, UpdatedAt: now}, + {ID: "asset-2", UserID: "user-2", Kind: "image", Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, Title: "他人素材", PayloadJSON: `{"id":"asset-2"}`, CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(&folder).Error; err != nil { + t.Fatal(err) + } + for index := range assets { + if err := db.Create(&assets[index]).Error; err != nil { + t.Fatal(err) + } + } + + if err := repo.MoveUserAssetsToFolder("user-1", []string{"asset-1", "asset-2"}, folder.ID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("move error = %v, want record-not-found", err) + } + var unchanged model.Asset + if err := db.First(&unchanged, "id = ?", "asset-1").Error; err != nil { + t.Fatal(err) + } + if unchanged.FolderID != "" { + t.Fatalf("foreign asset caused partial move: %#v", unchanged) + } +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 8db8bc1b1..4eddfc434 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1082,7 +1082,7 @@ func (r *Repository) Assets(userID string) ([]model.Asset, error) { func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) { var assets []model.Asset - err := r.db.Select("id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error + err := r.db.Select("id", "folder_id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error return assets, err } @@ -1106,7 +1106,7 @@ func (r *Repository) AssetsForUserIDs(userID string, ids []string) ([]model.Asse func (r *Repository) UpsertAsset(asset *model.Asset) error { result := r.db.Model(&model.Asset{}). Where("id = ? AND user_id = ?", asset.ID, asset.UserID). - Updates(map[string]any{"kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt}) + Updates(map[string]any{"folder_id": asset.FolderID, "kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt}) if result.Error != nil || result.RowsAffected > 0 { return result.Error } diff --git a/backend/internal/service/asset_library.go b/backend/internal/service/asset_library.go new file mode 100644 index 000000000..571af36aa --- /dev/null +++ b/backend/internal/service/asset_library.go @@ -0,0 +1,203 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + "time" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +type UserAssetPage struct { + Assets []json.RawMessage `json:"assets"` + KindCounts map[string]int64 `json:"kindCounts"` + CategoryCounts map[string]int64 `json:"categoryCounts"` + FolderCounts map[string]int64 `json:"folderCounts"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + HasMore bool `json:"hasMore"` +} + +type UserAssetPageFilter struct { + Kind string + Category string + FolderID *string + Uncategorized bool + Status string + Query string +} + +type CreateAssetFolderRequest struct { + Name string `json:"name"` +} + +type UpdateAssetFolderRequest struct { + Name string `json:"name"` +} + +type MoveUserAssetsRequest struct { + AssetIDs []string `json:"assetIds"` + FolderID string `json:"folderId"` +} + +func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter UserAssetPageFilter) (UserAssetPage, error) { + page, pageSize = normalizeProjectPage(page, pageSize, 120) + repoFilter := repository.UserAssetPageFilter{ + Kind: filter.Kind, Category: filter.Category, FolderID: filter.FolderID, + Uncategorized: filter.Uncategorized, Status: filter.Status, Query: filter.Query, + } + assets, total, err := s.repo.UserAssetsPage(userID, page, pageSize, repoFilter) + if err != nil { + return UserAssetPage{}, err + } + rawAssets := make([]json.RawMessage, 0, len(assets)) + for _, asset := range assets { + if strings.TrimSpace(asset.PayloadJSON) != "" { + rawAssets = append(rawAssets, json.RawMessage(asset.PayloadJSON)) + } + } + kindRows, categoryRows, folderRows, err := s.repo.UserAssetFacets(userID, filter.Status) + if err != nil { + return UserAssetPage{}, err + } + return UserAssetPage{ + Assets: rawAssets, KindCounts: assetFacetMap(kindRows), CategoryCounts: assetFacetMap(categoryRows), FolderCounts: assetFacetMap(folderRows), + Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total, + }, nil +} + +func assetFacetMap(rows []repository.UserAssetFacetRow) map[string]int64 { + result := make(map[string]int64, len(rows)) + for _, row := range rows { + result[row.Key] = row.Count + } + return result +} + +func (s *Service) AssetFolders(userID string) ([]model.AssetFolder, error) { + return s.repo.AssetFolders(userID) +} + +func (s *Service) CreateAssetFolder(userID string, req CreateAssetFolderRequest) (model.AssetFolder, error) { + name, nameKey, err := normalizeAssetFolderName(req.Name) + if err != nil { + return model.AssetFolder{}, err + } + exists, err := s.repo.AssetFolderNameExists(userID, nameKey, "") + if err != nil { + return model.AssetFolder{}, err + } + if exists { + return model.AssetFolder{}, BadAuthRequest("已存在同名素材分类") + } + position, err := s.repo.NextAssetFolderPosition(userID) + if err != nil { + return model.AssetFolder{}, err + } + now := time.Now().UTC() + folder := model.AssetFolder{ID: newID(), UserID: userID, Name: name, NameKey: nameKey, Position: position, CreatedAt: now, UpdatedAt: now} + if err := s.repo.CreateAssetFolder(&folder); err != nil { + return model.AssetFolder{}, err + } + return folder, nil +} + +func (s *Service) UpdateAssetFolder(userID string, folderID string, req UpdateAssetFolderRequest) (model.AssetFolder, error) { + folder, err := s.repo.AssetFolderForUser(userID, strings.TrimSpace(folderID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.AssetFolder{}, BadAuthRequest("素材分类不存在") + } + return model.AssetFolder{}, err + } + name, nameKey, err := normalizeAssetFolderName(req.Name) + if err != nil { + return model.AssetFolder{}, err + } + exists, err := s.repo.AssetFolderNameExists(userID, nameKey, folder.ID) + if err != nil { + return model.AssetFolder{}, err + } + if exists { + return model.AssetFolder{}, BadAuthRequest("已存在同名素材分类") + } + folder.Name = name + folder.NameKey = nameKey + folder.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateAssetFolder(folder); err != nil { + return model.AssetFolder{}, err + } + return *folder, nil +} + +func (s *Service) DeleteAssetFolder(userID string, folderID string) error { + s.storageMu.Lock() + defer s.storageMu.Unlock() + err := s.repo.DeleteAssetFolder(userID, strings.TrimSpace(folderID)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("素材分类不存在") + } + return err +} + +func (s *Service) MoveUserAssetsToFolder(userID string, req MoveUserAssetsRequest) error { + ids := uniqueNonemptyStrings(req.AssetIDs) + if len(ids) == 0 { + return BadAuthRequest("请选择要移动的素材") + } + if len(ids) > 200 { + return BadAuthRequest("一次最多移动 200 个素材") + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + folderID := strings.TrimSpace(req.FolderID) + if folderID != "" { + if _, err := s.repo.AssetFolderForUser(userID, folderID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("目标素材分类不存在") + } + return err + } + } + if err := s.repo.MoveUserAssetsToFolder(userID, ids, folderID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("部分素材不存在或不属于当前用户") + } + return err + } + return nil +} + +func normalizeAssetFolderName(value string) (string, string, error) { + name := strings.TrimSpace(value) + if name == "" { + return "", "", BadAuthRequest("请输入素材分类名称") + } + if utf8.RuneCountInString(name) > 40 { + return "", "", BadAuthRequest("素材分类名称不能超过 40 个字符") + } + return name, strings.ToLower(name), nil +} + +func uniqueNonemptyStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 2f7224d04..5c843f89d 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -22,6 +22,7 @@ type CanvasProjectsSyncRequest struct { type UserDataSummary struct { ID string `json:"id"` + FolderID string `json:"folderId,omitempty"` Kind string `json:"kind,omitempty"` Category string `json:"category,omitempty"` Status string `json:"status,omitempty"` @@ -54,7 +55,7 @@ func (s *Service) UserAssetSummaries(userID string) ([]UserDataSummary, error) { } result := make([]UserDataSummary, 0, len(assets)) for _, asset := range assets { - result = append(result, UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}) + result = append(result, UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}) } return result, nil } @@ -78,6 +79,14 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS } s.storageMu.Lock() defer s.storageMu.Unlock() + if asset.FolderID != "" { + if _, err := s.repo.AssetFolderForUser(userID, asset.FolderID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return UserDataSummary{}, BadAuthRequest("素材分类不存在") + } + return UserDataSummary{}, err + } + } existing, existingErr := s.repo.AssetForUser(userID, asset.ID) if existingErr != nil && !errors.Is(existingErr, gorm.ErrRecordNotFound) { return UserDataSummary{}, existingErr @@ -104,7 +113,7 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS if existingErr != nil { s.recordActivity(userID, "asset", 1) } - return UserDataSummary{ID: asset.ID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil + return UserDataSummary{ID: asset.ID, FolderID: asset.FolderID, Kind: asset.Kind, Category: string(asset.Category), Status: string(asset.Status), Title: asset.Title, CreatedAt: asset.CreatedAt, UpdatedAt: asset.UpdatedAt}, nil } func (s *Service) DeleteUserAsset(userID string, id string) error { @@ -283,6 +292,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { } var payload struct { ID string `json:"id"` + FolderID string `json:"folderId"` Kind string `json:"kind"` Category string `json:"category"` Status string `json:"status"` @@ -316,6 +326,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { return model.Asset{ ID: id, UserID: userID, + FolderID: strings.TrimSpace(payload.FolderID), Kind: strings.TrimSpace(payload.Kind), Category: category, Status: status, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 9d5dca8dd..435a3e4df 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 5。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构。 +当前结构版本为 6。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`。 版本 3 会将 `wardrobe`、`weapon`、`accessory` 迁移为 `prop`,将 `style` 迁移为 `material`,并把原先归入 `other` 的图片、视频、音频和 3D 模型迁移为 `material`。迁移只合并重复的待确认候选:保留最早记录,其余标记为 `ignored`;已经确认的资产不会被自动删除或归并。 @@ -52,7 +52,7 @@ description: 影策后端核心持久化表与状态边界 | --- | --- | --- | | `projects` | 保存短剧项目基础设置、画幅、画风快照、项目主图和默认图片/视频模型 | `default_image_model` / `default_video_model` 保存前端模型选择器的渠道限定引用;模型下架或渠道停用时前端回退当前全局默认。`cover_resource_id` 只能引用当前用户已就绪的图片资源;资源删除检查会阻止仍被项目主图使用的图片被回收 | | `project_units` | 保存项目章节或集数的正文、排序和状态 | `word_count` 在正文创建、导入和更新时同步维护,项目详情只返回摘要和字数,不批量返回长篇正文;升级时会为旧正文回填字数 | -| `assets` / `asset_versions` / `project_asset_links` | 保存用户资产、不可变版本及项目引用 | 业务分类统一为 `character`、`environment`、`prop`、`material`、`other`;服饰、武器与配饰属于道具,图片/视频/音频等通用媒体默认属于素材 | +| `assets` / `asset_folders` / `asset_versions` / `project_asset_links` | 保存用户资产、自定义分类、不可变版本及项目引用 | 业务分类统一为 `character`、`environment`、`prop`、`material`、`other`;自定义分类通过用户级 `asset_folders` 与 `assets.folder_id` 表达,不改变业务分类语义 | | `project_asset_candidates` | 保存章节或 Agent 提取后等待用户确认的资产候选 | `name_key` 保存规范化名称,`source` 记录提取入口;角色候选只允许来自 `chapter_character_extract` 且必须关联章节,同一项目、分类和规范化名称的待确认候选保持唯一 | | `shots` | 保存章节内稳定的镜头身份、排序和当前版本指针 | 不保存完整生成历史;`current_revision_id` 指向当前采用的分镜脚本版本 | | `shot_revisions` | 保存画面、动作、台词、景别、机位、运镜、提示词和动作节拍 | 每次编辑创建新版本;历史版本不可被静默覆盖 | diff --git a/web/src/components/layout/workspace-page.tsx b/web/src/components/layout/workspace-page.tsx index 84813de66..0d7311801 100644 --- a/web/src/components/layout/workspace-page.tsx +++ b/web/src/components/layout/workspace-page.tsx @@ -87,8 +87,8 @@ export function TableSurface({ children, className }: { children: ReactNode; cla return
{children}
; } -export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
; +export function CollectionGrid({ children, className, style }: { children: ReactNode; className?: string; style?: React.CSSProperties }) { + return
{children}
; } /* 自研轻量分页:页码胶囊 + 省略号 + 每页条数 + 总数,替代 AntD Pagination(无 AntD 残留样式,与工具栏容器同语言)。 */ diff --git a/web/src/lib/asset-storage-revision.ts b/web/src/lib/asset-storage-revision.ts index a52c8e0ce..0c4ce56dd 100644 --- a/web/src/lib/asset-storage-revision.ts +++ b/web/src/lib/asset-storage-revision.ts @@ -10,10 +10,12 @@ export type AssetStorageDocument = { export function normalizeAssetRecord(asset: Asset): Asset { const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)); - if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category) return asset; + const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : ""; + if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId) return asset; return { ...asset, tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [], + folderId: folderId || undefined, category, }; } diff --git a/web/src/pages/assets/asset-batch-upload-modal.tsx b/web/src/pages/assets/asset-batch-upload-modal.tsx new file mode 100644 index 000000000..c7cf2ec09 --- /dev/null +++ b/web/src/pages/assets/asset-batch-upload-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, useRef, useState } from "react"; +import { App, Button, Modal, Progress, Select, Tag } from "antd"; +import { FileImage, UploadCloud, X } from "lucide-react"; + +import { ASSET_CATEGORY_OPTIONS, type AssetCategory } from "@/lib/asset-category"; +import { readImageMeta } from "@/lib/image-utils"; +import { uploadImage } from "@/services/image-storage"; +import { saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; +import type { AssetFolder } from "@/services/api/user-data"; + +type BatchItem = { id: string; file: File; status: "queued" | "uploading" | "done" | "error"; error?: string; percent?: number }; + +export function AssetBatchUploadModal({ open, defaultFolderId, folders, onClose, onComplete }: { open: boolean; defaultFolderId: string; folders: AssetFolder[]; onClose: () => void; onComplete: () => Promise }) { + const { message } = App.useApp(); + const addAsset = useAssetStore((state) => state.addAsset); + const [items, setItems] = useState([]); + const [category, setCategory] = useState("material"); + const [folderId, setFolderId] = useState(defaultFolderId); + const [tags, setTags] = useState([]); + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + const doneCount = items.filter((item) => item.status === "done").length; + const failedItems = items.filter((item) => item.status === "error"); + const hasFiles = items.length > 0; + const folderOptions = useMemo(() => [{ label: "未分类", value: "" }, ...folders.map((folder) => ({ label: folder.name, value: folder.id }))], [folders]); + + const chooseFiles = (files: File[]) => { + const images = files.filter((file) => file.type.startsWith("image/")); + if (!images.length) { + message.warning("请选择图片文件"); + return; + } + setItems((current) => [...current, ...images.map((file) => ({ id: `${file.name}-${file.lastModified}-${Math.random()}`, file, status: "queued" as const }))]); + }; + + const uploadBatch = async () => { + const pending = items.filter((item) => item.status === "queued" || item.status === "error"); + if (!pending.length) return; + setUploading(true); + let cursor = 0; + const worker = async () => { + while (cursor < pending.length) { + const item = pending[cursor++]; + setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "uploading", percent: 10, error: undefined } : entry)); + try { + const uploaded = await uploadImage(item.file); + const meta = await readImageMeta(uploaded.url).catch(() => ({ width: uploaded.width, height: uploaded.height, mimeType: uploaded.mimeType })); + addAsset({ kind: "image", title: item.file.name.replace(/\.[^.]+$/, ""), category, folderId: folderId || undefined, coverUrl: uploaded.url, tags, source: "批量上传", metadata: { source: "manual-batch" }, data: { dataUrl: uploaded.url, storageKey: uploaded.storageKey, width: meta.width || uploaded.width, height: meta.height || uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType } }); + setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "done", percent: 100 } : entry)); + } catch (error) { + setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "error", error: error instanceof Error ? error.message : "上传失败" } : entry)); + } + } + }; + await Promise.all(Array.from({ length: Math.min(4, pending.length) }, () => worker())); + await flushAssetStorePersistence(); + try { + await saveRemoteUserDataNow(); + } catch { + message.warning("部分素材已保存在本地,稍后自动同步至云端"); + } + setUploading(false); + await onComplete(); + }; + + const close = () => { + if (uploading) return; + setItems([]); + setTags([]); + setFolderId(defaultFolderId); + onClose(); + }; + + return +
+
+ +
+ ; +} diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index d2ae174ba..be84cc7b1 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -1,6 +1,6 @@ -import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, type LucideIcon } from "lucide-react"; +import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd"; import type { MenuProps } from "antd"; import { useNavigate } from "react-router"; @@ -13,6 +13,7 @@ import { saveAs } from "file-saver"; import { cn } from "@/lib/utils"; import { useCopyText } from "@/hooks/use-copy-text"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils"; @@ -23,6 +24,8 @@ import { exportAssets, readAssetPackage } from "./asset-transfer"; import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage"; import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { useUserStore } from "@/stores/use-user-store"; +import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data"; +import { AssetBatchUploadModal } from "./asset-batch-upload-modal"; import { useAppearanceStore } from "@/stores/use-appearance-store"; type LibraryAsset = Exclude; @@ -30,6 +33,7 @@ type LibraryAsset = Exclude; type AssetFormValues = { kind: AssetKind; category: AssetCategory; + folderId?: string; title: string; coverUrl: string; tags: string[]; @@ -50,6 +54,11 @@ const kindOptions = [ ]; const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEGORY_OPTIONS]; +const ASSET_LIBRARY_QUERY_KEY = ["asset-library"] as const; +const ASSET_FOLDER_QUERY_KEY = ["asset-folders"] as const; +const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density"; +type AssetGridDensity = 6 | 8 | 10; +type AssetFolderFilter = "all" | "uncategorized" | string; const assetKindIcons: Record = { text: FileText, @@ -73,13 +82,16 @@ export default function AssetsPage() { const addAsset = useAssetStore((state) => state.addAsset); const updateAsset = useAssetStore((state) => state.updateAsset); + const userId = useUserStore((state) => state.user?.id || ""); const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30); const [viewMode, setViewMode] = useState<"library" | "trash">("library"); const [keyword, setKeyword] = useState(""); const [kindFilter, setKindFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all"); + const [folderFilter, setFolderFilter] = useState("all"); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(35); + const [pageSize, setPageSize] = useState(40); + const [gridDensity, setGridDensity] = useState(readAssetGridDensity); const [editingAsset, setEditingAsset] = useState(null); const [isAssetOpen, setIsAssetOpen] = useState(false); const [previewAsset, setPreviewAsset] = useState(null); @@ -88,6 +100,10 @@ export default function AssetsPage() { const [selectedIds, setSelectedIds] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchArchiveOpen, setBatchArchiveOpen] = useState(false); + const [batchUploadOpen, setBatchUploadOpen] = useState(false); + const [folderEditor, setFolderEditor] = useState(null); + const [folderName, setFolderName] = useState(""); + const [folderSaving, setFolderSaving] = useState(false); const [formKind, setFormKind] = useState("text"); const [imageDraft, setImageDraft] = useState(null); @@ -98,43 +114,139 @@ export default function AssetsPage() { const title = Form.useWatch("title", form) || ""; const tags = Form.useWatch("tags", form) || []; const content = Form.useWatch("content", form) || ""; + const debouncedKeyword = useDebouncedValue(keyword.trim(), 250); + + const foldersQuery = useQuery({ + queryKey: ASSET_FOLDER_QUERY_KEY, + queryFn: () => listAssetFolders(), + enabled: Boolean(userId), + }); + const folders = foldersQuery.data?.folders || []; const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]); const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]); const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]); const validAssets = viewMode === "trash" ? trashAssets : activeAssets; const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]); - const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => asset.kind === option.value).length])), [activeAssets]); - const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => (asset.category || "other") === option.value).length])), [activeAssets]); - const canCreateAsset = viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all"; - const filteredAssets = useMemo(() => { const query = keyword.trim().toLowerCase(); return validAssets.filter((asset) => { if (kindFilter !== "all" && asset.kind !== kindFilter) return false; if (categoryFilter !== "all" && (asset.category || "other") !== categoryFilter) return false; + if (folderFilter === "uncategorized" && asset.folderId) return false; + if (folderFilter !== "all" && folderFilter !== "uncategorized" && asset.folderId !== folderFilter) return false; if (!query) return true; return assetSearchText(asset).includes(query); }); - }, [validAssets, keyword, kindFilter, categoryFilter]); - const filteredAssetIds = useMemo(() => filteredAssets.map((asset) => asset.id), [filteredAssets]); - const allFilteredSelected = filteredAssetIds.length > 0 && filteredAssetIds.every((id) => selectedIds.includes(id)); + }, [validAssets, keyword, kindFilter, categoryFilter, folderFilter]); - const visibleAssets = useMemo(() => { + const assetPageQuery = useQuery({ + queryKey: [...ASSET_LIBRARY_QUERY_KEY, page, pageSize, viewMode, kindFilter, categoryFilter, folderFilter, debouncedKeyword], + queryFn: ({ signal }) => listRemoteAssetsPage({ + page, + pageSize, + status: viewMode === "trash" ? "archived" : "active", + kind: kindFilter === "all" ? undefined : kindFilter, + category: categoryFilter === "all" ? undefined : categoryFilter, + folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : undefined, + uncategorized: folderFilter === "uncategorized", + query: debouncedKeyword || undefined, + signal, + }), + enabled: Boolean(userId), + placeholderData: keepPreviousData, + }); + + const localVisibleAssets = useMemo(() => { const start = (page - 1) * pageSize; return filteredAssets.slice(start, start + pageSize); }, [filteredAssets, page, pageSize]); + const visibleAssets = useMemo( + () => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), + [assetPageQuery.data?.assets, localVisibleAssets], + ); + const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]); + const allFilteredSelected = visibleAssetIds.length > 0 && visibleAssetIds.every((id) => selectedIds.includes(id)); + const canCreateAsset = page === 1 && viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all" && folderFilter === "all"; + const totalAssets = assetPageQuery.data?.total ?? filteredAssets.length; + const kindCounts = useMemo(() => assetCountMap(kindOptions, assetPageQuery.data?.kindCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, viewMode]); + const categoryCounts = useMemo(() => assetCountMap(categoryOptions, assetPageQuery.data?.categoryCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, viewMode]); + const folderCounts = assetPageQuery.data?.folderCounts || {}; useEffect(() => { - const maxPage = Math.max(1, Math.ceil(filteredAssets.length / pageSize)); + const maxPage = Math.max(1, Math.ceil(totalAssets / pageSize)); setPage((value) => Math.min(value, maxPage)); - }, [filteredAssets.length, pageSize]); + }, [pageSize, totalAssets]); + + useEffect(() => { + window.localStorage.setItem(ASSET_GRID_DENSITY_KEY, String(gridDensity)); + }, [gridDensity]); useEffect(() => { const existingIds = new Set(validAssets.map((asset) => asset.id)); setSelectedIds((current) => current.filter((id) => existingIds.has(id))); }, [validAssets]); + const folderSelectOptions = useMemo(() => [ + { label: "未分类", value: "" }, + ...folders.map((folder) => ({ label: folder.name, value: folder.id })), + ], [folders]); + + const invalidateAssetLibrary = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ASSET_LIBRARY_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: ASSET_FOLDER_QUERY_KEY }), + ]); + }; + + const saveFolder = async () => { + const name = folderName.trim(); + if (!name || !folderEditor) return; + setFolderSaving(true); + try { + if (folderEditor === "new") await createAssetFolder(name); + else await updateAssetFolder(folderEditor.id, name); + setFolderEditor(null); + setFolderName(""); + await invalidateAssetLibrary(); + message.success(folderEditor === "new" ? "素材分类已创建" : "素材分类已重命名"); + } catch (error) { + message.error(error instanceof Error ? error.message : "素材分类保存失败"); + } finally { + setFolderSaving(false); + } + }; + + const removeFolder = async (folder: AssetFolder) => { + try { + await deleteAssetFolder(folder.id); + for (const asset of useAssetStore.getState().assets) { + if (asset.folderId === folder.id) updateAsset(asset.id, { folderId: undefined }); + } + await flushAssetStorePersistence(); + if (folderFilter === folder.id) setFolderFilter("all"); + setPage(1); + await invalidateAssetLibrary(); + message.success(`已删除分类「${folder.name}」,其中素材已移至未分类`); + } catch (error) { + message.error(error instanceof Error ? error.message : "素材分类删除失败"); + } + }; + + const moveAssetsToFolder = async (assetIds: string[], folderId: string) => { + if (!assetIds.length) return; + try { + await moveRemoteAssetsToFolder(assetIds, folderId); + assetIds.forEach((id) => updateAsset(id, { folderId: folderId || undefined })); + await flushAssetStorePersistence(); + setSelectedIds([]); + await invalidateAssetLibrary(); + message.success(`已移动 ${assetIds.length} 个素材`); + } catch (error) { + message.error(error instanceof Error ? error.message : "移动素材失败"); + } + }; + const openCreate = () => { setEditingAsset(null); setImageDraft(null); @@ -142,7 +254,7 @@ export default function AssetsPage() { setImageUploading(false); setImageUploadProgress(null); setFormKind("text"); - form.setFieldsValue({ kind: "text", category: "other", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); + form.setFieldsValue({ kind: "text", category: "other", folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : "", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); setIsAssetOpen(true); }; @@ -156,6 +268,7 @@ export default function AssetsPage() { form.setFieldsValue({ kind: asset.kind, category: asset.category || "other", + folderId: asset.folderId || "", title: asset.title, coverUrl: asset.coverUrl, tags: asset.tags || [], @@ -191,6 +304,7 @@ export default function AssetsPage() { const base = { title: values.title.trim(), category: values.category, + folderId: values.folderId || undefined, status: editingAsset?.status || ("confirmed" as const), primaryVersionId: editingAsset?.primaryVersionId, coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""), @@ -212,7 +326,14 @@ export default function AssetsPage() { editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); } - message.success(editingAsset ? "素材已更新" : "素材已保存"); + await flushAssetStorePersistence(); + try { + await saveRemoteUserDataNow(); + await invalidateAssetLibrary(); + message.success(editingAsset ? "素材已更新" : "素材已保存"); + } catch { + message.warning(editingAsset ? "素材已在本地更新,稍后自动同步至云端" : "素材已在本地保存,稍后自动同步至云端"); + } setIsAssetOpen(false); }; @@ -431,6 +552,9 @@ export default function AssetsPage() { + @@ -460,6 +584,7 @@ export default function AssetsPage() { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); + setFolderFilter("all"); setPage(1); }} > @@ -473,8 +598,24 @@ export default function AssetsPage() { setPage(1); setKeyword(event.target.value); }} - /> - + /> + } + options={[{ label: "舒适 · 6 列", value: 6 }, { label: "标准 · 8 列", value: 8 }, { label: "紧凑 · 10 列", value: 10 }]} + onChange={(value) => setGridDensity(value as AssetGridDensity)} + /> +
@@ -503,6 +644,27 @@ export default function AssetsPage() { }} className="lg:mt-5" /> +
+
+ 我的分类 + +
+
+ + {folders.map((folder) => ( +
+ + , label: "重命名", onClick: () => { setFolderName(folder.name); setFolderEditor(folder); } }, { key: "delete", danger: true, icon: , label: "删除分类", onClick: () => void removeFolder(folder) }] }}> + + +
+ ))} +
+
垃圾箱与归档
@@ -1218,6 +1402,24 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss ); } +function AssetImageZoom({ asset }: { asset: LibraryAsset & { kind: "image" } }) { + const [scale, setScale] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null); + const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); }; + return ( +
{ event.preventDefault(); setScale((value) => Math.min(4, Math.max(.25, value * (event.deltaY < 0 ? 1.12 : .89)))); }} onPointerDown={(event) => { if (scale <= 1) return; event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }; }} onPointerMove={(event) => { const drag = dragRef.current; if (!drag) return; setOffset({ x: drag.ox + event.clientX - drag.x, y: drag.oy + event.clientY - drag.y }); }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }}> + {asset.title} +
+ + + + +
+
+ ); +} + function assetArchiveFacts(asset: LibraryAsset) { const facts: Array<{ label: string; value: string }> = [ { label: "类型", value: assetKindLabel(asset.kind) }, @@ -1279,6 +1481,21 @@ function assetDownloadLabel(asset: LibraryAsset) { return "下载图片"; } +function readAssetGridDensity(): AssetGridDensity { + if (typeof window === "undefined") return 8; + const value = Number(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY)); + return value === 6 || value === 10 ? value : 8; +} + +function assetCountMap(options: T[], remote: Record | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) { + const result = new Map(); + options.forEach((option) => { + if (remote) result.set(option.value, option.value === "all" ? Object.values(remote).reduce((sum, count) => sum + count, 0) : remote[option.value] || 0); + else result.set(option.value, option.value === "all" ? fallback.length : fallback.filter((asset) => valueOf(asset) === option.value).length); + }); + return result; +} + function formatAssetDuration(durationMs?: number) { if (!durationMs) return "时长未知"; return `${Math.round(durationMs / 100) / 10} 秒`; diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index 80ca28d34..4d023e136 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -1,17 +1,37 @@ import type { Asset } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; -import { apiClient, request } from "@/services/api/request"; +import { apiClient, compactApiParams, request } from "@/services/api/request"; const api = apiClient; export type RemoteUserDataSummary = { id: string; + folderId?: string; kind?: string; title: string; createdAt: string; updatedAt: string; }; +export type AssetFolder = { + id: string; + name: string; + position: number; + createdAt: string; + updatedAt: string; +}; + +export type RemoteAssetPage = { + assets: Asset[]; + kindCounts: Record; + categoryCounts: Record; + folderCounts: Record; + page: number; + pageSize: number; + total: number; + hasMore: boolean; +}; + export type RemoteUserDataSnapshot = { assets: Asset[]; projects: CanvasProject[]; @@ -25,6 +45,42 @@ export function listRemoteAssets() { return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets")); } +export function listRemoteAssetsPage(options: { page: number; pageSize: number; kind?: string; category?: string; folderId?: string; uncategorized?: boolean; status?: string; query?: string; signal?: AbortSignal }) { + return request(api.get("/assets", { + signal: options.signal, + params: compactApiParams({ + page: options.page, + page_size: options.pageSize, + kind: options.kind, + category: options.category, + folder_id: options.folderId, + uncategorized: options.uncategorized ? 1 : undefined, + status: options.status, + q: options.query, + }), + })); +} + +export function listAssetFolders() { + return request<{ folders: AssetFolder[] }>(api.get("/asset-folders")); +} + +export function createAssetFolder(name: string) { + return request<{ folder: AssetFolder }>(api.post("/asset-folders", { name })); +} + +export function updateAssetFolder(id: string, name: string) { + return request<{ folder: AssetFolder }>(api.patch(`/asset-folders/${encodeURIComponent(id)}`, { name })); +} + +export function deleteAssetFolder(id: string) { + return request<{ id: string }>(api.delete(`/asset-folders/${encodeURIComponent(id)}`)); +} + +export function moveRemoteAssetsToFolder(assetIds: string[], folderId = "") { + return request<{ assetIds: string[]; folderId: string }>(api.patch("/assets/folder", { assetIds, folderId })); +} + export function getRemoteAsset(id: string) { return request<{ asset: Asset }>(api.get(`/assets/${encodeURIComponent(id)}`)); } diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 0dba0f29e..59645f46a 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -37,6 +37,7 @@ type AssetBase = { title: string; coverUrl: string; tags: string[]; + folderId?: string; category?: AssetCategory; status?: AssetStatus; primaryVersionId?: string; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index c516e7090..afc18208f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -15766,6 +15766,9 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou .assets-filter-item.is-active { background: var(--control-selected-bg); color: var(--control-selected-fg); font-weight: 560; } .assets-filter-item.is-active:hover { background: var(--control-selected-hover-bg); color: var(--control-selected-fg); } .assets-filter-item.is-active .assets-filter-count { background: color-mix(in srgb, var(--control-selected-fg) 18%, transparent); color: var(--control-selected-fg); } +.assets-folder-row { display: flex; min-width: 0; align-items: center; gap: 2px; } +.assets-folder-add, .assets-folder-more { display: grid; width: 26px; height: 26px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--r-md); background: transparent; color: color-mix(in srgb, var(--foreground) 42%, transparent); cursor: pointer; } +.assets-folder-add:hover, .assets-folder-more:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); } @media (min-width: 1024px) { .assets-filter-item { width: 100%; flex: none; } } @@ -15774,7 +15777,7 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou .assets-cover { position: relative; overflow: hidden; } .assets-cover-link { position: relative; display: block; width: 100%; height: 100%; padding: 0; cursor: pointer; text-align: left; } .assets-cover-link:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } -.assets-cover-media { width: 100%; height: 100%; object-fit: cover; } +.assets-cover-media { width: 100%; height: 100%; object-fit: contain; background: color-mix(in srgb, var(--foreground) 5%, transparent); } .assets-cover-fallback { display: grid; width: 100%; height: 100%; place-items: center; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); } .assets-cover-vignette { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, .16) 0%, transparent 32%, transparent 58%, rgba(0, 0, 0, .32) 100%); } .assets-cover-badges { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 5px; pointer-events: none; } @@ -15966,6 +15969,20 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou .asset-archive-subtitle { margin-top: 4px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-label); } .asset-archive-preview { overflow: hidden; border-radius: var(--r-xl); background: #050505; box-shadow: var(--elevation-hairline); } .asset-archive-preview-media { display: block; width: 100%; max-height: 400px; object-fit: contain; background: #050505; } +.asset-zoom-viewer { position: relative; display: flex; min-height: 420px; align-items: center; justify-content: center; overflow: hidden; background: #050505; touch-action: none; } +.asset-zoom-image { max-height: 70vh; transform-origin: center; transition: transform 120ms ease-out; user-select: none; } +.asset-zoom-controls { position: absolute; right: 12px; bottom: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 2px; padding: 4px; border: 1px solid rgba(255,255,255,.16); border-radius: var(--r-md); background: rgba(15,15,15,.76); backdrop-filter: blur(8px); } +.asset-zoom-controls button { display: grid; min-width: 30px; height: 30px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: rgba(255,255,255,.86); cursor: pointer; font-size: 11px; } +.asset-zoom-controls button:hover { background: rgba(255,255,255,.12); color: #fff; } +.batch-upload-dropzone { display: flex; min-height: 132px; flex-direction: column; align-items: center; justify-content: center; gap: 7px; width: 100%; border: 1px dashed color-mix(in srgb, var(--foreground) 18%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 3%, transparent); color: color-mix(in srgb, var(--foreground) 52%, transparent); cursor: pointer; } +.batch-upload-dropzone:hover { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 6%, transparent); color: var(--foreground); } +.batch-upload-dropzone strong { color: var(--foreground); font-size: var(--fs-body); } +.batch-upload-dropzone span { font-size: var(--fs-label); } +.batch-upload-list { max-height: 260px; overflow: auto; border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); border-radius: var(--r-lg); } +.batch-upload-item { display: flex; min-width: 0; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); font-size: var(--fs-label); } +.batch-upload-item:last-child { border-bottom: 0; } +.batch-upload-remove { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 5px; background: transparent; color: color-mix(in srgb, var(--foreground) 38%, transparent); cursor: pointer; } +.batch-upload-remove:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); } .asset-archive-preview-note { max-height: 400px; overflow: auto; padding: 26px 24px; color: color-mix(in srgb, var(--foreground) 72%, transparent); font-size: var(--fs-body); line-height: 1.75; white-space: pre-wrap; } .asset-archive-preview-model { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 40px 24px; background: color-mix(in srgb, var(--foreground) 3%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); } .asset-archive-preview-model > svg { width: 44px; height: 44px; color: color-mix(in srgb, var(--workspace-accent) 55%, var(--foreground)); stroke-width: 1; } @@ -16011,11 +16028,11 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou } /* 3. 素材网格以 6 列为桌面基准;首格是新增素材,因此每页 35 项时正好组成 6×6 完整网格。 */ -.assets-library-page .assets-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 18px 14px; } +.assets-library-page .assets-library-grid { grid-template-columns: repeat(var(--assets-grid-columns, 8), minmax(0, 1fr)); gap: 18px 14px; } .assets-library-page .library-create-title { margin-top: 12px; font-size: var(--fs-body); } .assets-library-page .library-create-meta { margin-top: 4px; font-size: var(--fs-label); } -@media (max-width: 1200px) and (min-width: 900px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } -@media (max-width: 899px) and (min-width: 640px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 1200px) and (min-width: 900px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } } +@media (max-width: 899px) and (min-width: 640px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } /* 4. 封面浮层统一:窄屏隐藏分类徽章;浅底封面(文本/音频/模型)改用自适应玻璃浮层 */ @media (max-width: 1100px) { .assets-cover-badge.is-category { display: none; } }