feat(skills): 技能管理 - 支持后台维护一级分类 (#694)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
15
backend/internal/database/skill_curation_roots.go
Normal file
15
backend/internal/database/skill_curation_roots.go
Normal 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
|
||||||
|
}
|
||||||
53
backend/internal/database/skill_curation_roots_test.go
Normal file
53
backend/internal/database/skill_curation_roots_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
27
backend/internal/model/skill_curation_roots.go
Normal file
27
backend/internal/model/skill_curation_roots.go
Normal 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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
65
backend/internal/repository/skill_curation_roots.go
Normal file
65
backend/internal/repository/skill_curation_roots.go
Normal 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)`
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
67
backend/internal/skills/skill_curation_roots.go
Normal file
67
backend/internal/skills/skill_curation_roots.go
Normal 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("一级分类已停用或不存在,请重新选择")
|
||||||
|
}
|
||||||
164
backend/internal/skills/skill_curation_roots_test.go
Normal file
164
backend/internal/skills/skill_curation_roots_test.go
Normal 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 = ¤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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,根停用时使用派生默认桶,根表为空回落固定五类。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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 筛选。关闭模式继续忽略新增筛选。
|
||||||
|
|||||||
@@ -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 仍不变。
|
||||||
|
|||||||
@@ -452,3 +452,5 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
|
|||||||
## 平台技能策展
|
## 平台技能策展
|
||||||
|
|
||||||
后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。
|
后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。
|
||||||
|
|
||||||
|
开启时一级分类也可在后台新增、改名、排序、停用和选择图示,未归入技能仍有默认入口。
|
||||||
|
|||||||
@@ -2919,3 +2919,5 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
|
|||||||
## 平台技能策展
|
## 平台技能策展
|
||||||
|
|
||||||
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。
|
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。
|
||||||
|
|
||||||
|
一级分类还需 PostgreSQL 验证 46→47 与并发改归/删除,以及浏览器图示、根停用默认桶与空根表回落。handler 全套涉及 socket,受限环境中未完成。
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}
|
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user