From dc1ad68015ec4772bded0b078b912de0f04a651d Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 21 Sep 2026 20:14:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E7=B4=A0=E6=9D=90=E5=BA=93=E4=B8=8E?= =?UTF-8?q?=E7=94=BB=E5=B8=83=20-=20=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=88=B7=E6=96=B0=E3=80=81=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=8F=8A=E5=B7=A5=E5=85=B7=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/canvas_bridge.go | 16 +- .../app/canvas_history_resource_test.go | 44 +++++ .../app/resource_batch_delete_test.go | 86 +++++++++ backend/internal/app/resource_delete.go | 78 +++++++-- backend/internal/app/resource_delete_test.go | 66 ++++--- backend/internal/canvas/host.go | 6 +- backend/internal/canvas/user_data.go | 12 +- backend/internal/handler/asset_delete_test.go | 126 +++++++++++++ backend/internal/handler/openapi.yaml | 59 +++++++ backend/internal/handler/user_data.go | 20 ++- .../internal/handler/user_data_page_test.go | 7 +- backend/internal/repository/repository.go | 2 +- .../internal/repository/resource_reference.go | 80 ++++++++- docs/content/docs/backend/http-api.mdx | 16 ++ docs/content/docs/progress/pending-test.mdx | 11 ++ .../assets/asset-library-picker-modal.tsx | 10 +- .../canvas/canvas-node-prompt-panel.tsx | 17 +- .../lib/canvas/canvas-resource-references.ts | 11 +- web/src/pages/assets/index.tsx | 12 +- .../pages/canvas/use-canvas-render-model.ts | 7 +- web/src/services/api/user-data.ts | 4 + web/src/services/user-data-sync.ts | 27 ++- web/src/stores/use-asset-store.ts | 16 +- web/src/styles/globals.css | 29 ++- web/test/asset-batch-delete.test.ts | 165 ++++++++++++++++++ web/test/assets-page-batch-toolbar.test.ts | 21 +++ web/test/canvas-tool-integration.test.ts | 13 +- 27 files changed, 852 insertions(+), 109 deletions(-) create mode 100644 backend/internal/app/resource_batch_delete_test.go create mode 100644 backend/internal/handler/asset_delete_test.go create mode 100644 web/test/asset-batch-delete.test.ts diff --git a/backend/internal/app/canvas_bridge.go b/backend/internal/app/canvas_bridge.go index f993af199..d81d97fed 100644 --- a/backend/internal/app/canvas_bridge.go +++ b/backend/internal/app/canvas_bridge.go @@ -98,11 +98,15 @@ func (h canvasHost) StructuredReplacementQuota(userID, kind string, count int, b return validateStructuredReplacementQuotaWithPolicy(usage, kind, count, bytes, policy.Resource) } -func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string) error { +func (h canvasHost) DeleteUserAssetWithResources(userID, assetID string, purge bool) error { if h.svc == nil { return nil } - return h.svc.deleteUserAssetWithResources(userID, assetID) + return h.svc.deleteUserAssetWithResources(userID, assetID, purge) +} + +func (h canvasHost) PurgeUserAssetsWithResources(userID string, assetIDs []string) error { + return h.svc.deleteUserAssetsWithResources(userID, assetIDs, true) } func (h canvasHost) RecordActivity(userID, event string, count int) { @@ -182,6 +186,14 @@ func (s *Service) DeleteUserAsset(userID string, id string) error { return s.canvasDomain().DeleteUserAsset(userID, id) } +func (s *Service) PurgeUserAsset(userID string, id string) error { + return s.canvasDomain().PurgeUserAsset(userID, id) +} + +func (s *Service) PurgeUserAssets(userID string, ids []string) error { + return s.canvasDomain().PurgeUserAssets(userID, ids) +} + func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { return s.canvasDomain().UserAssets(userID) } diff --git a/backend/internal/app/canvas_history_resource_test.go b/backend/internal/app/canvas_history_resource_test.go index e050c508f..3fceb8555 100644 --- a/backend/internal/app/canvas_history_resource_test.go +++ b/backend/internal/app/canvas_history_resource_test.go @@ -66,3 +66,47 @@ func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) { t.Fatalf("unreferenced media not cleaned: %v", err) } } + +func TestArchivedAssetDeletionRemovesCanvasHistoryReference(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + resource := model.Resource{ + ID: "archived-history-resource", UserID: "user-1", Provider: "unsupported-test-provider", + ObjectKey: "users/user-1/image/archived-history.png", Status: model.ResourceStatusReady, + } + asset := model.Asset{ + ID: "archived-history-asset", UserID: "user-1", Title: "回收站历史素材", + Status: model.AssetVersionStatusArchived, PayloadJSON: `{"data":{"storageKey":"resource:archived-history-resource"}}`, + } + snapshot := model.CanvasSnapshot{ + ID: "archived-history-snapshot", CanvasID: "canvas", UserID: "user-1", Revision: 1, + Title: "历史画布", PayloadJSON: `{}`, CreatedAt: time.Now(), + } + ref := model.CanvasSnapshotResource{SnapshotID: snapshot.ID, ResourceID: resource.ID} + for _, item := range []any{&resource, &asset, &snapshot, &ref} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.DeleteUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("archived asset deletion failed: %v", err) + } + for _, check := range []struct { + model any + want int64 + }{ + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.CanvasSnapshotResource{}, 0}, + {&model.CanvasSnapshot{}, 1}, + {&model.ResourceDeletionJob{}, 1}, + } { + var count int64 + if err := db.Model(check.model).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != check.want { + t.Fatalf("%T count=%d, want %d", check.model, count, check.want) + } + } +} diff --git a/backend/internal/app/resource_batch_delete_test.go b/backend/internal/app/resource_batch_delete_test.go new file mode 100644 index 000000000..594a75e59 --- /dev/null +++ b/backend/internal/app/resource_batch_delete_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestPurgeAssetsBatchSharedResourcesAndHistory(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + for _, id := range []string{"batch-shared", "keep-shared", "same-object", "foreign-alias"} { + owner, objectKey := "user-1", id+".png" + if id == "foreign-alias" { + owner, objectKey = "user-2", "same-object.png" + } + if err := db.Create(&model.Resource{ID: id, UserID: owner, Provider: "unsupported-test-provider", ObjectKey: objectKey, Status: model.ResourceStatusReady}).Error; err != nil { + t.Fatal(err) + } + } + for _, record := range []any{ + &model.Asset{ID: "first", UserID: "user-1", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{"resourceIds":["batch-shared","keep-shared","same-object"]}`}, + &model.Asset{ID: "second", UserID: "user-1", Status: model.AssetVersionStatusArchived, PayloadJSON: `{}`}, + &model.AssetVersion{ID: "second-version", AssetID: "second", DefinitionJSON: `{"resourceId":"batch-shared"}`}, + &model.AssetRepresentation{ID: "second-representation", AssetVersionID: "second-version", Role: "image", ResourceID: "batch-shared", MetadataJSON: `{}`}, + &model.Asset{ID: "unselected", UserID: "user-1", PayloadJSON: `{}`}, + &model.AssetVersion{ID: "unselected-version", AssetID: "unselected", DefinitionJSON: `{}`}, + &model.AssetRepresentation{ID: "unselected-representation", AssetVersionID: "unselected-version", Role: "video", MetadataJSON: `{"resourceId":"keep-shared"}`}, + &model.Task{ID: "batch-task", UserID: "user-1", Status: model.TaskStatusRunning, InputJSON: `{"resourceId":"batch-shared"}`}, + &model.CanvasProject{ID: "batch-canvas", UserID: "user-1", PayloadJSON: `{"resourceId":"batch-shared"}`}, + &model.CanvasSnapshot{ID: "batch-snapshot", CanvasID: "batch-canvas", UserID: "user-1", Revision: 1, PayloadJSON: `{}`, CreatedAt: time.Now()}, + &model.CanvasSnapshotResource{SnapshotID: "batch-snapshot", ResourceID: "batch-shared"}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + // Force an error late in the resource transaction, after asset and outbox writes. + if err := db.Exec("CREATE TRIGGER fail_batch_resource_delete BEFORE DELETE ON resources BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil { + t.Fatal(err) + } + if err := svc.PurgeUserAssets("user-1", []string{"first", "second"}); err == nil { + t.Fatal("expected rollback") + } + assertCount := func(record any, want int64) { + t.Helper() + var count int64 + if err := db.Model(record).Count(&count).Error; err != nil || count != want { + t.Fatalf("%T count=%d want=%d err=%v", record, count, want, err) + } + } + assertCount(&model.Asset{}, 3) + assertCount(&model.Resource{}, 4) + assertCount(&model.ResourceDeletionJob{}, 0) + assertCount(&model.CanvasSnapshotResource{}, 1) + assertCount(&model.AssetVersion{}, 2) + assertCount(&model.AssetRepresentation{}, 2) + if err := db.Exec("DROP TRIGGER fail_batch_resource_delete").Error; err != nil { + t.Fatal(err) + } + if err := svc.PurgeUserAssets("user-1", []string{"first", " second ", "first"}); err != nil { + t.Fatal(err) + } + assertCount(&model.Asset{}, 1) + assertCount(&model.Resource{}, 2) + assertCount(&model.ResourceDeletionJob{}, 1) + assertCount(&model.CanvasSnapshotResource{}, 0) + assertCount(&model.CanvasSnapshot{}, 1) + assertCount(&model.Task{}, 1) + assertCount(&model.CanvasProject{}, 1) + assertCount(&model.AssetVersion{}, 1) + assertCount(&model.AssetRepresentation{}, 1) + var job model.ResourceDeletionJob + if err := db.First(&job).Error; err != nil { + t.Fatal(err) + } + if job.ResourceID != "batch-shared" { + t.Fatalf("wrong physical object queued: %s", job.ResourceID) + } + for _, id := range []string{"keep-shared", "foreign-alias"} { + var resource model.Resource + if err := db.First(&resource, "id = ?", id).Error; err != nil { + t.Fatalf("shared resource %s lost: %v", id, err) + } + } +} diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index 55d4d3498..44a8cbeab 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -18,25 +18,54 @@ import ( qiniuAuth "github.com/qiniu/go-sdk/v7/auth" qiniuStorage "github.com/qiniu/go-sdk/v7/storage" + "gorm.io/gorm" ) -func (s *Service) deleteUserAssetWithResources(userID string, assetID string) error { - asset, err := s.repo.AssetForUser(userID, assetID) +func (s *Service) deleteUserAssetWithResources(userID string, assetID string, purge bool) error { + return s.deleteUserAssetsWithResources(userID, []string{assetID}, purge) +} + +func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, purge bool) error { + if strings.TrimSpace(userID) == "" || len(ids) == 0 || len(ids) > 1000 { + return BadAuthRequest("每次删除须提供 1–1000 个素材 ID 和有效用户") + } + assetIDs := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return BadAuthRequest("素材 ID 不能为空") + } + if _, exists := seen[id]; !exists { + seen[id] = struct{}{} + assetIDs = append(assetIDs, id) + } + } + assetRecords, err := s.repo.AssetsForUserIDs(userID, assetIDs) if err != nil { return err } - assetReferences, err := s.repo.AssetBusinessReferences(userID, assetID) - if err != nil { - return err + if len(assetRecords) != len(assetIDs) { + return NotFound("素材不存在或无权删除") } - versions, representations, err := s.repo.AssetResourceRecords(assetID) + deleteReferencedResources := purge + if !purge { + // 内部普通删除仍为单素材保护路径;用户显式删除统一走 purge。 + if len(assetRecords) != 1 { + return BadAuthRequest("普通删除仅支持单个素材") + } + deleteReferencedResources = assetRecords[0].Status == model.AssetVersionStatusArchived + } + versions, representations, err := s.repo.AssetsResourceRecords(assetIDs) if err != nil { return err } resourceIDs := map[string]struct{}{} - if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil { - return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + for _, asset := range assetRecords { + if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil { + return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + } } for _, version := range versions { if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs); err != nil { @@ -64,12 +93,24 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er ownedIDSet[resource.ID] = struct{}{} } - usages := make([]resourceUsage, 0, len(assetReferences)) - for _, reference := range assetReferences { - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + usages := make([]resourceUsage, 0) + if !deleteReferencedResources { + assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetIDs[0]) + if referenceErr != nil { + return referenceErr + } + for _, reference := range assetReferences { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } if len(ownedIDs) > 0 { - snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, assetID, ownedIDs) + var snapshot repository.ResourceReferenceSnapshot + var snapshotErr error + if deleteReferencedResources { + snapshot, snapshotErr = s.repo.OtherAssetResourceReferences(userID, assetIDs) + } else { + snapshot, snapshotErr = s.repo.ResourceReferenceSnapshot(userID, assetIDs[0], ownedIDs) + } if snapshotErr != nil { return snapshotErr } @@ -80,7 +121,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er sharedAssetResourceIDs[reference.ResourceID] = struct{}{} continue } - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } } for _, document := range snapshot.Documents { @@ -106,7 +149,9 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er } continue } - usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } } } if len(sharedAssetResourceIDs) > 0 { @@ -139,7 +184,10 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er deletionJobs := resourceDeletionJobs(userID, physicalObjects) // 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动; // 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。 - if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs, deletionJobs); err != nil { + if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, deleteReferencedResources); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return NotFound("素材不存在或无权删除") + } if errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) { return BadAuthRequest("素材仍被画布历史版本引用,已保留文件") } diff --git a/backend/internal/app/resource_delete_test.go b/backend/internal/app/resource_delete_test.go index f2ab072f9..77af112db 100644 --- a/backend/internal/app/resource_delete_test.go +++ b/backend/internal/app/resource_delete_test.go @@ -142,7 +142,7 @@ func TestDeleteAssetDatabaseFailureLeavesPhysicalObjectAndNoOutbox(t *testing.T) t.Fatal(err) } - if err := svc.deleteUserAssetWithResources("user-1", "asset-1"); err == nil { + if err := svc.deleteUserAssetWithResources("user-1", "asset-1", false); err == nil { t.Fatal("forced database failure should be returned") } if _, err := os.Stat(resourcePath); err != nil { @@ -298,6 +298,49 @@ func TestDeleteGeneratedAssetTaskReferences(t *testing.T) { } } +func TestPurgeConfirmedAssetIgnoresTaskReferences(t *testing.T) { + svc, db, _ := newResourceDeletionTestService(t) + payload := `{"url":"/api/resources/purge-confirmed-resource/file"}` + resource := model.Resource{ + ID: "purge-confirmed-resource", UserID: "user-1", Provider: "unsupported-test-provider", + ObjectKey: "purge-confirmed.png", Status: model.ResourceStatusReady, + } + asset := model.Asset{ + ID: "purge-confirmed-asset", UserID: "user-1", Title: "本地已在回收站的素材", + Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload, + } + task := model.Task{ + ID: "purge-confirmed-task", UserID: "user-1", Prompt: "将图片拆分为可独立编辑的图层", + Status: model.TaskStatusRunning, InputJSON: payload, ResultJSON: payload, + } + for _, item := range []any{&resource, &asset, &task} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + if err := svc.PurgeUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("PurgeUserAsset() error = %v", err) + } + for _, check := range []struct { + model any + want int64 + }{ + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.ResourceDeletionJob{}, 1}, + {&model.Task{}, 1}, + } { + var count int64 + if err := db.Model(check.model).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != check.want { + t.Fatalf("%T count=%d, want %d", check.model, count, check.want) + } + } +} + func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { svc, db, dataDir := newResourceDeletionTestService(t) objectKey := "users/user-1/image/queued.png" @@ -330,7 +373,7 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { } } -func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbox(t *testing.T) { +func TestExpiredArchivedAssetCleanupDeletesReferencedAssetAndUsesDeletionOutbox(t *testing.T) { svc, db, _ := newResourceDeletionTestService(t) old := time.Now().Add(-45 * 24 * time.Hour) resource := model.Resource{ @@ -363,25 +406,8 @@ func TestExpiredArchivedAssetCleanupRespectsCanvasReferencesAndUsesDeletionOutbo if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { t.Fatal(err) } - if assetCount != 1 || resourceCount != 1 || jobCount != 0 { - t.Fatalf("referenced archived asset cleanup changed data: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) - } - - if err := db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", canvas.ID, canvas.UserID).Error; err != nil { - t.Fatal(err) - } - svc.cleanupExpiredArchivedAssets() - if err := db.Model(&model.Asset{}).Where("id = ?", asset.ID).Count(&assetCount).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { - t.Fatal(err) - } if assetCount != 0 || resourceCount != 0 || jobCount != 1 { - t.Fatalf("unreferenced archived asset did not use deletion outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) + t.Fatalf("referenced archived asset was not force deleted through outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) } } diff --git a/backend/internal/canvas/host.go b/backend/internal/canvas/host.go index 1cc92c062..b35d1d71e 100644 --- a/backend/internal/canvas/host.go +++ b/backend/internal/canvas/host.go @@ -15,7 +15,8 @@ type Host interface { WithStorageLock(fn func() error) error StructuredQuota(userID, kind string, creating bool, deltaBytes int64) error StructuredReplacementQuota(userID, kind string, count int, bytes int64) error - DeleteUserAssetWithResources(userID, assetID string) error + DeleteUserAssetWithResources(userID, assetID string, purge bool) error + PurgeUserAssetsWithResources(userID string, assetIDs []string) error RecordActivity(userID, event string, count int) } @@ -37,7 +38,8 @@ func (nopHost) WithStorageLock(fn func() error) error { } func (nopHost) StructuredQuota(string, string, bool, int64) error { return nil } func (nopHost) StructuredReplacementQuota(string, string, int, int64) error { return nil } -func (nopHost) DeleteUserAssetWithResources(string, string) error { return nil } +func (nopHost) DeleteUserAssetWithResources(string, string, bool) error { return nil } +func (nopHost) PurgeUserAssetsWithResources(string, []string) error { return nil } func (nopHost) RecordActivity(string, string, int) {} type Service struct { diff --git a/backend/internal/canvas/user_data.go b/backend/internal/canvas/user_data.go index 151876d6e..ad2687ac5 100644 --- a/backend/internal/canvas/user_data.go +++ b/backend/internal/canvas/user_data.go @@ -128,7 +128,17 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS func (s *Service) DeleteUserAsset(userID string, id string) error { return s.host.WithStorageLock(func() error { - return s.host.DeleteUserAssetWithResources(userID, id) + return s.host.DeleteUserAssetWithResources(userID, id, false) + }) +} + +func (s *Service) PurgeUserAsset(userID string, id string) error { + return s.PurgeUserAssets(userID, []string{id}) +} + +func (s *Service) PurgeUserAssets(userID string, ids []string) error { + return s.host.WithStorageLock(func() error { + return s.host.PurgeUserAssetsWithResources(userID, ids) }) } diff --git a/backend/internal/handler/asset_delete_test.go b/backend/internal/handler/asset_delete_test.go new file mode 100644 index 000000000..9225100ff --- /dev/null +++ b/backend/internal/handler/asset_delete_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "infinite-canvas/backend/internal/auth" + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAssetBatchDeleteHTTP(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + for _, record := range []any{ + &model.User{ID: "batch-user", Username: "batch-user", Email: "batch@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive}, + &model.AuthSession{ID: "batch-session", UserID: "batch-user", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}, + &model.Asset{ID: "foreign", UserID: "other-user", PayloadJSON: `{}`}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + ids := make([]string, 100) + for i := range ids { + ids[i] = fmt.Sprintf("batch-%03d", i) + if err := db.Create(&model.Asset{ID: ids[i], UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: `{}`}).Error; err != nil { + t.Fatal(err) + } + } + router := gin.New() + RegisterUserDataRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir())) + call := func(method, path, body string, authenticated bool) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + r.Header.Set("Content-Type", "application/json") + if authenticated { + r.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: "batch-session.test-token"}) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w + } + assertAssets := func(want int64) { + t.Helper() + var count int64 + if err := db.Model(&model.Asset{}).Count(&count).Error; err != nil || count != want { + t.Fatalf("asset count=%d want=%d err=%v", count, want, err) + } + } + path := "/api/assets/batch-delete" + if w := call(http.MethodPost, path, `["batch-000"]`, false); w.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated: %d %s", w.Code, w.Body.String()) + } + for _, body := range []string{`[]`, `null`, `{ "ids": ["batch-000"] }`, `["batch-000", ""]`, `[1]`} { + if w := call(http.MethodPost, path, body, true); w.Code != http.StatusBadRequest { + t.Fatalf("invalid body %s: %d %s", body, w.Code, w.Body.String()) + } + assertAssets(101) + } + for _, badID := range []string{"foreign", "missing"} { + w := call(http.MethodPost, path, `["batch-000","`+badID+`"]`, true) + if w.Code != http.StatusNotFound { + t.Fatalf("ownership/missing %s: %d %s", badID, w.Code, w.Body.String()) + } + assertAssets(101) + } + body, _ := json.Marshal(ids) + if err := db.Exec("CREATE TRIGGER fail_batch_delete BEFORE DELETE ON assets WHEN OLD.id = 'batch-099' BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil { + t.Fatal(err) + } + if w := call(http.MethodPost, path, string(body), true); w.Code != http.StatusInternalServerError { + t.Fatalf("database failure: %d %s", w.Code, w.Body.String()) + } + assertAssets(101) + if err := db.Exec("DROP TRIGGER fail_batch_delete").Error; err != nil { + t.Fatal(err) + } + w := call(http.MethodPost, path, string(body), true) + var response struct { + Code int `json:"code"` + Data struct { + IDs []string `json:"ids"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != http.StatusOK || response.Code != 0 || len(response.Data.IDs) != 100 { + t.Fatalf("batch response: %d %s err=%v", w.Code, w.Body.String(), err) + } + assertAssets(1) + + // The existing DELETE endpoint uses the same purge policy for non-archived assets. + resource := model.Resource{ID: "single-resource", UserID: "batch-user", Provider: "unsupported-test-provider", ObjectKey: "single.png"} + payload := `{"url":"/api/resources/single-resource/file"}` + for _, record := range []any{ + &resource, + &model.Asset{ID: "single", UserID: "batch-user", Status: model.AssetVersionStatusConfirmed, PayloadJSON: payload}, + &model.Task{ID: "running-task", UserID: "batch-user", Status: model.TaskStatusRunning, InputJSON: payload}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusOK { + t.Fatalf("single referenced delete: %d %s", w.Code, w.Body.String()) + } + assertAssets(1) +} diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 211e6c62a..2d6f194a6 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -1090,6 +1090,65 @@ paths: responses: "200": $ref: "#/components/responses/Envelope" + /assets/batch-delete: + post: + tags: [assets] + summary: 批量彻底删除当前用户素材 + description: 顶层 JSON 数组,一次请求、一个数据库事务。所有 ID 必须属于当前用户,不受任务、画布或历史引用阻塞;共享文件保留,其余通过 Outbox 异步清理,失败重试。任一素材不存在、越权或数据库写入失败时整批不删除。 + requestBody: + required: true + content: + application/json: + schema: + type: array + minItems: 1 + maxItems: 1000 + items: + type: string + minLength: 1 + example: ["asset-1", "asset-2"] + responses: + "200": + description: 成功信封,data.ids 返回本次请求的素材 ID 数组;物理文件清理由后台执行。 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [ids] + properties: + ids: + type: array + items: { type: string } + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + "500": + $ref: "#/components/responses/Error" + /assets/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string } + delete: + tags: [assets] + summary: 彻底删除当前用户素材 + description: 不依赖素材 archived 状态,也不受画布、任务、项目或历史快照引用阻塞;其他素材共享的物理对象仍保留。 + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" components: parameters: AgentRunID: diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index d5800435a..e66d6e123 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -18,6 +18,24 @@ import ( ) func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/assets/batch-delete", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var ids []string + if err := c.ShouldBindJSON(&ids); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.PurgeUserAssets(user.ID, ids); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ids": ids}) + }) r.POST("/assets/batch", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -586,7 +604,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - if err := svc.DeleteUserAsset(user.ID, c.Param("id")); err != nil { + if err := svc.PurgeUserAsset(user.ID, c.Param("id")); err != nil { failService(c, err) return } diff --git a/backend/internal/handler/user_data_page_test.go b/backend/internal/handler/user_data_page_test.go index 88cdf4837..c04a6bc1c 100644 --- a/backend/internal/handler/user_data_page_test.go +++ b/backend/internal/handler/user_data_page_test.go @@ -13,9 +13,10 @@ func TestUserDataIncrementalRoutesAreRegistered(t *testing.T) { router := gin.New() RegisterUserDataRoutes(router.Group("/api"), &service.Service{}) wanted := map[string]bool{ - "GET /api/canvas-projects": false, - "GET /api/canvas-projects/:id": false, - "POST /api/assets/batch": false, + "GET /api/canvas-projects": false, + "GET /api/canvas-projects/:id": false, + "POST /api/assets/batch": false, + "POST /api/assets/batch-delete": false, } for _, route := range router.Routes() { key := route.Method + " " + route.Path diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index dd68511e6..6903a733d 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1156,7 +1156,7 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error { } func (r *Repository) DeleteAsset(userID string, id string) error { - return r.DeleteAssetAndResources(userID, id, nil, nil) + return r.DeleteAssetAndResources(userID, id, nil, nil, false) } func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) { diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 9700f5b5b..d623f1527 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -10,6 +10,7 @@ import ( "infinite-canvas/backend/internal/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ResourceReferenceDocument 是资源删除校验使用的只读业务文档快照。 @@ -36,8 +37,12 @@ type ResourceReferenceSnapshot struct { } func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, []model.AssetRepresentation, error) { + return r.AssetsResourceRecords([]string{assetID}) +} + +func (r *Repository) AssetsResourceRecords(assetIDs []string) ([]model.AssetVersion, []model.AssetRepresentation, error) { var versions []model.AssetVersion - if err := r.db.Where("asset_id = ?", assetID).Find(&versions).Error; err != nil { + if err := r.db.Where("asset_id IN ?", assetIDs).Find(&versions).Error; err != nil { return nil, nil, err } if len(versions) == 0 { @@ -54,6 +59,35 @@ func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, return versions, representations, nil } +// OtherAssetResourceReferences 只查询整批删除范围之外的素材,不扫描任务和画布。 +func (r *Repository) OtherAssetResourceReferences(userID string, excludedAssetIDs []string) (ResourceReferenceSnapshot, error) { + snapshot := ResourceReferenceSnapshot{} + var assets []model.Asset + if err := r.db.Where("user_id = ? AND id NOT IN ?", userID, excludedAssetIDs).Find(&assets).Error; err != nil { + return snapshot, err + } + ids := make([]string, 0, len(assets)) + for _, asset := range assets { + ids = append(ids, asset.ID) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: asset.ID, PrimaryJSON: asset.PayloadJSON}) + } + if len(ids) == 0 { + return snapshot, nil + } + versions, representations, err := r.AssetsResourceRecords(ids) + if err != nil { + return snapshot, err + } + for _, version := range versions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: version.ID, PrimaryJSON: version.DefinitionJSON}) + } + for _, representation := range representations { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, ResourceID: representation.ResourceID}) + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: representation.ID, PrimaryJSON: representation.MetadataJSON}) + } + return snapshot, nil +} + func (r *Repository) ResourcesForUserIDs(userID string, resourceIDs []string) ([]model.Resource, error) { if len(resourceIDs) == 0 { return []model.Resource{}, nil @@ -352,12 +386,42 @@ func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]R return result, nil } -func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob) error { +func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error { + return r.DeleteAssetsAndResources(userID, []string{assetID}, resourceIDs, deletionJobs, deleteReferencedResources) +} + +func (r *Repository) DeleteAssetsAndResources(userID string, assetIDs []string, resourceIDs []string, deletionJobs []model.ResourceDeletionJob, deleteReferencedResources bool) error { return r.db.Transaction(func(tx *gorm.DB) error { - if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + var ownedAssets []model.Asset + query := tx.Where("user_id = ? AND id IN ?", userID, assetIDs).Order("id") + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.Find(&ownedAssets).Error; err != nil { return err } - versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID) + if len(assetIDs) == 0 || len(ownedAssets) != len(assetIDs) { + return gorm.ErrRecordNotFound + } + if deleteReferencedResources { + if len(resourceIDs) > 0 { + // 与历史快照写入串行化,避免清除索引后又插入外键引用。 + if r.Dialect() == "postgres" { + var resources []model.Resource + if err := tx.Select("id").Where("id IN ?", resourceIDs).Order("id").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&resources).Error; err != nil { + return err + } + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.CanvasSnapshotResource{}).Error; err != nil { + return err + } + } + } else { + if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + return err + } + } + versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id IN ?", assetIDs) if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { return err } @@ -367,16 +431,16 @@ func (r *Repository) DeleteAssetAndResources(userID string, assetID string, reso if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { return err } - if err := tx.Where("asset_id = ?", assetID).Delete(&model.ProjectAssetLink{}).Error; err != nil { + if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.ProjectAssetLink{}).Error; err != nil { return err } - if err := tx.Where("resolved_asset_id = ?", assetID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + if err := tx.Where("resolved_asset_id IN ?", assetIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { return err } - if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil { + if err := tx.Where("asset_id IN ?", assetIDs).Delete(&model.AssetVersion{}).Error; err != nil { return err } - if err := tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", assetID, userID).Error; err != nil { + if err := tx.Delete(&model.Asset{}, "id IN ? AND user_id = ?", assetIDs, userID).Error; err != nil { return err } if len(deletionJobs) > 0 { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 3a74a8ce9..f900b8416 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -11,6 +11,22 @@ description: 信封、错误码与 OpenAPI 出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。 +## 素材彻底删除 + +`DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。 + +素材页及素材选择器的批量彻底删除、清空回收站统一调用 `POST /assets/batch-delete`,请求体直接为 JSON 数组(不是 `{ ids: [...] }`): + +```json +["asset-1", "asset-2"] +``` + +- 每次 1–1000 项,单个请求体最多 256 KiB;ID 去除首尾空白并去重,空 ID / 非数组返回 400,任一 ID 不存在或不属于当前用户返回 404,整批不删除。 +- 成功信封 `data.ids` 为请求传入的数组。前端等待服务端成功后一次更新本地集合并冲刷持久化;失败不回退为逐条 DELETE,不先移除本地记录。 +- 整批素材、版本、表现、业务关联、待删资源及对应历史资源索引,与物理删除 Outbox 在同一数据库事务中提交,数据库失败整批回滚。共享检查排除整批被删素材,避免两条互相共享的选中素材残留文件。 +- 物理空间由后台删除队列异步释放,失败保留任务重试;接口成功不代表对象存储已经清理完成。未选中素材使用的资源,以及其他 Resource 行(包括其他用户)复用的物理对象继续保留。 +- 旧画布 / 任务 / 历史快照记录本身保留,但被删媒体链接将失效。此操作不可恢复;没有数据库结构迁移。 + ## 站点皮肤主按钮 ### 画布配置与 Live2D(外观合同版本 9) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4c5c98a6..364104f1e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 回收站彻底删除与画布工具引用 + +- [ ] 素材卡片选择框恢复左上角,徽章为选择框和更多菜单让位;批量操作条取消吸顶覆盖,并与卡片网格保留间距。需在真实登录页面检查多选边框、长列表滚动、窄屏换行和明暗主题;自动浏览器访问被登录页拦截,样式约束测试不代替视觉验收。 +- [x] 新增 `POST /api/assets/batch-delete`,顶层 JSON 数组传入素材 ID;素材页及选择器的批量删除、清空回收站只发一次请求,后端批量事务处理,不按 ID 逐次提交。整批归属检查、事务失败回滚、同批共享资源回收与未选共享资源保留有专项覆盖。 +- [x] `DELETE /api/assets/:id` 明确表示彻底删除,不再依赖服务端素材状态判断是否位于回收站;即使本地已归档但服务端仍暂存为 `confirmed`,也不会受画布、任务或历史快照引用阻塞。事务会移除对应历史资源索引并通过删除 Outbox 释放未被其他素材复用的物理文件;内部普通删除仍保留引用保护。 +- [x] 画布提示词中的工具引用按“工具类型 + 工具 ID”区分,并使用最新提示词原子追加;相同数字 ID 的不同工具不会互相覆盖或把前一个标签回退成编码令牌。后端专项测试、前端工具集成测试和前端类型检查通过。 +- [x] 删除完成后退出用户数据同步队列,再异步刷新素材库与素材选择器分页缓存,避免删除与列表查询互相等待而无法关闭确认弹窗。去掉选择器删除后的重复刷新;刷新失败保留查询错误并记录告警,不误报删除失败。8 项批量删除与工具栏专项测试通过,包含真实 QueryObserver、分页加载与同步队列链路,以及刷新延迟、刷新失败场景(仅模拟 HTTP 响应)。 +- [ ] 在真实素材页及素材选择器中分别确认单条删除、批量删除和清空回收站:删除成功后确认弹窗关闭,列表刷新;限速或模拟列表请求失败时确认弹窗不被卡住,素材选择器外层窗口保持打开。 +- [ ] 在真实素材页分别验证单个彻底删除、选择 100 条后批量删除、清空回收站及过期自动清理;网络面板应显示单个批量 POST,body 为数组。确认后台清理完成后空间释放、原画布或任务中的旧媒体引用按预期失效、其他素材复用的文件仍可访问;失败时不出现部分删除。 +- [ ] 在真实画布快速连续选择两个不同工具,并覆盖相同数字 ID、中文标签、同类型替换和多个运镜工具,确认两个标签均正常显示、保存刷新后不乱码。 + ## 画布 Agent 配置与 Live2D - [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。 diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index 84c68951d..b30b8d527 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -13,7 +13,7 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { cn } from "@/lib/utils"; import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types"; import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; -import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; export type AssetPickerMediaKind = "image" | "video" | "audio" | "text"; @@ -291,14 +291,13 @@ export function AssetLibraryPickerModal({ if (!archivedSelectedIds.length) return; setWorking(true); try { - for (const id of archivedSelectedIds) await deleteAssetWithRemoteSync(id); + await deleteAssetsWithRemoteSync(archivedSelectedIds); setSelected(new Set()); message.success(`已彻底删除 ${archivedSelectedIds.length} 个素材`); } catch (err) { message.error(err instanceof Error ? err.message : "删除失败"); } finally { setWorking(false); - if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -307,7 +306,7 @@ export function AssetLibraryPickerModal({ if (!toDelete.length) return; setWorking(true); try { - for (const item of toDelete) await deleteAssetWithRemoteSync(item.id); + await deleteAssetsWithRemoteSync(toDelete.map((item) => item.id)); setSelected(new Set()); message.success(`已删除${remoteEnabled ? "当前页" : "回收站"} ${toDelete.length} 个素材`); setCategory("all"); @@ -315,7 +314,6 @@ export function AssetLibraryPickerModal({ message.error(err instanceof Error ? err.message : "清空回收站失败"); } finally { setWorking(false); - if (remoteEnabled) void remoteQuery.refetch(); } }; @@ -532,7 +530,7 @@ export function AssetLibraryPickerModal({
{isRecycleBin ? ( <> - + diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 8e628c197..6498fa605 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -90,6 +90,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; const [prompt, setPrompt] = useState(savedPrompt); + const promptRef = useRef(savedPrompt); const [presetOpen, setPresetOpen] = useState(false); const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); const [nineGridOpen, setNineGridOpen] = useState(false); @@ -213,6 +214,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const canAutoMention = autoMentionedPrompt !== prompt; useEffect(() => { + promptRef.current = normalizedSavedPrompt; setPrompt(normalizedSavedPrompt); if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt); }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]); @@ -258,6 +260,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); const updatePrompt = (value: string) => { + promptRef.current = value; setPrompt(value); onPromptChange(node.id, value); if (showPromptTemplates && /(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { @@ -266,6 +269,10 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan } }; + const updatePromptFromCurrent = (updater: (currentPrompt: string) => string) => { + updatePrompt(updater(promptRef.current)); + }; + const applyPreset = (preset: CanvasPromptPreset) => { const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd(); updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt); @@ -285,7 +292,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan updatePrompt(trimmedBase ? `${trimmedBase} ${insertText}` : insertText); }; - const removeMotionToolMention = () => updatePrompt(removeToolMentions(prompt, "motion")); + const removeMotionToolMention = () => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt, "motion")); const submit = () => { const text = prompt.trim(); @@ -330,11 +337,11 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
{!simpleMode && (mode === "image" || mode === "video") ?
e.stopPropagation()}> {mode === "image" ? <> - updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} /> - updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} /> + updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"style"))} /> + updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"nine_grid"},icon))} /> : <> - updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} /> - t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} /> + updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePromptFromCurrent((currentPrompt) => removeToolMentions(currentPrompt,"effect"))} /> + t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePromptFromCurrent((currentPrompt) => applyToolMention(currentPrompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} /> }
: null} {showPromptTemplates ? : null} diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index ef0d02431..0d2a3e90c 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -52,11 +52,12 @@ const TOOL_REF_PATTERN = /@\[tool:(\w+):(\d+):([^:\]]+):([^\]]+)\]/g; /** 从文本中解析所有 @[tool:type:ID:label:icon] 令牌,返回去重的工具引用。 */ export function parseToolMentionTokens(text: string): { type: string; toolId: number; label: string; icon: string }[] { const results: { type: string; toolId: number; label: string; icon: string }[] = []; - const seen = new Set(); + const seen = new Set(); for (const match of text.matchAll(TOOL_REF_PATTERN)) { const toolId = Number(match[2]); - if (seen.has(toolId)) continue; - seen.add(toolId); + const identity = `${match[1]}:${toolId}`; + if (seen.has(identity)) continue; + seen.add(identity); let label = match[3]; try { label = decodeURIComponent(label); } catch { /* Keep readable text for malformed imported labels. */ } results.push({ type: match[1], toolId, label, icon: match[4] }); @@ -585,8 +586,8 @@ function skillResourceText(node: CanvasNodeData) { export function buildToolMentionReference(toolId: number, label: string, type: string, icon: string): CanvasResourceReference { return { - id: `tool:${toolId}`, - nodeId: `tool:${toolId}`, + id: `tool:${type}:${toolId}`, + nodeId: `tool:${type}:${toolId}`, kind: "tool", label, title: label, diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 926cea0b1..e3e71914c 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -26,7 +26,7 @@ import { uploadMediaFile } from "@/services/file-storage"; import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; import { exportAssets, readAssetPackage } from "./asset-transfer"; import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage"; -import { deleteAssetWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { deleteAssetWithRemoteSync, deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, 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"; @@ -487,9 +487,7 @@ export default function AssetsPage() { const count = trashAssets.length; if (!count) return; try { - for (const asset of trashAssets) { - await deleteAssetWithRemoteSync(asset.id); - } + await deleteAssetsWithRemoteSync(trashAssets.map((asset) => asset.id)); setSelectedIds([]); message.success(`已彻底清空回收站 ${count} 个素材`); } catch (error) { @@ -516,7 +514,7 @@ export default function AssetsPage() { const confirmBatchDelete = async () => { if (!selectedAssets.length) return; try { - for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id); + await deleteAssetsWithRemoteSync(selectedAssets.map((asset) => asset.id)); message.success(`已彻底删除 ${selectedAssets.length} 个素材`); setSelectedIds([]); setBatchDeleteOpen(false); @@ -982,7 +980,7 @@ export default function AssetsPage() { okButtonProps={{ danger: true }} cancelText="取消" > - 确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。 + 确定彻底删除「{deletingAsset?.title}」吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。 - 确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。 + 确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被其他素材复用的服务器文件会直接释放,原画布或任务中的旧引用可能失效,操作不可恢复。 ); diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index 0d494ca4f..26bc6d149 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -359,11 +359,12 @@ export function useCanvasRenderModel({ const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; const tokens = parseToolMentionTokens(text); if (!tokens.length) continue; - const seen = new Set(); + const seen = new Set(); const refs: ReturnType[] = []; for (const { type, toolId, label, icon } of tokens) { - if (seen.has(toolId)) continue; - seen.add(toolId); + const identity = `${type}:${toolId}`; + if (seen.has(identity)) continue; + seen.add(identity); refs.push(buildToolMentionReference(toolId, label, type, icon)); } if (refs.length) map.set(node.id, refs); diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index 7c4b155e9..c09e15a2f 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -109,6 +109,10 @@ export function deleteRemoteAsset(id: string) { return http.delete<{ id: string }>(`/assets/${encodeURIComponent(id)}`); } +export function deleteRemoteAssets(ids: string[]) { + return http.post<{ ids: string[] }>("/assets/batch-delete", ids); +} + export function listRemoteCanvasProjects() { return http.get<{ projects: RemoteUserDataSummary[] }>("/canvas-projects"); } diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index a148f81d8..44ac5b0f6 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -1,6 +1,6 @@ import { getMediaBlob } from "@/services/file-storage"; import { getImageBlob } from "@/services/image-storage"; -import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; +import { deleteRemoteAssets, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data"; import { ApiError } from "@/services/api/request"; import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content"; import { getActiveUserScope } from "@/lib/user-scope"; @@ -438,19 +438,34 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId } export async function deleteAssetWithRemoteSync(id: string) { + return deleteAssetsWithRemoteSync([id]); +} + +export async function deleteAssetsWithRemoteSync(ids: string[]) { const epoch = sessionEpoch; - const assetId = id.trim(); - if (!assetId) throw new Error("素材 ID 不能为空"); + if (!ids.length || ids.length > 1000) throw new Error("每次请选择 1–1000 个素材删除"); + const assetIds = [...new Set(ids.map((id) => id.trim()))]; + if (assetIds.some((id) => !id)) throw new Error("素材 ID 不能为空"); await withRemoteUserDataSyncExclusive(async () => { if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的素材"); if (activeRemoteUserId) { requireRemoteUserDataBaseline(); - await deleteRemoteAsset(assetId); - acknowledgedAssets.delete(assetId); + await deleteRemoteAssets(assetIds); + if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果"); + for (const id of assetIds) acknowledgedAssets.delete(id); } - await useAssetStore.getState().removeAsset(assetId); + await useAssetStore.getState().removeAssets(assetIds); await flushAssetStorePersistence(); }); + if (epoch !== sessionEpoch) throw new Error("账号已切换,请刷新原账号素材库确认删除结果"); + // 列表查询也会进入同步队列,必须在退出删除队列后触发,不能在队列内等待刷新。 + // 刷新慢或失败不应阻塞确认弹窗关闭,也不能把已完成的删除报告为失败。 + void Promise.all([ + appQueryClient.invalidateQueries({ queryKey: ["asset-library"] }, { throwOnError: true }), + appQueryClient.invalidateQueries({ queryKey: ["asset-picker"] }, { throwOnError: true }), + ]).catch((error) => { + console.warn("素材删除后列表刷新失败", error); + }); } export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index 9bcf5a85f..fe7cf417c 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -59,6 +59,7 @@ type AssetStore = { addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise; updateAsset: (id: string, patch: Partial>) => void; removeAsset: (id: string) => Promise; + removeAssets: (ids: string[]) => Promise; replaceAssets: (assets: Asset[]) => void; cleanupImages: (extra?: unknown) => Promise; }; @@ -393,18 +394,23 @@ export const useAssetStore = create()( set((state) => ({ assets: state.assets.map((asset) => (asset.id === id ? parseAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() }) : asset)), })), - removeAsset: async (id) => { + removeAsset: async (id) => get().removeAssets([id]), + removeAssets: async (ids) => { + const removedIds = new Set(ids); let remainingAssets: Asset[] = []; - let removedAsset: Asset | undefined; + let hasLocalMedia = false; set((state) => { - removedAsset = state.assets.find((asset) => asset.id === id); - const assets = state.assets.filter((asset) => asset.id !== id); + const assets = state.assets.filter((asset) => { + if (!removedIds.has(asset.id)) return true; + hasLocalMedia ||= !!collectImageStorageKeys(asset).size || !!collectMediaStorageKeys(asset).size; + return false; + }); remainingAssets = assets; return { assets }; }); // 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描, // 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。 - if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) return; + if (!hasLocalMedia) return; await get().cleanupImages({ assets: remainingAssets }); }, replaceAssets: (assets) => set({ assets: assets.map(parseAssetRecord) }), diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 62cf0f41a..b8b13c705 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -14049,9 +14049,10 @@ html:not(.dark) .creation-history-drawer-root { .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 { display: none; } -.assets-cover-badges { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; align-items: center; gap: 5px; pointer-events: none; } +.assets-cover-badges { position: absolute; top: 8px; left: 38px; right: 44px; z-index: 2; display: flex; align-items: center; gap: 5px; overflow: hidden; white-space: nowrap; pointer-events: none; } .assets-cover-badge { display: inline-flex; + flex-shrink: 0; height: 20px; align-items: center; gap: 4px; @@ -14088,11 +14089,11 @@ html:not(.dark) .creation-history-drawer-root { .assets-cover-play svg { position: relative; width: 17px; height: 17px; margin-left: 2px; filter: drop-shadow(0 1px 4px rgba(0, 0, 0, .45)); } .assets-cover-link:hover .assets-cover-play, .assets-cover-link:focus-visible .assets-cover-play { opacity: 1; } -/* 选中态:accent 勾选框(右下)+ accent 描边由 is-selected 提供 */ +/* 选择入口固定在左上角,徽章预留其空间;选中描边由 is-selected 提供。 */ .assets-select-check { position: absolute; - right: 8px; - bottom: 8px; + left: 8px; + top: 8px; z-index: 3; width: 22px; height: 22px; @@ -14167,17 +14168,19 @@ html:not(.dark) .creation-history-drawer-root { .assets-cover-model > svg { width: 34px; height: 34px; stroke-width: 1.1; } .assets-cover-model > span { max-width: 76%; overflow: hidden; font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; } -/* 批量操作条:浮现动画,黑白表面 */ +/* 批量操作条随列表正常排版,留出选中描边和 hover 位移的空间。 */ .assets-batch-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; - margin: 2px 0 0; + margin: 2px 0 var(--space-4); padding: 8px 12px; border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); border-radius: var(--r-lg); - background: color-mix(in srgb, var(--foreground) 3%, transparent); + background: color-mix(in srgb, var(--surface-card) 82%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); box-shadow: var(--elevation-hairline); animation: assets-batch-in 240ms var(--motion-ease-out) both; } @@ -14185,7 +14188,7 @@ html:not(.dark) .creation-history-drawer-root { @media (prefers-reduced-motion: reduce) { .assets-batch-bar { animation: none; } } .assets-batch-count { display: inline-flex; align-items: baseline; gap: 6px; margin-right: auto; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-label); } .assets-batch-count strong { color: var(--foreground); font-size: var(--fs-body); font-weight: 620; font-variant-numeric: tabular-nums; } -.assets-batch-actions { display: inline-flex; align-items: center; gap: 4px; } +.assets-batch-actions { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; } .assets-batch-actions .ant-btn { border: 0; border-radius: var(--r-md); background: transparent; color: color-mix(in srgb, var(--foreground) 56%, transparent); box-shadow: none; } .assets-batch-actions .ant-btn:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); } @@ -14284,16 +14287,6 @@ html:not(.dark) .creation-history-drawer-root { /* 1. 欢迎卡与 banner 同列宽,边缘对齐 */ .assets-empty-cards { width: min(720px, calc(100% - 24px)); } -/* 2. 批量操作条:sticky + 毛玻璃,滚动长列表时始终可见 */ -.assets-batch-bar { - position: sticky; - top: 12px; - z-index: 20; - background: color-mix(in srgb, var(--surface-card) 82%, transparent); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); -} - /* 素材密度由页面传入卡片最小宽度,网格布局统一在 workspace-product.css。 */ .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); } diff --git a/web/test/asset-batch-delete.test.ts b/web/test/asset-batch-delete.test.ts new file mode 100644 index 000000000..e7a2726f6 --- /dev/null +++ b/web/test/asset-batch-delete.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { QueryObserver } from "@tanstack/react-query"; +import { readFileSync } from "node:fs"; + +import { apiClient } from "@/services/api/request"; +import { deleteAssetsWithRemoteSync, initializeRemoteUserDataSession, loadAssetLibraryPage, resetRemoteUserDataSync } from "@/services/user-data-sync"; +import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { appQueryClient } from "@/lib/query-client"; + +const originalAdapter = apiClient.defaults.adapter; +async function within(promise: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000); + })]); + } finally { + clearTimeout(timer); + } +} +const asset = (id: string): Asset => ({ + id, kind: "text", title: id, coverUrl: "", tags: [], + createdAt: "2026-09-21T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z", + data: { content: "测试素材" }, +} as Asset); + +beforeEach(async () => { + useAssetStore.setState({ assets: [] }); + await flushAssetStorePersistence(); + await initializeRemoteUserDataSession("batch-test-user"); +}); + +afterEach(async () => { + await appQueryClient.cancelQueries(); + appQueryClient.clear(); + apiClient.defaults.adapter = originalAdapter; + resetRemoteUserDataSync(); + useAssetStore.setState({ assets: [] }); + await flushAssetStorePersistence(); +}); + +describe("素材批量删除", () => { + test("100 个素材只发一个 POST 数组请求,成功后只更新一次本地素材集合", async () => { + const ids = Array.from({ length: 100 }, (_, i) => `asset-${i}`); + useAssetStore.setState({ assets: [...ids.map(asset), asset("keep")] }); + const requests: unknown[] = []; + apiClient.defaults.adapter = async (config) => { + requests.push({ method: config.method, url: config.url, body: JSON.parse(config.data) }); + expect(useAssetStore.getState().assets).toHaveLength(101); + return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } }; + }; + let updates = 0; + const stop = useAssetStore.subscribe((state, previous) => { if (state.assets !== previous.assets) updates++; }); + try { + await deleteAssetsWithRemoteSync(ids); + expect(requests).toEqual([{ method: "post", url: "/assets/batch-delete", body: ids }]); + expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]); + expect(updates).toBe(1); + } finally { stop(); } + }); + + test.each([false, true])("真实分页查询刷新不阻塞删除完成(刷新失败:%s)", async (failRefresh) => { + const initialPage = { + assets: [asset("first"), asset("keep")], total: 2, page: 1, pageSize: 20, + kindCounts: {}, categoryCounts: {}, folderCounts: {}, hasMore: false, + }; + useAssetStore.setState({ assets: initialPage.assets }); + let releaseRefresh!: () => void; + const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); + const requests: string[] = []; + apiClient.defaults.adapter = async (config) => { + requests.push(`${config.method} ${config.url}`); + if (config.method === "post") { + return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["first"] }, msg: "ok" } }; + } + await refreshGate; + if (failRefresh) throw new Error("列表暂时不可用"); + return { config, headers: {}, status: 200, statusText: "OK", data: { + code: 0, data: { ...initialPage, assets: [asset("keep")], total: 1 }, msg: "ok", + } }; + }; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const observers = ["asset-library", "asset-picker"].map((key) => new QueryObserver(appQueryClient, { + queryKey: [key, "delete-regression"], + queryFn: () => loadAssetLibraryPage({ page: 1, pageSize: 20 }), + initialData: initialPage, + })); + const stops: (() => void)[] = []; + const refreshed = observers.map((observer) => new Promise((resolve) => { + stops.push(observer.subscribe((result) => { + if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve(); + })); + })); + try { + // GET 尚未返回,删除必须已返回,调用方才能关闭 Modal / Popconfirm。 + await within(deleteAssetsWithRemoteSync(["first"])); + expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]); + expect(requests).toEqual(["post /assets/batch-delete", "get /assets", "get /assets"]); + for (const observer of observers) expect(observer.getCurrentResult().fetchStatus).toBe("fetching"); + + releaseRefresh(); + // 真实 loadAssetLibraryPage 要再次进入同步队列;不能与删除互相等待。 + await within(Promise.all(refreshed)); + for (const observer of observers) { + const result = observer.getCurrentResult(); + if (failRefresh) expect(result.error?.message).toBe("列表暂时不可用"); + else expect(result.data?.assets.map((item) => item.id)).toEqual(["keep"]); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + if (failRefresh) expect(warning).toHaveBeenCalledWith("素材删除后列表刷新失败", expect.any(Error)); + expect(requests.filter((request) => request.startsWith("post"))).toHaveLength(1); + } finally { + releaseRefresh(); + await appQueryClient.cancelQueries(); + stops.forEach((stop) => stop()); + observers.forEach((observer) => observer.destroy()); + warning.mockRestore(); + } + }); + + test("业务失败不会先删本地素材或回退到逐条请求", async () => { + useAssetStore.setState({ assets: [asset("first"), asset("second")] }); + let requests = 0; + apiClient.defaults.adapter = async (config) => { + requests++; + return { config, headers: {}, status: 200, statusText: "OK", data: { code: 400, data: null, msg: "拒绝删除", reason: "invalid_argument" } }; + }; + await expect(deleteAssetsWithRemoteSync(["first", "second"])).rejects.toThrow("拒绝删除"); + expect(requests).toBe(1); + expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["first", "second"]); + }); + + test("删除响应返回前切换账号,不删除新账号本地素材", async () => { + useAssetStore.setState({ assets: [asset("same-id")] }); + apiClient.defaults.adapter = async (config) => { + resetRemoteUserDataSync(); + useAssetStore.setState({ assets: [asset("same-id")] }); + return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids: ["same-id"] }, msg: "ok" } }; + }; + await expect(deleteAssetsWithRemoteSync(["same-id"])).rejects.toThrow("账号已切换"); + expect(useAssetStore.getState().assets).toHaveLength(1); + }); + + test("空 ID 不发请求,重复 ID 合并到一个请求", async () => { + const bodies: string[][] = []; + apiClient.defaults.adapter = async (config) => { + const ids = JSON.parse(config.data); + bodies.push(ids); + return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } }; + }; + await expect(deleteAssetsWithRemoteSync(["good", " "])).rejects.toThrow("素材 ID 不能为空"); + expect(bodies).toHaveLength(0); + await deleteAssetsWithRemoteSync([" first ", "first", "second"]); + expect(bodies).toEqual([["first", "second"]]); + }); + + test("素材页和选择器的批量及清空入口使用数组同步方法", () => { + const page = readFileSync(new URL("../src/pages/assets/index.tsx", import.meta.url), "utf8"); + const picker = readFileSync(new URL("../src/components/assets/asset-library-picker-modal.tsx", import.meta.url), "utf8"); + expect(page).toContain("deleteAssetsWithRemoteSync(trashAssets.map"); + expect(page).toContain("deleteAssetsWithRemoteSync(selectedAssets.map"); + expect(picker).toContain("deleteAssetsWithRemoteSync(archivedSelectedIds)"); + expect(picker).toContain("deleteAssetsWithRemoteSync(toDelete.map"); + }); +}); diff --git a/web/test/assets-page-batch-toolbar.test.ts b/web/test/assets-page-batch-toolbar.test.ts index 9b5909fe0..516dd32e3 100644 --- a/web/test/assets-page-batch-toolbar.test.ts +++ b/web/test/assets-page-batch-toolbar.test.ts @@ -3,6 +3,27 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; describe("assets page batch toolbar", () => { + const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); + + test("keeps card selection in the top left with badge clearance", () => { + const checkbox = styles.match(/\.assets-select-check\s*\{([^}]+)\}/)?.[1] ?? ""; + const badges = styles.match(/\.assets-cover-badges\s*\{([^}]+)\}/)?.[1] ?? ""; + expect(checkbox).toContain("left: 8px;"); + expect(checkbox).toContain("top: 8px;"); + expect(checkbox).not.toMatch(/(?:right|bottom):/); + expect(badges).toContain("left: 38px;"); + expect(badges).toContain("right: 44px;"); + }); + + test("keeps batch actions in document flow with space above selected cards", () => { + const toolbarRules = [...styles.matchAll(/\.assets-batch-bar\s*\{([^}]+)\}/g)].map((match) => match[1]).join("\n"); + expect(toolbarRules).not.toMatch(/position:\s*(?:sticky|fixed|absolute)/); + expect(toolbarRules).not.toContain("z-index:"); + expect(toolbarRules).toContain("margin: 2px 0 var(--space-4);"); + const actions = styles.match(/\.assets-batch-actions\s*\{([^}]+)\}/)?.[1] ?? ""; + expect(actions).toContain("flex-wrap: wrap;"); + }); + test("places select all before cancel selection", () => { const source = readFileSync(resolve(import.meta.dir, "../src/pages/assets/index.tsx"), "utf8"); const selectAllIndex = source.search(/>\s*全选\s*<\/Button>/); diff --git a/web/test/canvas-tool-integration.test.ts b/web/test/canvas-tool-integration.test.ts index 17fbb74a8..e6408fe12 100644 --- a/web/test/canvas-tool-integration.test.ts +++ b/web/test/canvas-tool-integration.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { applyToolMention, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references"; +import { applyToolMention, buildToolMentionReference, parseToolMentionTokens, removeToolMentions } from "../src/lib/canvas/canvas-resource-references"; import { createNineGridNode } from "../src/lib/canvas/canvas-image-source"; import { CanvasNodeType } from "../src/types/canvas"; import type { CanvasNodeData } from "../src/types/canvas"; @@ -55,6 +55,17 @@ test("tool labels round trip; style replaces, motion deduplicates; clear preserv expect(parseToolMentionTokens(combined)).toHaveLength(2); }); +test("tool references with the same numeric id remain distinct across types", () => { + let prompt = applyToolMention("", { id: 7, type: "style", label: "水墨" }, "Palette"); + prompt = applyToolMention(prompt, { id: 7, type: "motion", label: "推镜" }, "Camera"); + expect(parseToolMentionTokens(prompt).map(({ type, toolId, label }) => ({ type, toolId, label }))).toEqual([ + { type: "style", toolId: 7, label: "水墨" }, + { type: "motion", toolId: 7, label: "推镜" }, + ]); + expect(buildToolMentionReference(7, "水墨", "style", "Palette").id) + .not.toBe(buildToolMentionReference(7, "推镜", "motion", "Camera").id); +}); + test("nine grid creates idle child using stable source token, not an auto generation", () => { const child = createNineGridNode({ id: "source", type: CanvasNodeType.Image, title: "图", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "/image.png" } }, "child", 79, "九宫格", "nine_grid", "Grid3x3"); expect(child.metadata?.prompt).toContain("@[node:source]");