feat(skills): 技能管理 - 支持后台维护一级分类 (#694)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:28:03 +08:00
committed by GitHub
parent fb4af9bd34
commit fe2b892f1f
29 changed files with 579 additions and 37 deletions

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const CurrentSchemaVersion int64 = 46 const CurrentSchemaVersion int64 = 47
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-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{}) return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{})
}}, }},
{version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration}, {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 { func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -22,6 +22,9 @@ func TestSkillCurationHTTP(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sqlDB, _ := db.DB() sqlDB, _ := db.DB()
if err := db.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxOpenConns(1)
defer sqlDB.Close() defer sqlDB.Close()
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.Skill{}, &model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil { if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.Skill{}, &model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {

View File

@@ -89,6 +89,7 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
return return
} }
result, err := svc.Skills(user.ID, service.SkillListRequest{ result, err := svc.Skills(user.ID, service.SkillListRequest{
PlatformRootID: c.Query("platformRootId"),
PlatformCategoryID: c.Query("platformCategoryId"), PlatformUncategorized: c.Query("platformUncategorized") == "true", PlatformCategoryID: c.Query("platformCategoryId"), PlatformUncategorized: c.Query("platformUncategorized") == "true",
Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"), Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"),
Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"), Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"),

View File

@@ -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},
}
}

View File

@@ -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 { if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil {
return err 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 { 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 return err
} }

View File

@@ -10,8 +10,10 @@ import (
type SkillCuration struct { type SkillCuration struct {
model.SkillCurationSetting model.SkillCurationSetting
Categories []model.SkillCurationCategory `json:"categories"` Roots []model.SkillCurationRoot `json:"roots,omitempty"`
Assignments []model.SkillCurationAssignment `json:"assignments"` 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 // 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 { if !admin && !out.Enabled {
return out, nil 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") query := r.db.Order("sort_order, id")
if !admin { if !admin {
query = query.Where("enabled = ?", true) 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 { if err := query.Find(&out.Categories).Error; err != nil {
return nil, err 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.*"). 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") Joins("JOIN skills ON skills.id = skill_curation_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false")
if !admin { 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 { if err := query.Order("skill_id, category_id").Find(&out.Assignments).Error; err != nil {
return nil, err return nil, err
@@ -111,4 +140,4 @@ func (r *Repository) AssignSkillCuration(skillID string, ids []string) error {
return nil 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`

View File

@@ -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)`

View File

@@ -11,6 +11,7 @@ import (
) )
type SkillListFilter struct { type SkillListFilter struct {
PlatformRootID string
PlatformCategoryID string PlatformCategoryID string
PlatformUncategorized bool PlatformUncategorized bool
UserID string UserID string
@@ -79,6 +80,9 @@ func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error
if filter.Tag != "" { if filter.Tag != "" {
query = query.Where("skills.tag = ?", filter.Tag) query = query.Where("skills.tag = ?", filter.Tag)
} }
if filter.PlatformRootID != "" {
query = query.Where(effectiveCurationRoot+" = ?", filter.PlatformRootID)
}
if filter.PlatformCategoryID != "" { if filter.PlatformCategoryID != "" {
query = query.Where("EXISTS ("+effectiveCurationCategory+" AND c.id = ?)", 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 { if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", id).Error; err != nil {
return err 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 { if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil {
return err return err
} }

View File

@@ -10,10 +10,12 @@ import (
type SkillCuration = repository.SkillCuration type SkillCuration = repository.SkillCuration
type SkillCurationAssignmentInput struct { type SkillCurationAssignmentInput struct {
RootID *string `json:"rootId,omitempty"`
SkillID string `json:"skillId"` SkillID string `json:"skillId"`
CategoryIDs []string `json:"categoryIds"` CategoryIDs []string `json:"categoryIds"`
} }
type SkillCurationUpdate struct { type SkillCurationUpdate struct {
Root *model.SkillCurationRoot `json:"root,omitempty"`
ExpectedRevision *int64 `json:"expectedRevision"` ExpectedRevision *int64 `json:"expectedRevision"`
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
Category *model.SkillCurationCategory `json:"category,omitempty"` Category *model.SkillCurationCategory `json:"category,omitempty"`
@@ -46,6 +48,9 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
return nil, err return nil, err
} }
count := 0 count := 0
if req.Root != nil {
count++
}
if req.Enabled != nil { if req.Enabled != nil {
count++ count++
} }
@@ -66,11 +71,14 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
if err != nil { if err != nil {
return err return err
} }
if req.Root != nil {
return saveCurationRoot(repo, state, *req.Root)
}
if req.Category != nil { if req.Category != nil {
c := *req.Category c := *req.Category
c.Name = strings.Join(strings.Fields(c.Name), " ") c.Name = strings.Join(strings.Fields(c.Name), " ")
c.NormalizedName = strings.ToLower(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("分类名称或一级分类无效") return kernel.BadAuthRequest("分类名称或一级分类无效")
} }
exists := c.ID == "" exists := c.ID == ""
@@ -101,11 +109,24 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
if err != nil { if err != nil {
return kernel.NotFound("公开技能不存在") 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{} seen := map[string]bool{}
for _, id := range a.CategoryIDs { for _, id := range a.CategoryIDs {
valid := false valid := false
for _, c := range state.Categories { 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 valid = true
} }
} }
@@ -114,6 +135,11 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
} }
seen[id] = true 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) return repo.AssignSkillCuration(a.SkillID, a.CategoryIDs)
}) })
if err != nil { if err != nil {

View File

@@ -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("一级分类已停用或不存在,请重新选择")
}

View File

@@ -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 = &current.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")
}
}

View File

@@ -17,6 +17,9 @@ func TestSkillCurationManagementAvailable(t *testing.T) {
func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) { func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) {
t.Helper() t.Helper()
svc, db := newSkillLibraryCategoryTestService(t) 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 { if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -108,6 +108,7 @@ type SkillCategory struct {
} }
type SkillListRequest struct { type SkillListRequest struct {
PlatformRootID string
PlatformCategoryID string PlatformCategoryID string
PlatformUncategorized bool PlatformUncategorized bool
Page int Page int
@@ -147,6 +148,10 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
rootID, err := s.curationRootFilter(req.PlatformRootID)
if err != nil {
return nil, err
}
if req.LibraryCategoryID != "" && req.LibraryUncategorized { if req.LibraryCategoryID != "" && req.LibraryUncategorized {
return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用") 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{ rows, total, err := s.repo.Skills(repository.SkillListFilter{
PlatformRootID: rootID,
PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized, PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized,
UserID: userID, UserID: userID,
Scope: req.Scope, Scope: req.Scope,

View File

@@ -301,3 +301,5 @@ Schema 32 为 `channel_models` 新增 `tags`(TEXT、NOT NULL、默认 `'[]'`
## 可选技能策展 ## 可选技能策展
迁移 46 新增 skill_curation_settings、skill_curation_categories、skill_curation_assignments;默认关闭,不导入技能内容。表结构与事务规则见 [平台技能策展](./skill-curation)。 迁移 46 新增 skill_curation_settings、skill_curation_categories、skill_curation_assignments;默认关闭,不导入技能内容。表结构与事务规则见 [平台技能策展](./skill-curation)。
迁移 47 依赖 46,新增 skill_curation_roots 与 skill_curation_root_assignments;保留原五个根 ID,根停用时使用派生默认桶,根表为空回落固定五类。

View File

@@ -163,3 +163,5 @@ HTTP 请求 -> handler -> service(稳定导入面 / 别名)-> app(组合
## 技能策展 ## 技能策展
`internal/skills/skill_curation.go` 负责校验,`internal/repository/skill_curation.go` 负责分类持久化与 CAS 审计交易,handler/app bridge 接入原有技能域。 `internal/skills/skill_curation.go` 负责校验,`internal/repository/skill_curation.go` 负责分类持久化与 CAS 审计交易,handler/app bridge 接入原有技能域。
`skill_curation_roots.go` 扩展动态根维护、有效根查询和空表回落;数据库迁移 47 接续 46。

View File

@@ -393,3 +393,5 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两
## 平台技能策展 ## 平台技能策展
新增登录读取 `/api/skills/curation` 与管理员 GET/PUT `/api/admin/skill-curation`;原技能列表仅在策展开启时应用新增分类筛选,响应结构不变。见 [接口与行为](./skill-curation)。 新增登录读取 `/api/skills/curation` 与管理员 GET/PUT `/api/admin/skill-curation`;原技能列表仅在策展开启时应用新增分类筛选,响应结构不变。见 [接口与行为](./skill-curation)。
一级分类扩展:管理写入增加 root 命令,assignment.rootId 与子分类完整集合一起保存;列表增加 platformRootId 筛选。关闭模式继续忽略新增筛选。

View File

@@ -1,6 +1,6 @@
--- ---
title: 平台技能策展 title: 平台技能策展
description: 可选的后台子分类维护 description: 可选的后台一级分类和子分类维护
--- ---
平台策展模式在“后台 → 技能分类”中开启,默认关闭。关闭时原有 `/skills` JSON、固定五类、个人技能库分类保持不变,新增筛选参数被忽略。分类设置可以在开启前准备。 平台策展模式在“后台 → 技能分类”中开启,默认关闭。关闭时原有 `/skills` JSON、固定五类、个人技能库分类保持不变,新增筛选参数被忽略。分类设置可以在开启前准备。
@@ -31,3 +31,13 @@ description: 可选的后台子分类维护
## 待验证 ## 待验证
SQLite 及 HTTP recorder 可在无网络环境验证。PostgreSQL、前端构建、浏览器中的明暗主题、键盘操作和分页交互需要单独验证。 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 仍不变。

View File

@@ -452,3 +452,5 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
## 平台技能策展 ## 平台技能策展
后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。 后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。
开启时一级分类也可在后台新增、改名、排序、停用和选择图示,未归入技能仍有默认入口。

View File

@@ -2919,3 +2919,5 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
## 平台技能策展 ## 平台技能策展
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。 SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。
一级分类还需 PostgreSQL 验证 46→47 与并发改归/删除,以及浏览器图示、根停用默认桶与空根表回落。handler 全套涉及 socket,受限环境中未完成。

View File

@@ -5,7 +5,7 @@ import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react
import { AppModal } from "@/components/ui/product/app-modal"; import { AppModal } from "@/components/ui/product/app-modal";
import type { CanvasTheme } from "@/lib/canvas-theme"; import type { CanvasTheme } from "@/lib/canvas-theme";
import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills"; 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"; type SkillLibraryTab = "enabled" | "installed" | "market";
@@ -135,6 +135,7 @@ export function CanvasAgentSkillLibraryModal({
setTab(value); setTab(value);
setLibraryCategoryId("all"); setLibraryCategoryId("all");
onCategoryChange("all"); onCategoryChange("all");
onPlatformCategoryChange("");
}; };
const selectMarketplaceCategory = (value: string) => { const selectMarketplaceCategory = (value: string) => {
@@ -206,7 +207,7 @@ export function CanvasAgentSkillLibraryModal({
<div className="canvas-agent-skill-library-category-group"> <div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">技能广场分类</div> <div className="canvas-agent-skill-library-category-heading">技能广场分类</div>
<SkillTab active={tab === "market" && category === "all"} label="全部技能" count={marketplaceCategoryTotal} onClick={() => changeTab("market")} /> <SkillTab active={tab === "market" && category === "all"} label="全部技能" count={marketplaceCategoryTotal} onClick={() => changeTab("market")} />
{categoryItems.map((item) => ( {!curationState.curation?.enabled && categoryItems.map((item) => (
<SkillTab <SkillTab
key={item.value} key={item.value}
active={tab === "market" && category === item.value} active={tab === "market" && category === item.value}
@@ -232,6 +233,7 @@ export function CanvasAgentSkillLibraryModal({
<SkillLibraryCard <SkillLibraryCard
key={skill.skillId} key={skill.skillId}
skill={skill} skill={skill}
rootLabel={curationState.curation?.enabled ? curationState.curation.roots?.find((root) => root.id === effectiveCurationRoot(skill, curationState.curation!))?.name : undefined}
theme={theme} theme={theme}
categories={categories} categories={categories}
selected={selectedSkillIds.includes(skill.skillId)} 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; skill: Skill;
rootLabel?: string;
theme: CanvasTheme; theme: CanvasTheme;
categories: SkillCategory[]; categories: SkillCategory[];
selected: boolean; selected: boolean;
@@ -292,7 +295,7 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
|| skill.showcaseMedia?.find((item) => item.showcaseUrl); || skill.showcaseMedia?.find((item) => item.showcaseUrl);
const coverUrl = cover?.showcaseUrl; const coverUrl = cover?.showcaseUrl;
const hasCover = Boolean(coverUrl && !coverFailed); 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 author = skill.effectiveUser?.name || "影策创作者";
const addedCount = formatSkillCount(skill.addedCount || 0); const addedCount = formatSkillCount(skill.addedCount || 0);

View File

@@ -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 skillBrowseKey = JSON.stringify([userId, skillTag, debouncedSkillSearch, platformCategory, curationState.curation?.revision, curationState.curation?.enabled]);
const skillBrowseKeyRef = useRef(skillBrowseKey); const skillBrowseKeyRef = useRef(skillBrowseKey);
skillBrowseKeyRef.current = skillBrowseKey; skillBrowseKeyRef.current = skillBrowseKey;
useEffect(() => { if (curationState.curation?.enabled) setSkillTag("all"); }, [curationState.curation?.enabled]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [approvalSubmitting, setApprovalSubmitting] = useState(false); const [approvalSubmitting, setApprovalSubmitting] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);

View File

@@ -3,10 +3,24 @@ import { Alert, Button, Select, Space } from "antd";
import { getActiveUserScope } from "@/lib/user-scope"; import { getActiveUserScope } from "@/lib/user-scope";
import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation"; import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation";
import type { Skill } from "@/services/api/skills"; 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<string, string> = { 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<string, typeof Shapes>)[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) { export function curationQuery(data: SkillCuration | null, value: string) {
if (!data?.enabled || !value) return {}; if (!data?.enabled || !value) return {};
if (value.startsWith("root:")) return { platformRootId: value.slice(5) };
return value === "__uncategorized__" ? { platformUncategorized: true } : { platformCategoryId: value }; 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) { export function matchesCuration(skill: Skill, data: SkillCuration | null, category: string) {
if (!data?.enabled || !category) return true; 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); const ids = data.assignments.filter((item) => item.skillId === skill.skillId).map((item) => item.categoryId);
return category === "__uncategorized__" ? ids.length === 0 : ids.includes(category); 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 }) { export function SkillCurationBrowser({ data, value, onChange, error, retry }: { data: SkillCuration | null; value: string; onChange: (value: string) => void; error: string; retry: () => void }) {
useEffect(() => { 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]); }, [data, value, onChange]);
if (error) return <Alert type="warning" title={error} action={<Button onClick={retry}>重试</Button>} />; if (error) return <Alert type="warning" title={error} action={<Button onClick={retry}>重试</Button>} />;
if (!data?.enabled) return null; if (!data?.enabled) return null;
return <Space wrap><span>平台分类</span><Select aria-label="平台子分类" value={value} onChange={onChange} popupMatchSelectWidth={false} options={[ return <Space wrap><span>平台分类</span><Select aria-label="平台分类" value={value} onChange={onChange} popupMatchSelectWidth={false} options={[
{ value: "", label: "全部子分类" }, { value: "__uncategorized__", label: "未细分" }, { value: "", label: "全部分类" }, { value: "__uncategorized__", label: "未细分" },
...data.categories.map((item) => ({ value: item.id, label: `${fallbackSkillCategories.find((root) => root.value === item.rootTag)?.label || item.rootTag} / ${item.name}` })), ...(data.roots || []).map((root) => { const Icon = curationIcon(root.iconKey); return { value: `root:${root.id}`, label: <Space><Icon size={16} />{root.name}</Space> }; }),
...data.categories.map((item) => ({ value: item.id, label: `${data.roots?.find((root) => root.id === item.rootTag)?.name || "分类"} / ${item.name}` })),
]} /></Space>; ]} /></Space>;
} }

View File

@@ -3,8 +3,8 @@ import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
import { AdminPageFrame } from "./components/admin-shell"; import { AdminPageFrame } from "./components/admin-shell";
import { AdminDataTable } from "./components/admin-ui"; import { AdminDataTable } from "./components/admin-ui";
import { AdminSwitch } from "./ui/controls"; import { AdminSwitch } from "./ui/controls";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog"; import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration, type CurationRoot } from "@/services/api/skill-curation";
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration } from "@/services/api/skill-curation"; import { curationIcon, curationIconKeys, curationIconLabels } from "@/components/skills/skill-curation-browser";
import { listSkills, type Skill } from "@/services/api/skills"; import { listSkills, type Skill } from "@/services/api/skills";
export default function SkillCurationPage() { export default function SkillCurationPage() {
@@ -17,6 +17,8 @@ export default function SkillCurationPage() {
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
const [skillId, setSkillId] = useState(""); const [skillId, setSkillId] = useState("");
const [categoryIds, setCategoryIds] = useState<string[]>([]); const [categoryIds, setCategoryIds] = useState<string[]>([]);
const [root, setRoot] = useState<CurationRoot>({ 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)); } }; const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } };
useEffect(() => { void reload(); }, []); useEffect(() => { void reload(); }, []);
useEffect(() => { useEffect(() => {
@@ -36,12 +38,25 @@ export default function SkillCurationPage() {
<Space orientation="vertical" className="w-full" size="large"> <Space orientation="vertical" className="w-full" size="large">
{error && <Alert type="error" title={error} />} {error && <Alert type="error" title={error} />}
<Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space> <Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space>
<AdminDataTable<CurationRoot> table={{ rowKey: "id", dataSource: data?.roots || [], pagination: false, loading: !data, columns: [
{ title: "一级分类", dataIndex: "name" }, { title: "图示", render: (_, item) => { const Icon = curationIcon(item.iconKey); return <Icon size={18} />; } },
{ title: "排序", dataIndex: "sortOrder" }, { title: "状态", render: (_, item) => item.enabled ? "启用" : "停用" },
{ title: "操作", render: (_, item) => <Button onClick={() => setRoot({ ...item })}>编辑</Button> },
] }} />
<Space wrap>
<Input aria-label="一级分类名称" placeholder="一级分类名称" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
<Select aria-label="一级分类图示" value={root.iconKey || "shapes"} options={curationIconKeys.map((key) => { const Icon = curationIcon(key); return { value: key, label: <Space><Icon size={16} />{curationIconLabels[key]}</Space> }; })} onChange={(iconKey) => setRoot({ ...root, iconKey })} />
<InputNumber aria-label="一级分类排序" precision={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
<Button type="primary" loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>{root.id ? "保存一级分类" : "新增一级分类"}</Button>
<Button onClick={() => setRoot({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true })}>新建一级分类</Button>
</Space>
<AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [ <AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [
{ title: "一级分类", dataIndex: "rootTag" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" }, { title: "一级分类", render: (_, row) => data?.roots?.find((item) => item.id === row.rootTag)?.name || "未归入启用分类" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" },
{ title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> }, { title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> },
] }} /> ] }} />
<Space wrap> <Space wrap>
<Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={fallbackSkillCategories} fieldNames={{ value: "value", label: "label" }} onChange={(rootTag) => setCategory({ ...category, rootTag })} /> <Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={data?.roots?.map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))} onChange={(rootTag) => setCategory({ ...category, rootTag })} />
<Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} /> <Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
<InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} /> <InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} /> <AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
@@ -50,9 +65,10 @@ export default function SkillCurationPage() {
</Space> </Space>
<Space wrap> <Space wrap>
<Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} /> <Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} />
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} /> <Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setAssignedRoot(data?.rootAssignments?.find((item) => item.skillId === id)?.rootId || ""); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === selectedSkill?.tag).map((item) => ({ value: item.id, label: item.name }))} /> <Select aria-label="技能一级分类" value={assignedRoot} options={[{ value: "", label: "按原始分类" }, ...(data?.roots || []).map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))]} onChange={(id) => { setAssignedRoot(id); setCategoryIds([]); }} />
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds } })}>保存技能归类</Button> <Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === (assignedRoot || selectedSkill?.tag)).map((item) => ({ value: item.id, label: item.name }))} />
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>保存技能归类</Button>
</Space> </Space>
</Space> </Space>
</AdminPageFrame>; </AdminPageFrame>;

View File

@@ -41,7 +41,7 @@ const sortOptions: { label: string; value: SkillSort }[] = [
{ label: "最近更新", value: "updated" }, { label: "最近更新", value: "updated" },
]; ];
import { SkillCurationBrowser, useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser"; import { SkillCurationBrowser, useSkillCuration, curationQuery, groupCuratedSkills, curationIcon, effectiveCurationRoot } from "@/components/skills/skill-curation-browser";
export default function SkillsPage() { export default function SkillsPage() {
const { curation, error: curationError, retry: retryCuration } = useSkillCuration(); const { curation, error: curationError, retry: retryCuration } = useSkillCuration();
@@ -52,6 +52,7 @@ export default function SkillsPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250); const debouncedSearch = useDebouncedValue(search, 250);
const [tag, setTag] = useState("all"); const [tag, setTag] = useState("all");
useEffect(() => { if (curation?.enabled) setTag("all"); }, [curation?.enabled]);
const [libraryCategoryId, setLibraryCategoryId] = useState("all"); const [libraryCategoryId, setLibraryCategoryId] = useState("all");
const [libraryCategoryList, setLibraryCategoryList] = useState<SkillLibraryCategoryList | null>(null); const [libraryCategoryList, setLibraryCategoryList] = useState<SkillLibraryCategoryList | null>(null);
const [libraryCategoryError, setLibraryCategoryError] = useState(""); const [libraryCategoryError, setLibraryCategoryError] = useState("");
@@ -146,7 +147,8 @@ export default function SkillsPage() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [libraryCategoryScope, reloadKey]); }, [libraryCategoryScope, reloadKey]);
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]); const groupedSkills = useMemo(() => curation?.enabled ? groupCuratedSkills(skills, curation) : groupSkills(skills, categories), [categories, skills, curation]);
const browseCategories: SkillCategory[] = curation?.enabled ? (curation.roots || []).map((root) => ({ value: root.id, label: root.name })) : categories;
const filtersActive = Boolean(search || (!isLibraryScope && tag !== "all") || (isLibraryScope && libraryCategoryId !== "all") || sort !== "popular"); const filtersActive = Boolean(search || (!isLibraryScope && tag !== "all") || (isLibraryScope && libraryCategoryId !== "all") || sort !== "popular");
const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setLibraryCategoryId("all"); setSort("popular"); setPage(1); }, []); const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setLibraryCategoryId("all"); setSort("popular"); setPage(1); }, []);
@@ -315,6 +317,7 @@ export default function SkillsPage() {
<SkillCard <SkillCard
key={skill.skillId} key={skill.skillId}
skill={skill} skill={skill}
rootLabel={curation?.enabled ? curation.roots?.find((root) => root.id === effectiveCurationRoot(skill, curation))?.name : undefined}
categories={categories} categories={categories}
libraryCategories={libraryCategories} libraryCategories={libraryCategories}
canCategorize={isLibraryScope} canCategorize={isLibraryScope}
@@ -336,7 +339,7 @@ export default function SkillsPage() {
) : !isLibraryScope && groupedSkills.length ? ( ) : !isLibraryScope && groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel"> <div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => { {groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value); const GroupIcon = curation?.enabled ? curationIcon(curation.roots?.find((root) => root.id === group.value)?.iconKey) : categoryIconOf(group.value);
return ( return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}> <section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading"> <div className="skill-section-heading">
@@ -425,15 +428,15 @@ export default function SkillsPage() {
</div> </div>
</div> </div>
<nav className="skills-library-category-list" aria-label="技能广场分类"> <nav className="skills-library-category-list" aria-label="技能广场分类">
<button type="button" className={`skills-library-category-item${scope === "public" && tag === "all" ? " is-active" : ""}`} aria-pressed={scope === "public" && tag === "all"} onClick={() => selectMarketplaceCategory("all")}> <button type="button" className={`skills-library-category-item${scope === "public" && tag === "all" && (!curation?.enabled || !platformCategory) ? " is-active" : ""}`} aria-pressed={scope === "public" && tag === "all" && (!curation?.enabled || !platformCategory)} onClick={() => { setPlatformCategory(""); selectMarketplaceCategory("all"); }}>
<span><Sparkles className="size-4" aria-hidden="true" />全部技能</span> <span><Sparkles className="size-4" aria-hidden="true" />全部技能</span>
<span>{marketplaceCategoryTotal}</span> <span>{marketplaceCategoryTotal}</span>
</button> </button>
{categories.map((category) => { {browseCategories.map((category) => {
const Icon = categoryIconOf(category.value); const Icon = curation?.enabled ? curationIcon(curation.roots?.find((root) => root.id === category.value)?.iconKey) : categoryIconOf(category.value);
const active = scope === "public" && tag === category.value; const active = scope === "public" && (curation?.enabled ? platformCategory === `root:${category.value}` : tag === category.value);
return ( return (
<button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => selectMarketplaceCategory(category.value)}> <button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => { if (curation?.enabled) { setPlatformCategory(`root:${category.value}`); selectMarketplaceCategory("all"); } else selectMarketplaceCategory(category.value); }}>
<span><Icon className="size-4" aria-hidden="true" />{category.label}</span> <span><Icon className="size-4" aria-hidden="true" />{category.label}</span>
<span>{category.count ?? "—"}</span> <span>{category.count ?? "—"}</span>
</button> </button>
@@ -541,7 +544,7 @@ export default function SkillsPage() {
); );
} }
function SkillCard({ skill, categories, libraryCategories, canCategorize, loading, style, onOpen, onAdd, onLike, onEdit, onDelete, onSetLibraryCategory }: { skill: Skill; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; canCategorize: boolean; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void; onSetLibraryCategory: (categoryId: string) => void }) { function SkillCard({ skill, rootLabel, categories, libraryCategories, canCategorize, loading, style, onOpen, onAdd, onLike, onEdit, onDelete, onSetLibraryCategory }: { skill: Skill; rootLabel?: string; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; canCategorize: boolean; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void; onSetLibraryCategory: (categoryId: string) => void }) {
const CategoryIcon = categoryIconOf(skill.tag); const CategoryIcon = categoryIconOf(skill.tag);
const currentLibraryCategory = libraryCategories.find((category) => category.id === skill.libraryCategoryId); const currentLibraryCategory = libraryCategories.find((category) => category.id === skill.libraryCategoryId);
const libraryCategoryLabel = currentLibraryCategory?.name || (skill.libraryCategoryId ? "已归类" : "未分类"); const libraryCategoryLabel = currentLibraryCategory?.name || (skill.libraryCategoryId ? "已归类" : "未分类");
@@ -597,7 +600,7 @@ function SkillCard({ skill, categories, libraryCategories, canCategorize, loadin
<span>{formatSkillCount(skill.likeCount)}</span> <span>{formatSkillCount(skill.likeCount)}</span>
</button> </button>
<span className="skill-card-author">{skill.effectiveUser.name || "未知用户"}</span> <span className="skill-card-author">{skill.effectiveUser.name || "未知用户"}</span>
<span className="skill-card-tag">{skillCategoryLabel(skill.tag, categories)}</span> <span className="skill-card-tag">{rootLabel || skillCategoryLabel(skill.tag, categories)}</span>
{skill.isPrivate ? <span className="skill-card-flag">仅自己</span> : null} {skill.isPrivate ? <span className="skill-card-flag">仅自己</span> : null}
</div> </div>
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */} {/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}

View File

@@ -1,8 +1,9 @@
import { http } from "./request"; import { http } from "./request";
export type CurationCategory = { id: string; rootTag: string; name: string; sortOrder: number; enabled: boolean }; export type CurationCategory = { id: string; rootTag: string; name: string; sortOrder: number; enabled: boolean };
export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[] }; export type CurationRoot = { id: string; name: string; iconKey: string; sortOrder: number; enabled: boolean };
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { assignment: { skillId: string; categoryIds: string[] } }; export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[]; roots?: CurationRoot[]; rootAssignments?: { skillId: string; rootId: string }[] };
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { root: CurationRoot } | { assignment: { skillId: string; categoryIds: string[]; rootId?: string } };
export function getSkillCuration(admin = false) { return http.get<SkillCuration>(admin ? "/admin/skill-curation" : "/skills/curation"); } export function getSkillCuration(admin = false) { return http.get<SkillCuration>(admin ? "/admin/skill-curation" : "/skills/curation"); }
export function updateSkillCuration(expectedRevision: number, change: CurationChange) { export function updateSkillCuration(expectedRevision: number, change: CurationChange) {
return http.put<SkillCuration>("/admin/skill-curation", { expectedRevision, ...change }); return http.put<SkillCuration>("/admin/skill-curation", { expectedRevision, ...change });

View File

@@ -109,6 +109,7 @@ export type SkillList = {
}; };
export type ListSkillsInput = { export type ListSkillsInput = {
platformRootId?: string;
platformCategoryId?: string; platformCategoryId?: string;
platformUncategorized?: boolean; platformUncategorized?: boolean;
page?: number; page?: number;

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { curationQuery, matchesCuration } from "../src/components/skills/skill-curation-browser"; import { curationQuery, matchesCuration, effectiveCurationRoot, groupCuratedSkills, curationIcon } from "../src/components/skills/skill-curation-browser";
import type { SkillCuration } from "../src/services/api/skill-curation"; import type { SkillCuration } from "../src/services/api/skill-curation";
import type { Skill } from "../src/services/api/skills"; import type { Skill } from "../src/services/api/skills";
@@ -17,4 +17,14 @@ describe("platform curation", () => {
expect(matchesCuration(skill, enabled, "__uncategorized__")).toBe(false); expect(matchesCuration(skill, enabled, "__uncategorized__")).toBe(false);
expect(matchesCuration({ ...skill, skillId: "two" }, enabled, "__uncategorized__")).toBe(true); expect(matchesCuration({ ...skill, skillId: "two" }, enabled, "__uncategorized__")).toBe(true);
}); });
test("dynamic roots use overrides, default bucket, icons and disabled gate", () => {
const enabled: SkillCuration = { ...data, enabled: true, roots: [{ id: "custom", name: "Custom", iconKey: "landmark", enabled: true, sortOrder: 0 }, { id: "__unassigned__", name: "未归入启用分类", iconKey: "shapes", enabled: true, sortOrder: 1 }], rootAssignments: [{ skillId: skill.skillId, rootId: "custom" }] };
expect(effectiveCurationRoot(skill, enabled)).toBe("custom");
expect(curationQuery(enabled, "root:custom")).toEqual({ platformRootId: "custom" });
expect(curationQuery({ ...enabled, enabled: false }, "root:custom")).toEqual({});
expect(matchesCuration(skill, enabled, "root:custom")).toBe(true);
expect(effectiveCurationRoot({ ...skill, isPrivate: true }, enabled)).toBe("__unassigned__");
expect(groupCuratedSkills([skill], enabled)[0].label).toBe("Custom");
expect(curationIcon("unknown")).toBe(curationIcon("shapes"));
});
}); });