From fe2b892f1f75c05187cfed5be099c6b7460f23ad Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:28:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=8A=80=E8=83=BD=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=94=AF=E6=8C=81=E5=90=8E=E5=8F=B0=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E4=B8=80=E7=BA=A7=E5=88=86=E7=B1=BB=20(#694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com> --- backend/internal/database/migrations.go | 3 +- .../internal/database/skill_curation_roots.go | 15 ++ .../database/skill_curation_roots_test.go | 53 ++++++ .../internal/handler/skill_curation_test.go | 3 + backend/internal/handler/skills.go | 1 + .../internal/model/skill_curation_roots.go | 27 +++ backend/internal/repository/builtin_skills.go | 3 + backend/internal/repository/skill_curation.go | 37 +++- .../repository/skill_curation_roots.go | 65 +++++++ backend/internal/repository/skills.go | 7 + backend/internal/skills/skill_curation.go | 30 +++- .../internal/skills/skill_curation_roots.go | 67 +++++++ .../skills/skill_curation_roots_test.go | 164 ++++++++++++++++++ .../internal/skills/skill_curation_test.go | 3 + backend/internal/skills/skills.go | 6 + .../content/docs/backend/backend-database.mdx | 2 + docs/content/docs/backend/code-map.mdx | 2 + docs/content/docs/backend/http-api.mdx | 2 + docs/content/docs/backend/skill-curation.mdx | 12 +- docs/content/docs/overview/features.mdx | 2 + docs/content/docs/progress/pending-test.mdx | 2 + .../canvas-agent-skill-library-modal.tsx | 11 +- .../canvas/canvas-cloud-agent-panel.tsx | 1 + .../skills/skill-curation-browser.tsx | 27 ++- web/src/pages/admin/skill-curation-page.tsx | 30 +++- web/src/pages/skills/index.tsx | 23 +-- web/src/services/api/skill-curation.ts | 5 +- web/src/services/api/skills.ts | 1 + web/test/skill-curation.test.ts | 12 +- 29 files changed, 579 insertions(+), 37 deletions(-) create mode 100644 backend/internal/database/skill_curation_roots.go create mode 100644 backend/internal/database/skill_curation_roots_test.go create mode 100644 backend/internal/model/skill_curation_roots.go create mode 100644 backend/internal/repository/skill_curation_roots.go create mode 100644 backend/internal/skills/skill_curation_roots.go create mode 100644 backend/internal/skills/skill_curation_roots_test.go diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 51796487f..302c7e916 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 46 +const CurrentSchemaVersion int64 = 47 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -147,6 +147,7 @@ var schemaMigrations = []migration{ return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{}) }}, {version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration}, + {version: 47, name: "skill_curation_roots", checksum: "sha256:skill-curation-roots-v47-20261005", apply: migrateSkillCurationRoots}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/skill_curation_roots.go b/backend/internal/database/skill_curation_roots.go new file mode 100644 index 000000000..847679ae9 --- /dev/null +++ b/backend/internal/database/skill_curation_roots.go @@ -0,0 +1,15 @@ +package database + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func migrateSkillCurationRoots(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil { + return err + } + roots := model.DefaultSkillCurationRoots() + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&roots).Error +} diff --git a/backend/internal/database/skill_curation_roots_test.go b/backend/internal/database/skill_curation_roots_test.go new file mode 100644 index 000000000..9fc2d1ed6 --- /dev/null +++ b/backend/internal/database/skill_curation_roots_test.go @@ -0,0 +1,53 @@ +package database + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "path/filepath" + "testing" +) + +func TestSkillCurationRootsMigration(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "roots.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + if err := db.Transaction(migrateSkillCuration); err != nil { + t.Fatal(err) + } + db.Create(&model.SkillCurationCategory{ID: "old", Name: "Existing", NormalizedName: "existing", RootTag: "drama", Enabled: true}) + if err := db.Transaction(migrateSkillCurationRoots); err != nil { + t.Fatal(err) + } + var roots []model.SkillCurationRoot + db.Find(&roots) + if len(roots) != 5 { + t.Fatalf("neutral defaults: %d", len(roots)) + } + db.Model(&model.SkillCurationRoot{}).Where("id = ?", "drama").Updates(map[string]any{"name": "Edited", "enabled": false}) + if err := db.Transaction(migrateSkillCurationRoots); err != nil { + t.Fatal(err) + } + var root model.SkillCurationRoot + db.First(&root, "id = ?", "drama") + if root.Name != "Edited" || root.Enabled { + t.Fatal("migration overwrote root") + } + var category model.SkillCurationCategory + db.First(&category, "id = ?", "old") + if category.RootTag != "drama" { + t.Fatal("migration changed U39 categories") + } + found := false + for _, m := range schemaMigrations { + if m.version == 47 { + found = m.name == "skill_curation_roots" && m.checksum == "sha256:skill-curation-roots-v47-20261005" + } + } + if !found { + t.Fatal("migration 47 is not registered") + } +} diff --git a/backend/internal/handler/skill_curation_test.go b/backend/internal/handler/skill_curation_test.go index 9a9b6872b..7e1343a17 100644 --- a/backend/internal/handler/skill_curation_test.go +++ b/backend/internal/handler/skill_curation_test.go @@ -22,6 +22,9 @@ func TestSkillCurationHTTP(t *testing.T) { t.Fatal(err) } sqlDB, _ := db.DB() + if err := db.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil { + t.Fatal(err) + } sqlDB.SetMaxOpenConns(1) defer sqlDB.Close() if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.Skill{}, &model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil { diff --git a/backend/internal/handler/skills.go b/backend/internal/handler/skills.go index d4d09fd7b..56871ce22 100644 --- a/backend/internal/handler/skills.go +++ b/backend/internal/handler/skills.go @@ -89,6 +89,7 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { return } result, err := svc.Skills(user.ID, service.SkillListRequest{ + PlatformRootID: c.Query("platformRootId"), PlatformCategoryID: c.Query("platformCategoryId"), PlatformUncategorized: c.Query("platformUncategorized") == "true", Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"), Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"), diff --git a/backend/internal/model/skill_curation_roots.go b/backend/internal/model/skill_curation_roots.go new file mode 100644 index 000000000..f3fe3b6d1 --- /dev/null +++ b/backend/internal/model/skill_curation_roots.go @@ -0,0 +1,27 @@ +package model + +type SkillCurationRoot struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + NormalizedName string `gorm:"size:128;not null;uniqueIndex" json:"-"` + IconKey string `gorm:"size:32" json:"iconKey"` + SortOrder int `json:"sortOrder"` + Enabled bool `json:"enabled"` +} + +type SkillCurationRootAssignment struct { + SkillID string `gorm:"primaryKey;size:36" json:"skillId"` + RootID string `gorm:"size:36;not null;index" json:"rootId"` +} + +// These neutral defaults match the original catalog. No skill content or +// skill-to-category mapping is shipped by the migration. +func DefaultSkillCurationRoots() []SkillCurationRoot { + return []SkillCurationRoot{ + {ID: "drama", Name: "短剧影视", NormalizedName: "短剧影视", IconKey: "film", SortOrder: 10, Enabled: true}, + {ID: "ecommerce", Name: "电商营销", NormalizedName: "电商营销", IconKey: "shopping-bag", SortOrder: 20, Enabled: true}, + {ID: "creative", Name: "创意设计", NormalizedName: "创意设计", IconKey: "palette", SortOrder: 30, Enabled: true}, + {ID: "social", Name: "社媒内容", NormalizedName: "社媒内容", IconKey: "users", SortOrder: 40, Enabled: true}, + {ID: "others", Name: "其他", NormalizedName: "其他", IconKey: "shapes", SortOrder: 50, Enabled: true}, + } +} diff --git a/backend/internal/repository/builtin_skills.go b/backend/internal/repository/builtin_skills.go index 9a3874ef8..b707b3822 100644 --- a/backend/internal/repository/builtin_skills.go +++ b/backend/internal/repository/builtin_skills.go @@ -28,6 +28,9 @@ func (r *Repository) DeleteBuiltinSkill(skillID string, actorID string) error { if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil { return err } + if err := tx.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", skillID).Error; err != nil { + return err + } if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "skill_id"}}, DoUpdates: clause.AssignmentColumns([]string{"deleted_by", "deleted_at"})}).Create(&model.BuiltinSkillTombstone{SkillID: skillID, DeletedBy: actorID, DeletedAt: now}).Error; err != nil { return err } diff --git a/backend/internal/repository/skill_curation.go b/backend/internal/repository/skill_curation.go index 03661511a..156775b6d 100644 --- a/backend/internal/repository/skill_curation.go +++ b/backend/internal/repository/skill_curation.go @@ -10,8 +10,10 @@ import ( type SkillCuration struct { model.SkillCurationSetting - Categories []model.SkillCurationCategory `json:"categories"` - Assignments []model.SkillCurationAssignment `json:"assignments"` + Roots []model.SkillCurationRoot `json:"roots,omitempty"` + RootAssignments []model.SkillCurationRootAssignment `json:"rootAssignments,omitempty"` + Categories []model.SkillCurationCategory `json:"categories"` + Assignments []model.SkillCurationAssignment `json:"assignments"` } // Read the version before and after the snapshot so concurrent edits cannot @@ -25,6 +27,21 @@ func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) { if !admin && !out.Enabled { return out, nil } + roots, err := r.SkillCurationRoots() + if err != nil { + return nil, err + } + for _, root := range roots { + if admin || root.Enabled { + out.Roots = append(out.Roots, root) + } + } + if !admin { + out.Roots = append(out.Roots, model.SkillCurationRoot{ID: UnassignedSkillRoot, Name: "未归入启用分类", IconKey: "shapes", Enabled: true}) + } + if err := r.db.Model(&model.SkillCurationRootAssignment{}).Select("skill_curation_root_assignments.*").Joins("JOIN skills ON skills.id = skill_curation_root_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false").Order("skill_id").Find(&out.RootAssignments).Error; err != nil { + return nil, err + } query := r.db.Order("sort_order, id") if !admin { query = query.Where("enabled = ?", true) @@ -32,10 +49,22 @@ func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) { if err := query.Find(&out.Categories).Error; err != nil { return nil, err } + if !admin { + visible := []model.SkillCurationCategory{} + for _, c := range out.Categories { + for _, root := range out.Roots { + if root.ID == c.RootTag { + visible = append(visible, c) + break + } + } + } + out.Categories = visible + } query = r.db.Model(&model.SkillCurationAssignment{}).Select("skill_curation_assignments.*"). Joins("JOIN skills ON skills.id = skill_curation_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false") if !admin { - query = query.Joins("JOIN skill_curation_categories c ON c.id = skill_curation_assignments.category_id").Where("c.enabled = true AND c.root_tag = skills.tag") + query = query.Joins("JOIN skill_curation_categories c ON c.id = skill_curation_assignments.category_id").Where("c.enabled = true AND c.root_tag = " + effectiveCurationRoot) } if err := query.Order("skill_id, category_id").Find(&out.Assignments).Error; err != nil { return nil, err @@ -111,4 +140,4 @@ func (r *Repository) AssignSkillCuration(skillID string, ids []string) error { return nil } -const effectiveCurationCategory = `SELECT 1 FROM skill_curation_assignments a JOIN skill_curation_categories c ON c.id = a.category_id WHERE a.skill_id = skills.id AND c.enabled = true AND c.root_tag = skills.tag AND skills.is_private = false` +const effectiveCurationCategory = `SELECT 1 FROM skill_curation_assignments a JOIN skill_curation_categories c ON c.id = a.category_id WHERE a.skill_id = skills.id AND c.enabled = true AND c.root_tag = ` + effectiveCurationRoot + ` AND skills.is_private = false` diff --git a/backend/internal/repository/skill_curation_roots.go b/backend/internal/repository/skill_curation_roots.go new file mode 100644 index 000000000..b2adfc2ec --- /dev/null +++ b/backend/internal/repository/skill_curation_roots.go @@ -0,0 +1,65 @@ +package repository + +import ( + "infinite-canvas/backend/internal/model" +) + +const UnassignedSkillRoot = "__unassigned__" + +func (r *Repository) SkillCurationRoots() ([]model.SkillCurationRoot, error) { + rows := []model.SkillCurationRoot{} + if err := r.db.Order("sort_order, id").Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return model.DefaultSkillCurationRoots(), nil + } + return rows, nil +} +func (r *Repository) SaveSkillCurationRoot(root *model.SkillCurationRoot) error { + // Materialize the fallback before its first edit, keeping existing children + // attached to their stable root IDs even after an empty-table fallback. + var count int64 + if err := r.db.Model(&model.SkillCurationRoot{}).Count(&count).Error; err != nil { + return err + } + if count == 0 { + rows := model.DefaultSkillCurationRoots() + if err := r.db.Create(&rows).Error; err != nil { + return err + } + } + return r.db.Save(root).Error +} +func (r *Repository) AssignSkillCurationRoot(skillID, rootID string) error { + if err := r.db.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", skillID).Error; err != nil { + return err + } + if rootID == "" { + return nil + } + return r.db.Create(&model.SkillCurationRootAssignment{SkillID: skillID, RootID: rootID}).Error +} + +func (state *SkillCuration) EffectiveRoot(skill *model.Skill) string { + id := skill.Tag + if !skill.IsPrivate { + for _, a := range state.RootAssignments { + if a.SkillID == skill.ID { + id = a.RootID + break + } + } + } + for _, root := range state.Roots { + if root.ID == id && root.Enabled { + return id + } + } + return UnassignedSkillRoot +} + +// Explicit assignments override the source tag. A disabled/missing target is +// kept discoverable in the default bucket; it never falls into another root. +const curationRootID = `COALESCE((SELECT ra.root_id FROM skill_curation_root_assignments ra WHERE ra.skill_id = skills.id AND skills.is_private = false),skills.tag)` +const effectiveCurationRoot = `(CASE WHEN NOT EXISTS (SELECT 1 FROM skill_curation_roots) THEN CASE WHEN ` + curationRootID + ` IN ('drama','ecommerce','creative','social','others') THEN ` + curationRootID + ` ELSE '__unassigned__' END ELSE COALESCE((SELECT r.id FROM skill_curation_roots r WHERE r.enabled = true AND r.id = ` + curationRootID + `), '__unassigned__') END)` diff --git a/backend/internal/repository/skills.go b/backend/internal/repository/skills.go index d0e51670c..28395ce6f 100644 --- a/backend/internal/repository/skills.go +++ b/backend/internal/repository/skills.go @@ -11,6 +11,7 @@ import ( ) type SkillListFilter struct { + PlatformRootID string PlatformCategoryID string PlatformUncategorized bool UserID string @@ -79,6 +80,9 @@ func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error if filter.Tag != "" { query = query.Where("skills.tag = ?", filter.Tag) } + if filter.PlatformRootID != "" { + query = query.Where(effectiveCurationRoot+" = ?", filter.PlatformRootID) + } if filter.PlatformCategoryID != "" { query = query.Where("EXISTS ("+effectiveCurationCategory+" AND c.id = ?)", filter.PlatformCategoryID) } @@ -147,6 +151,9 @@ func (r *Repository) DeleteSkill(id string) error { if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", id).Error; err != nil { return err } + if err := tx.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", id).Error; err != nil { + return err + } if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil { return err } diff --git a/backend/internal/skills/skill_curation.go b/backend/internal/skills/skill_curation.go index 74d702d70..cc303b981 100644 --- a/backend/internal/skills/skill_curation.go +++ b/backend/internal/skills/skill_curation.go @@ -10,10 +10,12 @@ import ( type SkillCuration = repository.SkillCuration type SkillCurationAssignmentInput struct { + RootID *string `json:"rootId,omitempty"` SkillID string `json:"skillId"` CategoryIDs []string `json:"categoryIds"` } type SkillCurationUpdate struct { + Root *model.SkillCurationRoot `json:"root,omitempty"` ExpectedRevision *int64 `json:"expectedRevision"` Enabled *bool `json:"enabled,omitempty"` Category *model.SkillCurationCategory `json:"category,omitempty"` @@ -46,6 +48,9 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate return nil, err } count := 0 + if req.Root != nil { + count++ + } if req.Enabled != nil { count++ } @@ -66,11 +71,14 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate if err != nil { return err } + if req.Root != nil { + return saveCurationRoot(repo, state, *req.Root) + } if req.Category != nil { c := *req.Category c.Name = strings.Join(strings.Fields(c.Name), " ") c.NormalizedName = strings.ToLower(c.Name) - if utf8.RuneCountInString(c.Name) < 1 || utf8.RuneCountInString(c.Name) > 64 || skillCategoryLabels[c.RootTag] == "" { + if utf8.RuneCountInString(c.Name) < 1 || utf8.RuneCountInString(c.Name) > 64 || !activeCurationRoot(state, c.RootTag) { return kernel.BadAuthRequest("分类名称或一级分类无效") } exists := c.ID == "" @@ -101,11 +109,24 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate if err != nil { return kernel.NotFound("公开技能不存在") } + rootID := state.EffectiveRoot(skill) + if a.RootID != nil { + if *a.RootID != "" && !activeCurationRoot(state, *a.RootID) { + return kernel.BadAuthRequest("一级分类必须存在且启用") + } + rootID = *a.RootID + if rootID == "" { + rootID = skill.Tag + if !activeCurationRoot(state, rootID) { + rootID = repository.UnassignedSkillRoot + } + } + } seen := map[string]bool{} for _, id := range a.CategoryIDs { valid := false for _, c := range state.Categories { - if c.ID == id && c.Enabled && c.RootTag == skill.Tag { + if c.ID == id && c.Enabled && c.RootTag == rootID { valid = true } } @@ -114,6 +135,11 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate } seen[id] = true } + if a.RootID != nil { + if err := repo.AssignSkillCurationRoot(a.SkillID, *a.RootID); err != nil { + return err + } + } return repo.AssignSkillCuration(a.SkillID, a.CategoryIDs) }) if err != nil { diff --git a/backend/internal/skills/skill_curation_roots.go b/backend/internal/skills/skill_curation_roots.go new file mode 100644 index 000000000..b91493fb1 --- /dev/null +++ b/backend/internal/skills/skill_curation_roots.go @@ -0,0 +1,67 @@ +package skills + +import ( + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "strings" + "unicode/utf8" +) + +func activeCurationRoot(state *SkillCuration, id string) bool { + if id == repository.UnassignedSkillRoot { + return false + } + for _, root := range state.Roots { + if root.ID == id && root.Enabled { + return true + } + } + return false +} + +func saveCurationRoot(repo *repository.Repository, state *SkillCuration, root model.SkillCurationRoot) error { + root.Name = strings.Join(strings.Fields(root.Name), " ") + root.NormalizedName = strings.ToLower(root.Name) + if utf8.RuneCountInString(root.Name) < 1 || utf8.RuneCountInString(root.Name) > 64 { + return kernel.BadAuthRequest("一级分类名称必须为1-64个字符") + } + switch root.IconKey { + case "", "shapes", "film", "shopping-bag", "megaphone", "landmark", "palette", "users", "wrench": + default: + return kernel.BadAuthRequest("图示键无效") + } + exists := root.ID == "" + for _, old := range state.Roots { + if old.ID == root.ID { + exists = true + } + if old.ID != root.ID && old.NormalizedName == root.NormalizedName { + return kernel.BadAuthRequest("一级分类名称不能重复") + } + } + if !exists { + return kernel.NotFound("一级分类不存在") + } + if root.ID == "" { + root.ID = kernel.NewID() + } + return repo.SaveSkillCurationRoot(&root) +} + +func (s *Service) curationRootFilter(id string) (string, error) { + if id == "" { + return "", nil + } + state, err := s.repo.SkillCuration(false) + if err != nil { + return "", err + } + if !state.Enabled { + return "", nil + } + if id == repository.UnassignedSkillRoot || activeCurationRoot(state, id) { + return id, nil + } + return "", kernel.BadAuthRequest("一级分类已停用或不存在,请重新选择") +} diff --git a/backend/internal/skills/skill_curation_roots_test.go b/backend/internal/skills/skill_curation_roots_test.go new file mode 100644 index 000000000..99bc0abb0 --- /dev/null +++ b/backend/internal/skills/skill_curation_roots_test.go @@ -0,0 +1,164 @@ +package skills + +import ( + "encoding/json" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "reflect" + "testing" +) + +func TestSkillCurationRootManagementAvailable(t *testing.T) { + if _, ok := reflect.TypeOf(SkillCurationUpdate{}).FieldByName("Root"); !ok { + t.Fatal("missing root management: U39 only supports the five hard-coded root tags") + } +} + +func TestSkillCurationRootsLifecycle(t *testing.T) { + svc, db, actor := newCurationTest(t) + state, err := svc.SkillCuration(actor, true) + if err != nil { + t.Fatal(err) + } + defaults := model.DefaultSkillCurationRoots() + if !reflect.DeepEqual(state.Roots, defaults) { + t.Fatalf("empty table fallback changed: %#v", state.Roots) + } + update := func(req SkillCurationUpdate) *SkillCuration { + t.Helper() + current, err := svc.SkillCuration(actor, true) + if err != nil { + t.Fatal(err) + } + req.ExpectedRevision = ¤t.Revision + out, err := svc.UpdateSkillCuration(actor, req, "root-test") + if err != nil { + t.Fatal(err) + } + return out + } + enabled := true + update(SkillCurationUpdate{Enabled: &enabled}) + state = update(SkillCurationUpdate{Root: &model.SkillCurationRoot{Name: "New root", IconKey: "landmark", SortOrder: -10, Enabled: true}}) + root := state.Roots[0] + if len(state.Roots) != 6 || root.Name != "New root" { + t.Fatalf("root creation/order: %#v", state.Roots) + } + state = update(SkillCurationUpdate{Category: &model.SkillCurationCategory{Name: "Child", RootTag: root.ID, Enabled: true}}) + child := state.Categories[0] + for _, skill := range []model.Skill{{ID: "assigned", Name: "Assigned", Tag: "drama", Status: 1}, {ID: "legacy", Name: "Legacy", Tag: "drama", Status: 1}, {ID: "unknown", Name: "Unknown", Tag: "unknown", Status: 1}, {ID: "private", Name: "Private", Tag: "drama", Status: 1, IsPrivate: true, OwnerID: actor.ID}} { + if err := db.Create(&skill).Error; err != nil { + t.Fatal(err) + } + } + state = update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &root.ID, CategoryIDs: []string{child.ID}}}) + for _, req := range []SkillListRequest{{PlatformRootID: root.ID}, {PlatformCategoryID: child.ID}, {PlatformRootID: repository.UnassignedSkillRoot}} { + list, err := svc.Skills(actor.ID, req) + if err != nil || list.TotalCount != 1 { + t.Fatalf("root/child/default list: %#v %v", list, err) + } + } + skill, err := svc.repo.Skill("assigned") + if err != nil || skill.Tag != "drama" { + t.Fatal("root assignment changed source tag") + } + // Root changes must replace children atomically; reject incompatible old children. + drama := "drama" + _, err = svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &drama, CategoryIDs: []string{child.ID}}}, "") + if appErrorStatus(err) != 400 { + t.Fatalf("cross-root update: %v", err) + } + current, _ := svc.SkillCuration(actor, true) + if current.Revision != state.Revision || current.EffectiveRoot(skill) != root.ID { + t.Fatal("failed change partially committed") + } + root.Enabled = false + root.Name = "Renamed root" + state = update(SkillCurationUpdate{Root: &root}) + public, err := svc.SkillCuration(actor, false) + if err != nil { + t.Fatal(err) + } + if len(public.Categories) != 0 || len(public.Assignments) != 0 { + t.Fatal("disabled root still exposes children") + } + list, err := svc.Skills(actor.ID, SkillListRequest{PlatformRootID: repository.UnassignedSkillRoot}) + if err != nil || list.TotalCount != 2 { + t.Fatalf("disabled root lost skills: %#v %v", list, err) + } + root.Enabled = true + update(SkillCurationUpdate{Root: &root}) + clear := "" + update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &clear, CategoryIDs: []string{}}}) + list, err = svc.Skills(actor.ID, SkillListRequest{PlatformRootID: "drama"}) + if err != nil || list.TotalCount != 2 { + t.Fatalf("clear override: %#v %v", list, err) + } + off := false + update(SkillCurationUpdate{Enabled: &off}) + original, err := svc.Skills(actor.ID, SkillListRequest{}) + if err != nil { + t.Fatal(err) + } + filtered, err := svc.Skills(actor.ID, SkillListRequest{PlatformRootID: "does-not-exist"}) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(original) + b, _ := json.Marshal(filtered) + if string(a) != string(b) { + t.Fatal("disabled root filter changed original response") + } + public, err = svc.SkillCuration(actor, false) + if err != nil || len(public.Roots) != 0 || len(public.RootAssignments) != 0 { + t.Fatalf("disabled mode leaked roots: %#v %v", public, err) + } +} + +func TestSkillCurationRootsValidation(t *testing.T) { + svc, _, actor := newCurationTest(t) + revision := int64(0) + for _, root := range []model.SkillCurationRoot{ + {Name: "Bad icon", IconKey: "https://example.invalid/icon.svg", Enabled: true}, + {Name: "短剧影视", Enabled: true}, + {ID: "unknown", Name: "Unknown", Enabled: true}, + } { + if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &revision, Root: &root}, ""); err == nil { + t.Fatalf("accepted invalid root: %#v", root) + } + } +} + +func TestSkillCurationRootsDeletionAndPrivateVisibility(t *testing.T) { + svc, db, actor := newCurationTest(t) + if err := db.AutoMigrate(&model.SkillVersion{}, &model.SkillFile{}, &model.BuiltinSkillTombstone{}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"normal", "builtin", "private"} { + db.Create(&model.Skill{ID: id, Tag: "drama", Status: 1, IsPrivate: id == "private", OwnerID: actor.ID}) + db.Create(&model.SkillCurationRootAssignment{SkillID: id, RootID: "social"}) + } + db.Model(&model.SkillCurationSetting{}).Where("id = 1").Update("enabled", true) + state, err := svc.SkillCuration(actor, false) + if err != nil { + t.Fatal(err) + } + if len(state.RootAssignments) != 2 { + t.Fatalf("private override exposed: %#v", state.RootAssignments) + } + list, err := svc.Skills(actor.ID, SkillListRequest{Scope: "mine", PlatformRootID: "drama"}) + if err != nil || list.TotalCount != 1 || list.Skills[0].SkillID != "private" { + t.Fatalf("private skill root changed: %#v %v", list, err) + } + if err := svc.repo.DeleteSkill("normal"); err != nil { + t.Fatal(err) + } + if err := svc.repo.DeleteBuiltinSkill("builtin", actor.ID); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.SkillCurationRootAssignment{}).Where("skill_id != ?", "private").Count(&count) + if count != 0 { + t.Fatal("deleted root assignment retained") + } +} diff --git a/backend/internal/skills/skill_curation_test.go b/backend/internal/skills/skill_curation_test.go index b19de8ebe..afac0de67 100644 --- a/backend/internal/skills/skill_curation_test.go +++ b/backend/internal/skills/skill_curation_test.go @@ -17,6 +17,9 @@ func TestSkillCurationManagementAvailable(t *testing.T) { func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) { t.Helper() svc, db := newSkillLibraryCategoryTestService(t) + if err := db.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil { + t.Fatal(err) + } if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil { t.Fatal(err) } diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index f6974df01..286f2a16b 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -108,6 +108,7 @@ type SkillCategory struct { } type SkillListRequest struct { + PlatformRootID string PlatformCategoryID string PlatformUncategorized bool Page int @@ -147,6 +148,10 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error if err != nil { return nil, err } + rootID, err := s.curationRootFilter(req.PlatformRootID) + if err != nil { + return nil, err + } if req.LibraryCategoryID != "" && req.LibraryUncategorized { return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用") } @@ -156,6 +161,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error } } rows, total, err := s.repo.Skills(repository.SkillListFilter{ + PlatformRootID: rootID, PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized, UserID: userID, Scope: req.Scope, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 5afa8ef9f..9874dcaa6 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -301,3 +301,5 @@ Schema 32 为 `channel_models` 新增 `tags`(TEXT、NOT NULL、默认 `'[]'` ## 可选技能策展 迁移 46 新增 skill_curation_settings、skill_curation_categories、skill_curation_assignments;默认关闭,不导入技能内容。表结构与事务规则见 [平台技能策展](./skill-curation)。 + +迁移 47 依赖 46,新增 skill_curation_roots 与 skill_curation_root_assignments;保留原五个根 ID,根停用时使用派生默认桶,根表为空回落固定五类。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 26f1646aa..804f47b83 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -163,3 +163,5 @@ HTTP 请求 -> handler -> service(稳定导入面 / 别名)-> app(组合 ## 技能策展 `internal/skills/skill_curation.go` 负责校验,`internal/repository/skill_curation.go` 负责分类持久化与 CAS 审计交易,handler/app bridge 接入原有技能域。 + +`skill_curation_roots.go` 扩展动态根维护、有效根查询和空表回落;数据库迁移 47 接续 46。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 46489060a..49a0ed2ab 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -393,3 +393,5 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两 ## 平台技能策展 新增登录读取 `/api/skills/curation` 与管理员 GET/PUT `/api/admin/skill-curation`;原技能列表仅在策展开启时应用新增分类筛选,响应结构不变。见 [接口与行为](./skill-curation)。 + +一级分类扩展:管理写入增加 root 命令,assignment.rootId 与子分类完整集合一起保存;列表增加 platformRootId 筛选。关闭模式继续忽略新增筛选。 diff --git a/docs/content/docs/backend/skill-curation.mdx b/docs/content/docs/backend/skill-curation.mdx index d283b6952..ec66de778 100644 --- a/docs/content/docs/backend/skill-curation.mdx +++ b/docs/content/docs/backend/skill-curation.mdx @@ -1,6 +1,6 @@ --- title: 平台技能策展 -description: 可选的后台子分类维护 +description: 可选的后台一级分类和子分类维护 --- 平台策展模式在“后台 → 技能分类”中开启,默认关闭。关闭时原有 `/skills` JSON、固定五类、个人技能库分类保持不变,新增筛选参数被忽略。分类设置可以在开启前准备。 @@ -31,3 +31,13 @@ description: 可选的后台子分类维护 ## 待验证 SQLite 及 HTTP recorder 可在无网络环境验证。PostgreSQL、前端构建、浏览器中的明暗主题、键盘操作和分页交互需要单独验证。 + +## 一级分类维护 + +迁移 47 `skill_curation_roots`,checksum `sha256:skill-curation-roots-v47-20261005`,依赖迁移 46。新增 `skill_curation_roots`(id、name、normalized_name 唯一索引、icon_key、sort_order、enabled)及 `skill_curation_root_assignments`(skill_id 主键、root_id 索引)。仅创建原目录的五个中性根,保持 U39 的 rootTag 引用,不导入任何技能指派。 + +管理员在同一页面维护一级分类;PUT 增加 `root` 命令,含 id(新建为空)、name、iconKey、sortOrder、enabled。图示只接受空值或 shapes/film/shopping-bag/megaphone/landmark/palette/users/wrench,客户端未知值使用 Shapes。根表为空时回落原五类;第一次编辑时将回落值保存入表。读取或数据库错误不冒充空表。 + +`assignment` 可带 `rootId`,空字符串清除指定;改根必须同时提交完整 categoryIds,跨根则整笔回滚。原 tag、技能内容、版本和安装状态不变。未指定时按原 tag 对应稳定根 ID;指定优先,目标停用或不存在时进入派生桶 `__unassigned__`(未归入启用分类),此桶不可编辑或指派。根停用不删除子分类、指派或已挂载技能。 + +策展快照开启时增加 roots/rootAssignments;公开读取隐藏停用根及其子分类,不公开私人技能指派。`GET /api/skills?platformRootId=...` 在原有可见性和分页查询前筛选;关闭时忽略该参数,原响应结构和五类不变。后台界面和市场/技能库根导览、分组、图示均读取同一快照。场景预设来源及 scene 仍不变。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 65011f920..4bd0098b1 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -452,3 +452,5 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph ## 平台技能策展 后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。 + +开启时一级分类也可在后台新增、改名、排序、停用和选择图示,未归入技能仍有默认入口。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f867834fc..7d0217941 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -2919,3 +2919,5 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客 ## 平台技能策展 SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。 + +一级分类还需 PostgreSQL 验证 46→47 与并发改归/删除,以及浏览器图示、根停用默认桶与空根表回落。handler 全套涉及 socket,受限环境中未完成。 diff --git a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx index c4ffd3cba..c77c3d0ae 100644 --- a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx +++ b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx @@ -5,7 +5,7 @@ import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react import { AppModal } from "@/components/ui/product/app-modal"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills"; -import { SkillCurationBrowser, matchesCuration, type useSkillCuration } from "@/components/skills/skill-curation-browser"; +import { SkillCurationBrowser, matchesCuration, effectiveCurationRoot, type useSkillCuration } from "@/components/skills/skill-curation-browser"; type SkillLibraryTab = "enabled" | "installed" | "market"; @@ -135,6 +135,7 @@ export function CanvasAgentSkillLibraryModal({ setTab(value); setLibraryCategoryId("all"); onCategoryChange("all"); + onPlatformCategoryChange(""); }; const selectMarketplaceCategory = (value: string) => { @@ -206,7 +207,7 @@ export function CanvasAgentSkillLibraryModal({
技能广场分类
changeTab("market")} /> - {categoryItems.map((item) => ( + {!curationState.curation?.enabled && categoryItems.map((item) => ( root.id === effectiveCurationRoot(skill, curationState.curation!))?.name : undefined} theme={theme} categories={categories} selected={selectedSkillIds.includes(skill.skillId)} @@ -277,8 +279,9 @@ function SkillTab({ active, label, count, onClick }: { active: boolean; label: s ); } -function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onToggle, onInstall }: { +function SkillLibraryCard({ skill, rootLabel, theme, categories, selected, canSelect, onToggle, onInstall }: { skill: Skill; + rootLabel?: string; theme: CanvasTheme; categories: SkillCategory[]; selected: boolean; @@ -292,7 +295,7 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog || skill.showcaseMedia?.find((item) => item.showcaseUrl); const coverUrl = cover?.showcaseUrl; const hasCover = Boolean(coverUrl && !coverFailed); - const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他"; + const categoryLabel = rootLabel || categories.find((item) => item.value === skill.tag)?.label || "其他"; const author = skill.effectiveUser?.name || "影策创作者"; const addedCount = formatSkillCount(skill.addedCount || 0); diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 1e6857e66..564cf1b29 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -115,6 +115,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se const skillBrowseKey = JSON.stringify([userId, skillTag, debouncedSkillSearch, platformCategory, curationState.curation?.revision, curationState.curation?.enabled]); const skillBrowseKeyRef = useRef(skillBrowseKey); skillBrowseKeyRef.current = skillBrowseKey; + useEffect(() => { if (curationState.curation?.enabled) setSkillTag("all"); }, [curationState.curation?.enabled]); const [busy, setBusy] = useState(false); const [approvalSubmitting, setApprovalSubmitting] = useState(false); const [exporting, setExporting] = useState(false); diff --git a/web/src/components/skills/skill-curation-browser.tsx b/web/src/components/skills/skill-curation-browser.tsx index a6896e9f3..07d0f512b 100644 --- a/web/src/components/skills/skill-curation-browser.tsx +++ b/web/src/components/skills/skill-curation-browser.tsx @@ -3,10 +3,24 @@ import { Alert, Button, Select, Space } from "antd"; import { getActiveUserScope } from "@/lib/user-scope"; import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation"; import type { Skill } from "@/services/api/skills"; -import { fallbackSkillCategories } from "@/pages/skills/skill-catalog"; +import { Film, ShoppingBag, Megaphone, Landmark, Palette, Users, Wrench, Shapes } from "lucide-react"; + +export const curationIconKeys = ["shapes", "film", "shopping-bag", "megaphone", "landmark", "palette", "users", "wrench"]; +export const curationIconLabels: Record = { shapes: "默认", film: "影视", "shopping-bag": "购物袋", megaphone: "喇叭", landmark: "地标", palette: "调色盘", users: "社群", wrench: "工具" }; +export function curationIcon(key?: string) { + return ({ film: Film, "shopping-bag": ShoppingBag, megaphone: Megaphone, landmark: Landmark, palette: Palette, users: Users, wrench: Wrench } as Record)[key || ""] || Shapes; +} +export function effectiveCurationRoot(skill: Skill, data: SkillCuration) { + const id = (!skill.isPrivate && data.rootAssignments?.find((item) => item.skillId === skill.skillId)?.rootId) || skill.tag; + return data.roots?.some((root) => root.id === id && root.enabled) ? id : "__unassigned__"; +} +export function groupCuratedSkills(skills: Skill[], data: SkillCuration) { + return (data.roots || []).map((root) => ({ value: root.id, label: root.name, skills: skills.filter((skill) => effectiveCurationRoot(skill, data) === root.id) })).filter((group) => group.skills.length); +} export function curationQuery(data: SkillCuration | null, value: string) { if (!data?.enabled || !value) return {}; + if (value.startsWith("root:")) return { platformRootId: value.slice(5) }; return value === "__uncategorized__" ? { platformUncategorized: true } : { platformCategoryId: value }; } @@ -30,18 +44,21 @@ export function useSkillCuration(active = true) { export function matchesCuration(skill: Skill, data: SkillCuration | null, category: string) { if (!data?.enabled || !category) return true; + if (category.startsWith("root:")) return effectiveCurationRoot(skill, data) === category.slice(5); const ids = data.assignments.filter((item) => item.skillId === skill.skillId).map((item) => item.categoryId); return category === "__uncategorized__" ? ids.length === 0 : ids.includes(category); } export function SkillCurationBrowser({ data, value, onChange, error, retry }: { data: SkillCuration | null; value: string; onChange: (value: string) => void; error: string; retry: () => void }) { useEffect(() => { - if ((!data?.enabled || (value && value !== "__uncategorized__" && !data.categories.some((item) => item.id === value))) && value) onChange(""); + const valid = value.startsWith("root:") ? data?.roots?.some((root) => root.id === value.slice(5)) : data?.categories.some((item) => item.id === value); + if ((!data?.enabled || (value && value !== "__uncategorized__" && !valid)) && value) onChange(""); }, [data, value, onChange]); if (error) return 重试} />; if (!data?.enabled) return null; - return 平台分类 { const Icon = curationIcon(root.iconKey); return { value: `root:${root.id}`, label: {root.name} }; }), + ...data.categories.map((item) => ({ value: item.id, label: `${data.roots?.find((root) => root.id === item.rootTag)?.name || "分类"} / ${item.name}` })), ]} />; } diff --git a/web/src/pages/admin/skill-curation-page.tsx b/web/src/pages/admin/skill-curation-page.tsx index deabd9c66..2a8c6f061 100644 --- a/web/src/pages/admin/skill-curation-page.tsx +++ b/web/src/pages/admin/skill-curation-page.tsx @@ -3,8 +3,8 @@ import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd"; import { AdminPageFrame } from "./components/admin-shell"; import { AdminDataTable } from "./components/admin-ui"; import { AdminSwitch } from "./ui/controls"; -import { fallbackSkillCategories } from "@/pages/skills/skill-catalog"; -import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration } from "@/services/api/skill-curation"; +import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration, type CurationRoot } from "@/services/api/skill-curation"; +import { curationIcon, curationIconKeys, curationIconLabels } from "@/components/skills/skill-curation-browser"; import { listSkills, type Skill } from "@/services/api/skills"; export default function SkillCurationPage() { @@ -17,6 +17,8 @@ export default function SkillCurationPage() { const [skills, setSkills] = useState([]); const [skillId, setSkillId] = useState(""); const [categoryIds, setCategoryIds] = useState([]); + const [root, setRoot] = useState({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true }); + const [assignedRoot, setAssignedRoot] = useState(""); const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } }; useEffect(() => { void reload(); }, []); useEffect(() => { @@ -36,12 +38,25 @@ export default function SkillCurationPage() { {error && } void save({ enabled })} />平台策展模式 + table={{ rowKey: "id", dataSource: data?.roots || [], pagination: false, loading: !data, columns: [ + { title: "一级分类", dataIndex: "name" }, { title: "图示", render: (_, item) => { const Icon = curationIcon(item.iconKey); return ; } }, + { title: "排序", dataIndex: "sortOrder" }, { title: "状态", render: (_, item) => item.enabled ? "启用" : "停用" }, + { title: "操作", render: (_, item) => }, + ] }} /> + + setRoot({ ...root, name: e.target.value })} /> + setCategory({ ...category, rootTag })} /> + setCategory({ ...category, name: e.target.value })} /> setCategory({ ...category, sortOrder: sortOrder ?? 0 })} /> setCategory({ ...category, enabled })} /> @@ -50,9 +65,10 @@ export default function SkillCurationPage() { setSearch(e.target.value)} /> - item.enabled && item.rootTag === selectedSkill?.tag).map((item) => ({ value: item.id, label: item.name }))} /> - + ({ value: item.id, label: item.name, disabled: !item.enabled }))]} onChange={(id) => { setAssignedRoot(id); setCategoryIds([]); }} /> +