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
+
+
+
+
+
+
+
+
{ chooseFiles(Array.from(event.target.files || [])); event.currentTarget.value = ""; }} />
+ {hasFiles ?
+
已选择 {items.length} 张 · 成功 {doneCount} 张
+
{items.map((item) =>
{item.file.name}{item.status === "uploading" ?
: item.status === "done" ?
完成 : item.status === "error" ?
失败 :
待上传}
)}
+
: null}
+
} onClick={() => void uploadBatch()} disabled={!items.some((item) => item.status === "queued" || item.status === "error")} loading={uploading}>{failedItems.length ? `重试失败项 (${failedItems.length})` : "开始上传"}
+
+ ;
+}
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() {
} onClick={openCreate}>
新增素材
+ } onClick={() => setBatchUploadOpen(true)}>
+ 批量上传图片
+
} onClick={() => navigate("/plugins/eagle")}>
Eagle 素材库
@@ -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);
}}
- />
-
+ />
+