feat(*): 技能库与认证体验 - 重构共享技能库并优化登录页面与默认创作入口
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 重写技能库为后端持久化的用户共享能力,支持创建、公开、加入、收藏和画布引用,并初始化 33 个内置公开技能,不再依赖 Updream 远程社区。
|
||||
|
||||
## v1.0.23
|
||||
|
||||
- 修复视频 Composer 丢失已配置首尾帧的问题,并按 `POST /v1/video/generations` 协议顺序传递首帧、尾帧和普通参考图。
|
||||
|
||||
@@ -51,6 +51,9 @@ func main() {
|
||||
if err := svc.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := svc.EnsureBuiltinSkills(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if summary, err := svc.MigrateLegacyStorage(); err != nil {
|
||||
log.Printf("storage migration skipped after error: %v", err)
|
||||
} else if summary.Backup != "" {
|
||||
|
||||
@@ -28,6 +28,7 @@ func Models() []any {
|
||||
&model.SystemSetting{},
|
||||
&model.UserOSSSetting{},
|
||||
&model.UserDailyUploadUsage{},
|
||||
&model.Skill{},
|
||||
&model.UserSkillState{},
|
||||
&model.Resource{},
|
||||
&model.Asset{},
|
||||
@@ -61,6 +62,12 @@ func Models() []any {
|
||||
}
|
||||
|
||||
func MigrateSchema(db *gorm.DB) error {
|
||||
// 旧表只保存 Updream 目录状态,与本地技能主键没有可迁移关系;首次升级时按产品要求清空重建。
|
||||
if db.Migrator().HasColumn(&model.UserSkillState{}, "skill_dir") && !db.Migrator().HasColumn(&model.UserSkillState{}, "skill_id") {
|
||||
if err := db.Migrator().DropTable(&model.UserSkillState{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(Models()...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
@@ -16,28 +9,17 @@ import (
|
||||
)
|
||||
|
||||
func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/skills/image", proxySkillImage)
|
||||
r.GET("/skills/capabilities", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"capabilities": svc.SkillIntegrationCapabilities()})
|
||||
})
|
||||
r.GET("/skills/community", func(c *gin.Context) {
|
||||
r.GET("/skills", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "12"))
|
||||
result, err := svc.CommunitySkills(c.Request.Context(), user.ID, service.CommunitySkillsRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Sort: c.DefaultQuery("sort", "hot"),
|
||||
Search: c.Query("search"),
|
||||
Categories: c.QueryArray("categories"),
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
result, err := svc.Skills(user.ID, service.SkillListRequest{
|
||||
Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"),
|
||||
Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"),
|
||||
})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -45,158 +27,120 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.GET("/skills/activated", func(c *gin.Context) {
|
||||
|
||||
r.GET("/skills/added", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skills, err := svc.ActivatedSkills(c.Request.Context(), user.ID)
|
||||
skills, err := svc.AddedSkills(user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skills": skills})
|
||||
})
|
||||
r.GET("/skills/favorites", func(c *gin.Context) {
|
||||
|
||||
r.GET("/skills/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skills, err := svc.FavoriteSkills(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skills": skills})
|
||||
})
|
||||
r.GET("/skills/community/:dir", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.CommunitySkillDetail(c.Request.Context(), user.ID, c.Param("dir"))
|
||||
skill, err := svc.SkillDetail(user.ID, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
})
|
||||
r.POST("/skills/:dir/activate", func(c *gin.Context) {
|
||||
|
||||
r.POST("/skills", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), true)
|
||||
var req service.SkillMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
failService(c, service.BadAuthRequest("技能数据格式无效"))
|
||||
return
|
||||
}
|
||||
skill, err := svc.CreateSkill(user.ID, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
})
|
||||
r.DELETE("/skills/:dir/activate", func(c *gin.Context) {
|
||||
|
||||
r.PUT("/skills/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), false)
|
||||
var req service.SkillMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
failService(c, service.BadAuthRequest("技能数据格式无效"))
|
||||
return
|
||||
}
|
||||
skill, err := svc.UpdateSkill(user.ID, c.Param("id"), req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
})
|
||||
r.POST("/skills/:dir/favorite", func(c *gin.Context) {
|
||||
|
||||
r.DELETE("/skills/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), true)
|
||||
if err != nil {
|
||||
if err := svc.DeleteSkill(user.ID, c.Param("id")); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
})
|
||||
r.DELETE("/skills/:dir/favorite", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), false)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
ok(c, gin.H{"deleted": true})
|
||||
})
|
||||
|
||||
r.POST("/skills/:id/add", setSkillAdded(svc, true))
|
||||
r.DELETE("/skills/:id/add", setSkillAdded(svc, false))
|
||||
r.POST("/skills/:id/like", setSkillLiked(svc, true))
|
||||
r.DELETE("/skills/:id/like", setSkillLiked(svc, false))
|
||||
}
|
||||
|
||||
func proxySkillImage(c *gin.Context) {
|
||||
rawURL := strings.TrimSpace(c.Query("url"))
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil || !allowedSkillImageURL(target) {
|
||||
fail(c, http.StatusBadRequest, errInvalidSkillImageURL())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Referer", "https://www.updream.cn/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 InfiniteCanvas/skills-image-proxy")
|
||||
client := service.OutboundHTTPClient(15 * time.Second)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 || !allowedSkillImageURL(req.URL) {
|
||||
return errInvalidSkillImageURL()
|
||||
func setSkillAdded(svc *service.Service, added bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
skill, err := svc.SetSkillAdded(user.ID, c.Param("id"), added)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
fail(c, http.StatusBadGateway, errSkillImageUpstream(resp.StatusCode))
|
||||
return
|
||||
}
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800")
|
||||
if etag := resp.Header.Get("ETag"); etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
c.Header("Content-Type", contentType)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func allowedSkillImageURL(target *url.URL) bool {
|
||||
if target == nil || (target.Scheme != "https" && target.Scheme != "http") {
|
||||
return false
|
||||
func setSkillLiked(svc *service.Service, liked bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
skill, err := svc.SetSkillLiked(user.ID, c.Param("id"), liked)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"skill": skill})
|
||||
}
|
||||
host := strings.ToLower(target.Hostname())
|
||||
return host == "updream.cn" || strings.HasSuffix(host, ".updream.cn") || host == "hdslb.com" || strings.HasSuffix(host, ".hdslb.com")
|
||||
}
|
||||
|
||||
func errInvalidSkillImageURL() error {
|
||||
return errors.New("不支持的技能图片地址")
|
||||
}
|
||||
|
||||
func errSkillImageUpstream(status int) error {
|
||||
return errors.New("技能图片读取失败: " + strconv.Itoa(status))
|
||||
}
|
||||
|
||||
@@ -425,11 +425,33 @@ type UserDailyUploadUsage struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Skill struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
OwnerID string `json:"ownerId" gorm:"index;size:36"`
|
||||
AuthorName string `json:"authorName" gorm:"size:120;index"`
|
||||
AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"`
|
||||
Name string `json:"name" gorm:"size:80;index"`
|
||||
Description string `json:"description" gorm:"size:500"`
|
||||
Instruction string `json:"instruction" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"index"`
|
||||
Source int `json:"source" gorm:"index"`
|
||||
Tag string `json:"tag" gorm:"size:32;index"`
|
||||
SortWeight int `json:"sortWeight" gorm:"index"`
|
||||
IsPrivate bool `json:"isPrivate" gorm:"index"`
|
||||
MarkdownURL string `json:"markdownUrl" gorm:"size:500"`
|
||||
ShowcaseMediaJSON string `json:"-" gorm:"type:text"`
|
||||
ExtraInfo string `json:"extraInfo" gorm:"type:text"`
|
||||
InitialLikeCount int64 `json:"initialLikeCount"`
|
||||
InitialAddedCount int64 `json:"initialAddedCount"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"index"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
|
||||
}
|
||||
|
||||
type UserSkillState struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_dir,priority:1"`
|
||||
SkillDir string `json:"skillDir" gorm:"size:180;index;uniqueIndex:idx_user_skill_state_user_dir,priority:2"`
|
||||
Activated bool `json:"activated" gorm:"index"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"`
|
||||
SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"`
|
||||
Added bool `json:"added" gorm:"index"`
|
||||
Liked bool `json:"liked" gorm:"index"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
@@ -1467,29 +1467,3 @@ func (r *Repository) SaveStoryboardPromptTemplate(template *model.StoryboardProm
|
||||
func (r *Repository) DeleteStoryboardPromptTemplate(id string) error {
|
||||
return r.db.Delete(&model.StoryboardPromptTemplate{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UserSkillState(userID string, skillDir string) (*model.UserSkillState, error) {
|
||||
var state model.UserSkillState
|
||||
if err := r.db.First(&state, "user_id = ? AND skill_dir = ?", userID, skillDir).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UserSkillStates(userID string) ([]model.UserSkillState, error) {
|
||||
var states []model.UserSkillState
|
||||
err := r.db.Order("updated_at desc").Find(&states, "user_id = ?", userID).Error
|
||||
return states, err
|
||||
}
|
||||
|
||||
func (r *Repository) UserSkillStatesByDirs(userID string, skillDirs []string) ([]model.UserSkillState, error) {
|
||||
if len(skillDirs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var states []model.UserSkillState
|
||||
err := r.db.Find(&states, "user_id = ? AND skill_dir IN ?", userID, skillDirs).Error
|
||||
return states, err
|
||||
}
|
||||
|
||||
191
backend/internal/repository/skills.go
Normal file
191
backend/internal/repository/skills.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type SkillListFilter struct {
|
||||
UserID string
|
||||
Scope string
|
||||
Search string
|
||||
Tag string
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type SkillMetrics struct {
|
||||
SkillID string
|
||||
AddedCount int64
|
||||
LikeCount int64
|
||||
}
|
||||
|
||||
func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error) {
|
||||
query := r.db.Model(&model.Skill{}).Where("skills.status = ?", 1)
|
||||
switch filter.Scope {
|
||||
case "mine":
|
||||
query = query.Joins("LEFT JOIN user_skill_states ON user_skill_states.skill_id = skills.id AND user_skill_states.user_id = ?", filter.UserID).
|
||||
Where("skills.owner_id = ? OR (user_skill_states.added = ? AND skills.is_private = ?)", filter.UserID, true, false)
|
||||
case "created":
|
||||
query = query.Where("skills.owner_id = ?", filter.UserID)
|
||||
case "favorites":
|
||||
query = query.Joins("JOIN user_skill_states ON user_skill_states.skill_id = skills.id AND user_skill_states.user_id = ? AND user_skill_states.liked = ?", filter.UserID, true).
|
||||
Where("skills.owner_id = ? OR skills.is_private = ?", filter.UserID, false)
|
||||
default:
|
||||
query = query.Where("skills.is_private = ?", false)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
pattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||
query = query.Joins("LEFT JOIN users skill_owners ON skill_owners.id = skills.owner_id").
|
||||
Where("lower(skills.name) LIKE ? OR lower(skills.description) LIKE ? OR lower(skills.author_name) LIKE ? OR lower(skill_owners.display_name) LIKE ? OR lower(skill_owners.username) LIKE ?", pattern, pattern, pattern, pattern, pattern)
|
||||
}
|
||||
if filter.Tag != "" {
|
||||
query = query.Where("skills.tag = ?", filter.Tag)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Distinct("skills.id").Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := "skills.sort_weight desc, skills.updated_at desc"
|
||||
switch filter.Sort {
|
||||
case "new":
|
||||
order = "skills.created_at desc"
|
||||
case "popular":
|
||||
order = "(skills.initial_added_count + (SELECT COUNT(*) FROM user_skill_states metric_states WHERE metric_states.skill_id = skills.id AND metric_states.added = true)) desc, skills.updated_at desc"
|
||||
}
|
||||
var skills []model.Skill
|
||||
err := query.Select("skills.*").Distinct().Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&skills).Error
|
||||
return skills, total, err
|
||||
}
|
||||
|
||||
// 内置技能使用稳定的外部技能 ID 幂等更新,用户加入和收藏关系保留在独立状态表中。
|
||||
func (r *Repository) UpsertBuiltinSkills(skills []model.Skill) error {
|
||||
if len(skills) == 0 {
|
||||
return errors.New("builtin skills are empty")
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_id", "author_name", "author_avatar_url", "name", "description", "instruction", "status", "source", "tag", "sort_weight", "is_private",
|
||||
"markdown_url", "showcase_media_json", "extra_info", "initial_like_count", "initial_added_count", "created_at", "updated_at",
|
||||
}),
|
||||
}).Create(&skills).Error
|
||||
}
|
||||
|
||||
func (r *Repository) Skill(id string) (*model.Skill, error) {
|
||||
var skill model.Skill
|
||||
if err := r.db.First(&skill, "id = ? AND status = ?", id, 1).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &skill, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSkill(skill *model.Skill, ownerState *model.UserSkillState) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(skill).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(ownerState).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteSkill(id string) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.Skill{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) UserSkillState(userID string, skillID string) (*model.UserSkillState, error) {
|
||||
var state model.UserSkillState
|
||||
if err := r.db.First(&state, "user_id = ? AND skill_id = ?", userID, skillID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UserSkillStatesBySkillIDs(userID string, skillIDs []string) ([]model.UserSkillState, error) {
|
||||
if len(skillIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var states []model.UserSkillState
|
||||
err := r.db.Find(&states, "user_id = ? AND skill_id IN ?", userID, skillIDs).Error
|
||||
return states, err
|
||||
}
|
||||
|
||||
func (r *Repository) SetUserSkillAdded(state *model.UserSkillState) error {
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "skill_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"added", "updated_at"}),
|
||||
}).Create(state).Error
|
||||
}
|
||||
|
||||
func (r *Repository) SetUserSkillLiked(state *model.UserSkillState) error {
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "skill_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"liked", "updated_at"}),
|
||||
}).Create(state).Error
|
||||
}
|
||||
|
||||
func (r *Repository) SkillMetrics(skillIDs []string) (map[string]SkillMetrics, error) {
|
||||
metrics := make(map[string]SkillMetrics, len(skillIDs))
|
||||
if len(skillIDs) == 0 {
|
||||
return metrics, nil
|
||||
}
|
||||
var rows []SkillMetrics
|
||||
err := r.db.Model(&model.UserSkillState{}).
|
||||
Select("skill_id, SUM(CASE WHEN added THEN 1 ELSE 0 END) AS added_count, SUM(CASE WHEN liked THEN 1 ELSE 0 END) AS like_count").
|
||||
Where("skill_id IN ?", skillIDs).
|
||||
Group("skill_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range rows {
|
||||
metrics[item.SkillID] = item
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SkillOwners(ownerIDs []string) (map[string]model.User, error) {
|
||||
owners := make(map[string]model.User, len(ownerIDs))
|
||||
if len(ownerIDs) == 0 {
|
||||
return owners, nil
|
||||
}
|
||||
var users []model.User
|
||||
if err := r.db.Find(&users, "id IN ?", ownerIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
owners[user.ID] = user
|
||||
}
|
||||
return owners, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SkillOwnerAvatars(ownerIDs []string) (map[string]string, error) {
|
||||
avatars := make(map[string]string, len(ownerIDs))
|
||||
if len(ownerIDs) == 0 {
|
||||
return avatars, nil
|
||||
}
|
||||
var identities []model.UserIdentity
|
||||
if err := r.db.Where("user_id IN ? AND avatar_url <> ''", ownerIDs).Order("updated_at desc").Find(&identities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, identity := range identities {
|
||||
if avatars[identity.UserID] == "" {
|
||||
avatars[identity.UserID] = identity.AvatarURL
|
||||
}
|
||||
}
|
||||
return avatars, nil
|
||||
}
|
||||
@@ -491,7 +491,7 @@ func safeOAuthNext(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(value)
|
||||
if value == "" || err != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") {
|
||||
return "/canvas"
|
||||
return "/create"
|
||||
}
|
||||
return parsed.RequestURI()
|
||||
}
|
||||
|
||||
1102
backend/internal/service/seed/skills.json
Normal file
1102
backend/internal/service/seed/skills.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,421 +1,447 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const updreamSkillCommunityURL = "https://www.updream.cn/api/skills/community"
|
||||
const (
|
||||
skillStatusEnabled = 1
|
||||
skillSourceUser = 1
|
||||
)
|
||||
|
||||
type CommunitySkillsRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Sort string
|
||||
Search string
|
||||
Categories []string
|
||||
var skillCategoryLabels = map[string]string{
|
||||
"drama": "短剧影视",
|
||||
"ecommerce": "电商营销",
|
||||
"creative": "创意设计",
|
||||
"social": "社媒内容",
|
||||
"others": "其他",
|
||||
}
|
||||
|
||||
type CommunitySkillList struct {
|
||||
Skills []UpdreamSkill `json:"skills"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Categories []string `json:"categories"`
|
||||
type SkillShowcaseMedia struct {
|
||||
Type string `json:"type"`
|
||||
ShowcaseURI string `json:"showcase_uri"`
|
||||
ShowcaseURL string `json:"showcase_url"`
|
||||
}
|
||||
|
||||
type SkillIntegrationCapabilities struct {
|
||||
Provider string `json:"provider"`
|
||||
PublicCommunity bool `json:"publicCommunity"`
|
||||
CategoryFilter bool `json:"categoryFilter"`
|
||||
PublicRankings bool `json:"publicRankings"`
|
||||
PrivateAuthorization string `json:"privateAuthorization"`
|
||||
Upload bool `json:"upload"`
|
||||
Comments bool `json:"comments"`
|
||||
type SkillEffectiveUser struct {
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (s *Service) SkillIntegrationCapabilities() SkillIntegrationCapabilities {
|
||||
return SkillIntegrationCapabilities{Provider: "updream", PublicCommunity: true, CategoryFilter: true, PublicRankings: true, PrivateAuthorization: "not_configured", Upload: false, Comments: false}
|
||||
type SkillItem struct {
|
||||
SkillID string `json:"skill_id"`
|
||||
SkillName string `json:"skill_name"`
|
||||
Description string `json:"description"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Status int `json:"status"`
|
||||
MarkdownURL string `json:"markdown_url"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
UpdateTime int64 `json:"update_time"`
|
||||
Source int `json:"source"`
|
||||
Tag string `json:"tag"`
|
||||
SortWeight int `json:"sort_weight"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
IsLike bool `json:"is_like"`
|
||||
OwnerUID string `json:"owner_uid"`
|
||||
EffectiveUser SkillEffectiveUser `json:"effective_user"`
|
||||
OriginalSkillID *string `json:"original_skill_id"`
|
||||
ShowcaseMedia []SkillShowcaseMedia `json:"showcase_media"`
|
||||
AddedCount int64 `json:"added_count"`
|
||||
IsTest bool `json:"is_test"`
|
||||
ExtraInfo string `json:"extra_info"`
|
||||
IsAdded bool `json:"is_added"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
}
|
||||
|
||||
type UpdreamSkill struct {
|
||||
Dir string `json:"dir"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
DetailContent string `json:"detail_content"`
|
||||
DetailText string `json:"detail_text"`
|
||||
Categories []string `json:"categories"`
|
||||
Version int `json:"version"`
|
||||
UploaderID int64 `json:"uploader_id"`
|
||||
UploaderName string `json:"uploader_name"`
|
||||
UploaderAvatar string `json:"uploader_avatar"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
CTime string `json:"ctime"`
|
||||
MTime string `json:"mtime"`
|
||||
FeaturedLabel string `json:"featured_label"`
|
||||
ActivationCount int `json:"activation_count"`
|
||||
LikeCount int `json:"like_count"`
|
||||
UsageCount int `json:"usage_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
AvgRating *float64 `json:"avg_rating"`
|
||||
HotScore int `json:"hot_score"`
|
||||
Liked bool `json:"liked"`
|
||||
Activated bool `json:"activated"`
|
||||
UserRating *float64 `json:"user_rating"`
|
||||
ShareScope string `json:"share_scope"`
|
||||
ShareTeamID any `json:"share_team_id"`
|
||||
type SkillCategory struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type updreamCommunityResponse struct {
|
||||
Skills []UpdreamSkill `json:"skills"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
type SkillListRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Scope string
|
||||
Search string
|
||||
Tag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
func (s *Service) CommunitySkills(ctx context.Context, userID string, req CommunitySkillsRequest) (*CommunitySkillList, error) {
|
||||
req = normalizeCommunitySkillsRequest(req)
|
||||
skillsURL, err := updreamCommunityListURL(req)
|
||||
type SkillList struct {
|
||||
Skills []SkillItem `json:"skills"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextOffset int `json:"next_offset"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Categories []SkillCategory `json:"categories"`
|
||||
}
|
||||
|
||||
type SkillMutationRequest struct {
|
||||
SkillName string `json:"skill_name"`
|
||||
Description string `json:"description"`
|
||||
Instruction string `json:"instruction"`
|
||||
Tag string `json:"tag"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
MarkdownURL string `json:"markdown_url"`
|
||||
ShowcaseMedia []SkillShowcaseMedia `json:"showcase_media"`
|
||||
ExtraInfo string `json:"extra_info"`
|
||||
}
|
||||
|
||||
func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error) {
|
||||
req = normalizeSkillListRequest(req)
|
||||
rows, total, err := s.repo.Skills(repository.SkillListFilter{
|
||||
UserID: userID,
|
||||
Scope: req.Scope,
|
||||
Search: req.Search,
|
||||
Tag: req.Tag,
|
||||
Sort: req.Sort,
|
||||
Limit: req.PageSize,
|
||||
Offset: (req.Page - 1) * req.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload updreamCommunityResponse
|
||||
if err := fetchUpdreamJSON(ctx, skillsURL, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states, err := s.repo.UserSkillStatesByDirs(userID, skillDirs(payload.Skills))
|
||||
items, err := s.skillItems(userID, rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyUserSkillStates(payload.Skills, states)
|
||||
for index := range payload.Skills {
|
||||
payload.Skills[index].DetailText = strings.TrimSpace(payload.Skills[index].Description)
|
||||
payload.Skills[index].DetailContent = ""
|
||||
nextOffset := req.Page * req.PageSize
|
||||
if int64(nextOffset) >= total {
|
||||
nextOffset = 0
|
||||
}
|
||||
categories := []string{}
|
||||
for _, skill := range payload.Skills {
|
||||
categories = append(categories, skill.Categories...)
|
||||
}
|
||||
return &CommunitySkillList{Skills: payload.Skills, Total: payload.Total, Page: payload.Page, PageSize: payload.PageSize, Categories: cleanStringList(categories)}, nil
|
||||
return &SkillList{
|
||||
Skills: items,
|
||||
TotalCount: total,
|
||||
HasMore: int64(req.Page*req.PageSize) < total,
|
||||
NextOffset: nextOffset,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Categories: skillCategories(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CommunitySkillDetail(ctx context.Context, userID string, dir string) (*UpdreamSkill, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return nil, BadAuthRequest("技能标识不能为空")
|
||||
}
|
||||
skill, err := fetchUpdreamSkillDetail(ctx, dir)
|
||||
func (s *Service) AddedSkills(userID string) ([]SkillItem, error) {
|
||||
rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states, err := s.repo.UserSkillStatesByDirs(userID, []string{dir})
|
||||
return s.skillItems(userID, rows, true)
|
||||
}
|
||||
|
||||
func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) {
|
||||
skill, err := s.visibleSkill(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(states) > 0 {
|
||||
skill.Activated = states[0].Activated
|
||||
skill.Liked = states[0].Liked
|
||||
} else {
|
||||
skill.Activated = false
|
||||
skill.Liked = false
|
||||
}
|
||||
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
|
||||
skill.DetailContent = ""
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func (s *Service) ActivatedSkills(ctx context.Context, userID string) ([]UpdreamSkill, error) {
|
||||
return s.userStateSkills(ctx, userID, func(state model.UserSkillState) bool { return state.Activated })
|
||||
}
|
||||
|
||||
func (s *Service) FavoriteSkills(ctx context.Context, userID string) ([]UpdreamSkill, error) {
|
||||
return s.userStateSkills(ctx, userID, func(state model.UserSkillState) bool { return state.Liked })
|
||||
}
|
||||
|
||||
func (s *Service) userStateSkills(ctx context.Context, userID string, pick func(model.UserSkillState) bool) ([]UpdreamSkill, error) {
|
||||
states, err := s.repo.UserSkillStates(userID)
|
||||
items, err := s.skillItems(userID, []model.Skill{*skill}, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skills := make([]UpdreamSkill, 0, len(states))
|
||||
var firstErr error
|
||||
for _, state := range states {
|
||||
if !pick(state) {
|
||||
continue
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSkill(userID string, req SkillMutationRequest) (*SkillItem, error) {
|
||||
normalized, mediaJSON, err := normalizeSkillMutationRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skill := &model.Skill{
|
||||
ID: newID(),
|
||||
OwnerID: userID,
|
||||
Name: normalized.SkillName,
|
||||
Description: normalized.Description,
|
||||
Instruction: normalized.Instruction,
|
||||
Status: skillStatusEnabled,
|
||||
Source: skillSourceUser,
|
||||
Tag: normalized.Tag,
|
||||
IsPrivate: normalized.IsPrivate,
|
||||
MarkdownURL: normalized.MarkdownURL,
|
||||
ShowcaseMediaJSON: mediaJSON,
|
||||
ExtraInfo: normalized.ExtraInfo,
|
||||
}
|
||||
state := &model.UserSkillState{ID: newID(), UserID: userID, SkillID: skill.ID, Added: true}
|
||||
if err := s.repo.CreateSkill(skill, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.SkillDetail(userID, skill.ID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSkill(userID string, id string, req SkillMutationRequest) (*SkillItem, error) {
|
||||
skill, err := s.ownedSkill(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized, mediaJSON, err := normalizeSkillMutationRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skill.Name = normalized.SkillName
|
||||
skill.Description = normalized.Description
|
||||
skill.Instruction = normalized.Instruction
|
||||
skill.Tag = normalized.Tag
|
||||
skill.IsPrivate = normalized.IsPrivate
|
||||
skill.MarkdownURL = normalized.MarkdownURL
|
||||
skill.ShowcaseMediaJSON = mediaJSON
|
||||
skill.ExtraInfo = normalized.ExtraInfo
|
||||
if err := s.repo.Save(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.SkillDetail(userID, skill.ID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSkill(userID string, id string) error {
|
||||
skill, err := s.ownedSkill(userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.DeleteSkill(skill.ID)
|
||||
}
|
||||
|
||||
func (s *Service) SetSkillAdded(userID string, id string, added bool) (*SkillItem, error) {
|
||||
skill, err := s.visibleSkill(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill.OwnerID == userID {
|
||||
if !added {
|
||||
return nil, BadAuthRequest("自己创建的技能始终保留在我的技能中")
|
||||
}
|
||||
skill, err := fetchUpdreamSkillDetail(ctx, state.SkillDir)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
skill.Activated = state.Activated
|
||||
skill.Liked = state.Liked
|
||||
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
|
||||
skill.DetailContent = ""
|
||||
skills = append(skills, *skill)
|
||||
return s.SkillDetail(userID, id)
|
||||
}
|
||||
if len(skills) == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
state, err := s.skillState(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skills, nil
|
||||
state.Added = added
|
||||
if err := s.repo.SetUserSkillAdded(state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.SkillDetail(userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) SetSkillActivated(ctx context.Context, userID string, dir string, activated bool) (*UpdreamSkill, error) {
|
||||
skill, err := fetchUpdreamSkillDetail(ctx, strings.TrimSpace(dir))
|
||||
if err != nil {
|
||||
func (s *Service) SetSkillLiked(userID string, id string, liked bool) (*SkillItem, error) {
|
||||
if _, err := s.visibleSkill(userID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := s.ensureUserSkillState(userID, skill.Dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Activated = activated
|
||||
if err := s.repo.Save(state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skill.Activated = state.Activated
|
||||
skill.Liked = state.Liked
|
||||
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
|
||||
skill.DetailContent = ""
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSkillLiked(ctx context.Context, userID string, dir string, liked bool) (*UpdreamSkill, error) {
|
||||
skill, err := fetchUpdreamSkillDetail(ctx, strings.TrimSpace(dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := s.ensureUserSkillState(userID, skill.Dir)
|
||||
state, err := s.skillState(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Liked = liked
|
||||
if err := s.repo.Save(state); err != nil {
|
||||
if err := s.repo.SetUserSkillLiked(state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skill.Activated = state.Activated
|
||||
skill.Liked = state.Liked
|
||||
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
|
||||
skill.DetailContent = ""
|
||||
return s.SkillDetail(userID, id)
|
||||
}
|
||||
|
||||
func (s *Service) skillItems(userID string, skills []model.Skill, includeInstruction bool) ([]SkillItem, error) {
|
||||
ids := make([]string, 0, len(skills))
|
||||
ownerIDs := make([]string, 0, len(skills))
|
||||
for _, skill := range skills {
|
||||
ids = append(ids, skill.ID)
|
||||
ownerIDs = append(ownerIDs, skill.OwnerID)
|
||||
}
|
||||
states, err := s.repo.UserSkillStatesBySkillIDs(userID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := s.repo.SkillMetrics(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners, err := s.repo.SkillOwners(ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerAvatars, err := s.repo.SkillOwnerAvatars(ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stateBySkillID := make(map[string]model.UserSkillState, len(states))
|
||||
for _, state := range states {
|
||||
stateBySkillID[state.SkillID] = state
|
||||
}
|
||||
items := make([]SkillItem, 0, len(skills))
|
||||
for _, skill := range skills {
|
||||
var showcaseMedia []SkillShowcaseMedia
|
||||
if err := json.Unmarshal([]byte(skill.ShowcaseMediaJSON), &showcaseMedia); err != nil {
|
||||
return nil, errors.New("技能展示媒体数据格式错误")
|
||||
}
|
||||
owner := owners[skill.OwnerID]
|
||||
ownerName := strings.TrimSpace(skill.AuthorName)
|
||||
ownerAvatarURL := strings.TrimSpace(skill.AuthorAvatarURL)
|
||||
if strings.TrimSpace(owner.DisplayName) != "" {
|
||||
ownerName = strings.TrimSpace(owner.DisplayName)
|
||||
} else if strings.TrimSpace(owner.Username) != "" {
|
||||
ownerName = strings.TrimSpace(owner.Username)
|
||||
}
|
||||
if ownerAvatars[skill.OwnerID] != "" {
|
||||
ownerAvatarURL = ownerAvatars[skill.OwnerID]
|
||||
}
|
||||
state := stateBySkillID[skill.ID]
|
||||
metric := metrics[skill.ID]
|
||||
metric.LikeCount += skill.InitialLikeCount
|
||||
metric.AddedCount += skill.InitialAddedCount
|
||||
instruction := ""
|
||||
if includeInstruction {
|
||||
instruction = skill.Instruction
|
||||
}
|
||||
items = append(items, SkillItem{
|
||||
SkillID: skill.ID, SkillName: skill.Name, Description: skill.Description, Instruction: instruction,
|
||||
Status: skill.Status, MarkdownURL: skill.MarkdownURL, CreateTime: skill.CreatedAt.UnixMilli(), UpdateTime: skill.UpdatedAt.UnixMilli(),
|
||||
Source: skill.Source, Tag: skill.Tag, SortWeight: skill.SortWeight, IsPrivate: skill.IsPrivate,
|
||||
LikeCount: metric.LikeCount, IsLike: state.Liked, OwnerUID: skill.OwnerID,
|
||||
EffectiveUser: SkillEffectiveUser{Name: ownerName, AvatarURL: ownerAvatarURL, UID: skill.OwnerID}, ShowcaseMedia: showcaseMedia,
|
||||
AddedCount: metric.AddedCount, ExtraInfo: skill.ExtraInfo, IsAdded: state.Added || skill.OwnerID == userID, IsOwner: skill.OwnerID == userID,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// 所有详情和关系写入都先经过同一可见性边界,避免私有技能通过加入、收藏或画布接口泄露正文。
|
||||
func (s *Service) visibleSkill(userID string, id string) (*model.Skill, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, BadAuthRequest("技能 ID 不能为空")
|
||||
}
|
||||
skill, err := s.repo.Skill(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, BadAuthRequest("技能不存在或已删除")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill.IsPrivate && skill.OwnerID != userID {
|
||||
return nil, Forbidden("该技能未公开")
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureUserSkillState(userID string, dir string) (*model.UserSkillState, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return nil, Unauthorized("请先登录")
|
||||
func (s *Service) ownedSkill(userID string, id string) (*model.Skill, error) {
|
||||
skill, err := s.visibleSkill(userID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, BadAuthRequest("技能标识不能为空")
|
||||
if skill.OwnerID != userID {
|
||||
return nil, Forbidden("只有作者可以修改或删除该技能")
|
||||
}
|
||||
state, err := s.repo.UserSkillState(userID, dir)
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func (s *Service) skillState(userID string, skillID string) (*model.UserSkillState, error) {
|
||||
state, err := s.repo.UserSkillState(userID, skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state != nil {
|
||||
return state, nil
|
||||
}
|
||||
return &model.UserSkillState{ID: newID(), UserID: userID, SkillDir: dir}, nil
|
||||
return &model.UserSkillState{ID: newID(), UserID: userID, SkillID: skillID}, nil
|
||||
}
|
||||
|
||||
func normalizeCommunitySkillsRequest(req CommunitySkillsRequest) CommunitySkillsRequest {
|
||||
func normalizeSkillListRequest(req SkillListRequest) SkillListRequest {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 12
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 48 {
|
||||
req.PageSize = 48
|
||||
if req.PageSize > 80 {
|
||||
req.PageSize = 80
|
||||
}
|
||||
switch req.Scope {
|
||||
case "public", "mine", "created", "favorites":
|
||||
default:
|
||||
req.Scope = "public"
|
||||
}
|
||||
switch req.Sort {
|
||||
case "hot", "top_rated", "new":
|
||||
case "popular", "new", "updated":
|
||||
default:
|
||||
req.Sort = "hot"
|
||||
req.Sort = "popular"
|
||||
}
|
||||
req.Search = strings.TrimSpace(req.Search)
|
||||
req.Categories = cleanStringList(req.Categories)
|
||||
req.Tag = strings.TrimSpace(req.Tag)
|
||||
if req.Tag != "" {
|
||||
if _, ok := skillCategoryLabels[req.Tag]; !ok {
|
||||
req.Tag = ""
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func updreamCommunityListURL(req CommunitySkillsRequest) (string, error) {
|
||||
target, err := url.Parse(updreamSkillCommunityURL)
|
||||
func normalizeSkillMutationRequest(req SkillMutationRequest) (SkillMutationRequest, string, error) {
|
||||
req.SkillName = strings.TrimSpace(req.SkillName)
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.Instruction = strings.TrimSpace(req.Instruction)
|
||||
req.Tag = strings.TrimSpace(req.Tag)
|
||||
req.MarkdownURL = strings.TrimSpace(req.MarkdownURL)
|
||||
req.ExtraInfo = strings.TrimSpace(req.ExtraInfo)
|
||||
if req.SkillName == "" || utf8.RuneCountInString(req.SkillName) > 80 {
|
||||
return req, "", BadAuthRequest("技能名称必须为 1-80 个字符")
|
||||
}
|
||||
if req.Description == "" || utf8.RuneCountInString(req.Description) > 500 {
|
||||
return req, "", BadAuthRequest("技能简介必须为 1-500 个字符")
|
||||
}
|
||||
if req.Instruction == "" || utf8.RuneCountInString(req.Instruction) > 100000 {
|
||||
return req, "", BadAuthRequest("技能指令必须为 1-100000 个字符")
|
||||
}
|
||||
if _, ok := skillCategoryLabels[req.Tag]; !ok {
|
||||
return req, "", BadAuthRequest("请选择有效的技能分类")
|
||||
}
|
||||
if req.MarkdownURL != "" && !validSkillURL(req.MarkdownURL) {
|
||||
return req, "", BadAuthRequest("Markdown 地址必须是有效的 HTTP(S) 链接")
|
||||
}
|
||||
if utf8.RuneCountInString(req.ExtraInfo) > 2000 {
|
||||
return req, "", BadAuthRequest("补充信息不能超过 2000 个字符")
|
||||
}
|
||||
if len(req.ShowcaseMedia) > 8 {
|
||||
return req, "", BadAuthRequest("展示媒体最多添加 8 个")
|
||||
}
|
||||
for index := range req.ShowcaseMedia {
|
||||
media := &req.ShowcaseMedia[index]
|
||||
media.Type = strings.TrimSpace(media.Type)
|
||||
media.ShowcaseURI = strings.TrimSpace(media.ShowcaseURI)
|
||||
media.ShowcaseURL = strings.TrimSpace(media.ShowcaseURL)
|
||||
if media.Type != "image" && media.Type != "video" {
|
||||
return req, "", BadAuthRequest("展示媒体类型仅支持图片或视频")
|
||||
}
|
||||
if !validSkillURL(media.ShowcaseURL) {
|
||||
return req, "", BadAuthRequest("展示媒体必须填写有效的 HTTP(S) 链接")
|
||||
}
|
||||
if utf8.RuneCountInString(media.ShowcaseURI) > 500 || utf8.RuneCountInString(media.ShowcaseURL) > 2000 {
|
||||
return req, "", BadAuthRequest("展示媒体地址过长")
|
||||
}
|
||||
}
|
||||
mediaJSON, err := json.Marshal(req.ShowcaseMedia)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return req, "", err
|
||||
}
|
||||
values := target.Query()
|
||||
values.Set("page", strconv.Itoa(req.Page))
|
||||
values.Set("page_size", strconv.Itoa(req.PageSize))
|
||||
values.Set("sort", req.Sort)
|
||||
if req.Search != "" {
|
||||
values.Set("search", req.Search)
|
||||
}
|
||||
for _, category := range req.Categories {
|
||||
values.Add("categories", category)
|
||||
}
|
||||
target.RawQuery = values.Encode()
|
||||
return target.String(), nil
|
||||
return req, string(mediaJSON), nil
|
||||
}
|
||||
|
||||
func fetchUpdreamSkillDetail(ctx context.Context, dir string) (*UpdreamSkill, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return nil, BadAuthRequest("技能标识不能为空")
|
||||
}
|
||||
var skill UpdreamSkill
|
||||
if err := fetchUpdreamJSON(ctx, updreamSkillCommunityURL+"/"+url.PathEscape(dir), &skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill.Dir == "" {
|
||||
skill.Dir = dir
|
||||
}
|
||||
return &skill, nil
|
||||
func validSkillURL(value string) bool {
|
||||
target, err := url.ParseRequestURI(value)
|
||||
return err == nil && (target.Scheme == "http" || target.Scheme == "https") && target.Host != ""
|
||||
}
|
||||
|
||||
func fetchUpdreamJSON(ctx context.Context, target string, value any) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "InfiniteCanvas/skills-proxy")
|
||||
resp, err := OutboundHTTPClient(12 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Updream 技能接口连接失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Updream 技能接口响应读取失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Updream 技能接口返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if err := json.Unmarshal(body, value); err != nil {
|
||||
return fmt.Errorf("Updream 技能接口数据格式错误: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUserSkillStates(skills []UpdreamSkill, states []model.UserSkillState) {
|
||||
byDir := make(map[string]model.UserSkillState, len(states))
|
||||
for _, state := range states {
|
||||
byDir[state.SkillDir] = state
|
||||
}
|
||||
for index := range skills {
|
||||
state, ok := byDir[skills[index].Dir]
|
||||
if !ok {
|
||||
skills[index].Activated = false
|
||||
skills[index].Liked = false
|
||||
continue
|
||||
}
|
||||
skills[index].Activated = state.Activated
|
||||
skills[index].Liked = state.Liked
|
||||
func skillCategories() []SkillCategory {
|
||||
return []SkillCategory{
|
||||
{Value: "drama", Label: skillCategoryLabels["drama"]},
|
||||
{Value: "ecommerce", Label: skillCategoryLabels["ecommerce"]},
|
||||
{Value: "creative", Label: skillCategoryLabels["creative"]},
|
||||
{Value: "social", Label: skillCategoryLabels["social"]},
|
||||
{Value: "others", Label: skillCategoryLabels["others"]},
|
||||
}
|
||||
}
|
||||
|
||||
func skillDirs(skills []UpdreamSkill) []string {
|
||||
dirs := make([]string, 0, len(skills))
|
||||
for _, skill := range skills {
|
||||
if strings.TrimSpace(skill.Dir) != "" {
|
||||
dirs = append(dirs, skill.Dir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func detailTextFromHTML(raw string, fallback string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader(raw))
|
||||
if err != nil {
|
||||
return strings.Join(strings.Fields(stripHTMLTags(raw)), " ")
|
||||
}
|
||||
lines := make([]string, 0, 64)
|
||||
var walk func(*html.Node, bool)
|
||||
walk = func(node *html.Node, skip bool) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
nextSkip := skip || isSkippedHTMLNode(node)
|
||||
if node.Type == html.TextNode && !nextSkip {
|
||||
text := strings.Join(strings.Fields(node.Data), " ")
|
||||
if text != "" {
|
||||
lines = append(lines, text)
|
||||
}
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child, nextSkip)
|
||||
}
|
||||
}
|
||||
walk(doc, false)
|
||||
text := strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(fallback)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func isSkippedHTMLNode(node *html.Node) bool {
|
||||
if node.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(node.Data) {
|
||||
case "script", "style", "noscript", "svg", "canvas":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripHTMLTags(value string) string {
|
||||
var builder strings.Builder
|
||||
inTag := false
|
||||
for _, char := range value {
|
||||
switch {
|
||||
case char == '<':
|
||||
inTag = true
|
||||
case char == '>':
|
||||
inTag = false
|
||||
builder.WriteRune(' ')
|
||||
case !inTag:
|
||||
builder.WriteRune(char)
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func cleanStringList(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
91
backend/internal/service/skills_seed.go
Normal file
91
backend/internal/service/skills_seed.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
//go:embed seed/skills.json
|
||||
var builtinSkillsJSON []byte
|
||||
|
||||
type builtinSkillDefinition struct {
|
||||
SkillID string `json:"skill_id"`
|
||||
SkillName string `json:"skill_name"`
|
||||
Description string `json:"description"`
|
||||
Instruction string `json:"instruction"`
|
||||
Status int `json:"status"`
|
||||
MarkdownURL string `json:"markdown_url"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
UpdateTime int64 `json:"update_time"`
|
||||
Source int `json:"source"`
|
||||
Tag string `json:"tag"`
|
||||
SortWeight int `json:"sort_weight"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
OwnerUID string `json:"owner_uid"`
|
||||
EffectiveUser SkillEffectiveUser `json:"effective_user"`
|
||||
ShowcaseMedia []SkillShowcaseMedia `json:"showcase_media"`
|
||||
AddedCount int64 `json:"added_count"`
|
||||
ExtraInfo string `json:"extra_info"`
|
||||
}
|
||||
|
||||
// EnsureBuiltinSkills 校验并同步内置技能正文;用户关系独立保存,因此重复启动不会清空加入或收藏状态。
|
||||
func (s *Service) EnsureBuiltinSkills() error {
|
||||
var definitions []builtinSkillDefinition
|
||||
if err := json.Unmarshal(builtinSkillsJSON, &definitions); err != nil {
|
||||
return fmt.Errorf("解析内置技能失败: %w", err)
|
||||
}
|
||||
if len(definitions) == 0 {
|
||||
return fmt.Errorf("内置技能不能为空")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(definitions))
|
||||
skills := make([]model.Skill, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
id := strings.TrimSpace(definition.SkillID)
|
||||
ownerID := strings.TrimSpace(definition.OwnerUID)
|
||||
if id == "" || len(id) > 36 || ownerID == "" || len(ownerID) > 36 {
|
||||
return fmt.Errorf("内置技能 ID 或作者 ID 无效: %q", definition.SkillID)
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return fmt.Errorf("内置技能 ID 重复: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if definition.Status != skillStatusEnabled || definition.IsPrivate {
|
||||
return fmt.Errorf("内置技能必须为公开启用状态: %s", id)
|
||||
}
|
||||
if definition.CreateTime <= 0 || definition.UpdateTime <= 0 || definition.LikeCount < 0 || definition.AddedCount < 0 {
|
||||
return fmt.Errorf("内置技能时间或计数无效: %s", id)
|
||||
}
|
||||
authorName := strings.TrimSpace(definition.EffectiveUser.Name)
|
||||
authorAvatarURL := strings.TrimSpace(definition.EffectiveUser.AvatarURL)
|
||||
if authorName == "" || (authorAvatarURL != "" && !validSkillURL(authorAvatarURL)) {
|
||||
return fmt.Errorf("内置技能作者信息无效: %s", id)
|
||||
}
|
||||
normalized, mediaJSON, err := normalizeSkillMutationRequest(SkillMutationRequest{
|
||||
SkillName: definition.SkillName, Description: definition.Description, Instruction: definition.Instruction,
|
||||
Tag: definition.Tag, IsPrivate: definition.IsPrivate, MarkdownURL: definition.MarkdownURL,
|
||||
ShowcaseMedia: definition.ShowcaseMedia, ExtraInfo: definition.ExtraInfo,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("内置技能 %s 数据无效: %w", id, err)
|
||||
}
|
||||
skills = append(skills, model.Skill{
|
||||
ID: id, OwnerID: ownerID, AuthorName: authorName, AuthorAvatarURL: authorAvatarURL,
|
||||
Name: normalized.SkillName, Description: normalized.Description, Instruction: normalized.Instruction,
|
||||
Status: skillStatusEnabled, Source: definition.Source, Tag: normalized.Tag, SortWeight: definition.SortWeight,
|
||||
IsPrivate: false, MarkdownURL: normalized.MarkdownURL, ShowcaseMediaJSON: mediaJSON, ExtraInfo: normalized.ExtraInfo,
|
||||
InitialLikeCount: definition.LikeCount, InitialAddedCount: definition.AddedCount,
|
||||
CreatedAt: time.UnixMilli(definition.CreateTime), UpdatedAt: time.UnixMilli(definition.UpdateTime),
|
||||
})
|
||||
}
|
||||
if err := s.repo.UpsertBuiltinSkills(skills); err != nil {
|
||||
return fmt.Errorf("同步内置技能失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
|
||||
const presets = useMemo(() => {
|
||||
const skills = skillReferences.flatMap((reference): CanvasPromptPreset[] => {
|
||||
if (!reference.skill) return [];
|
||||
return [{ id: `skill:${reference.skill.dir}`, name: reference.skill.name, description: reference.skill.description || reference.skill.detail_text || "已激活技能", prompt: `@${reference.skill.name} `, modes: ["text", "image", "video", "audio"], source: "skill" }];
|
||||
return [{ id: `skill:${reference.skill.skill_id}`, name: reference.skill.skill_name, description: reference.skill.description || reference.skill.instruction || "已加入技能", prompt: `@${reference.skill.skill_name} `, modes: ["text", "image", "video", "audio"], source: "skill" }];
|
||||
});
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return [...BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized));
|
||||
@@ -88,7 +88,7 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha
|
||||
|
||||
const content = (
|
||||
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[320px] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Input className="canvas-preset-picker-search" variant="borderless" autoFocus allowClear size="small" prefix={<Search className="size-3.5" />} placeholder="搜索预设或已激活技能" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<Input className="canvas-preset-picker-search" variant="borderless" autoFocus allowClear size="small" prefix={<Search className="size-3.5" />} placeholder="搜索预设或已加入技能" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className="thin-scrollbar mt-1 max-h-72 space-y-0.5 overflow-y-auto">
|
||||
{presets.length ? presets.map((preset) => (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import type { UpdreamSkill } from "@/services/api/skills";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text" | "skill" | "character";
|
||||
@@ -16,11 +16,11 @@ export type CanvasResourceReference = {
|
||||
text?: string;
|
||||
active: boolean;
|
||||
sourceType?: CanvasNodeType;
|
||||
skill?: UpdreamSkill;
|
||||
skill?: Skill;
|
||||
};
|
||||
|
||||
export function canvasResourceMentionToken(reference: CanvasResourceReference) {
|
||||
if (reference.kind === "skill" && reference.skill?.dir) return `@[skill:${reference.skill.dir}]`;
|
||||
if (reference.kind === "skill" && reference.skill?.skill_id) return `@[skill:${reference.skill.skill_id}]`;
|
||||
return `@[node:${reference.nodeId}]`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { UpdreamSkill } from "@/services/api/skills";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
|
||||
const SKILL_REF_PATTERN = /@\[skill:([^\]]+)\]/g;
|
||||
|
||||
export function buildSkillMentionReferences(skills: UpdreamSkill[]): CanvasResourceReference[] {
|
||||
export function buildSkillMentionReferences(skills: Skill[]): CanvasResourceReference[] {
|
||||
return skills
|
||||
.filter((skill) => skill.activated ?? true)
|
||||
.filter((skill) => skill.is_added)
|
||||
.map((skill) => ({
|
||||
id: `skill:${skill.dir}`,
|
||||
nodeId: `skill:${skill.dir}`,
|
||||
id: `skill:${skill.skill_id}`,
|
||||
nodeId: `skill:${skill.skill_id}`,
|
||||
kind: "skill" as const,
|
||||
label: skill.name,
|
||||
title: skill.name,
|
||||
text: skill.description || skill.detail_text,
|
||||
label: skill.skill_name,
|
||||
title: skill.skill_name,
|
||||
text: skill.instruction || skill.description,
|
||||
active: true,
|
||||
skill,
|
||||
}));
|
||||
}
|
||||
|
||||
export function expandSkillMentions(prompt: string, skills: UpdreamSkill[]) {
|
||||
export function expandSkillMentions(prompt: string, skills: Skill[]) {
|
||||
if (!prompt.trim()) return prompt;
|
||||
const activeSkills = skills.filter((skill) => skill.activated ?? true);
|
||||
const activeSkills = skills.filter((skill) => skill.is_added);
|
||||
if (!activeSkills.length) return prompt;
|
||||
|
||||
const byId = new Map(activeSkills.map((skill) => [skill.dir, skill]));
|
||||
const byId = new Map(activeSkills.map((skill) => [skill.skill_id, skill]));
|
||||
let next = prompt.replace(SKILL_REF_PATTERN, (token, id) => {
|
||||
const skill = byId.get(id);
|
||||
return skill ? renderSkillPrompt(skill) : token;
|
||||
@@ -31,7 +31,7 @@ export function expandSkillMentions(prompt: string, skills: UpdreamSkill[]) {
|
||||
|
||||
activeSkills
|
||||
.slice()
|
||||
.sort((a, b) => b.name.length - a.name.length)
|
||||
.sort((a, b) => b.skill_name.length - a.skill_name.length)
|
||||
.forEach((skill) => {
|
||||
next = replaceNaturalSkillMention(next, skill);
|
||||
});
|
||||
@@ -39,19 +39,19 @@ export function expandSkillMentions(prompt: string, skills: UpdreamSkill[]) {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function renderSkillPrompt(skill: Pick<UpdreamSkill, "name" | "description" | "detail_text">) {
|
||||
export function renderSkillPrompt(skill: Pick<Skill, "skill_name" | "description" | "instruction">) {
|
||||
return [
|
||||
`【技能:${skill.name}】`,
|
||||
`【技能:${skill.skill_name}】`,
|
||||
skill.description ? `用途:${skill.description}` : "",
|
||||
skill.detail_text ? `技能详情:\n${skill.detail_text}` : "",
|
||||
skill.instruction ? `执行指令:\n${skill.instruction}` : "",
|
||||
"请严格执行该技能,只输出结果,不要输出解释性套话。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function replaceNaturalSkillMention(value: string, skill: UpdreamSkill) {
|
||||
const token = `@${skill.name}`;
|
||||
function replaceNaturalSkillMention(value: string, skill: Skill) {
|
||||
const token = `@${skill.skill_name}`;
|
||||
let result = "";
|
||||
let index = 0;
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { ConfigProvider, Tabs } from "antd";
|
||||
import { ArrowLeft, Play } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
|
||||
@@ -102,34 +100,26 @@ export function AuthScene() {
|
||||
className="my-auto w-full max-w-[460px]"
|
||||
>
|
||||
<ConfigProvider theme={getAntThemeConfig(true)}>
|
||||
<SpotlightSurface spotlightColor="rgba(96,165,250,.14)" spotlightRadius={300} className="flex h-auto rounded-[26px]">
|
||||
<CometCard
|
||||
rotateDepth={1.8}
|
||||
translateDepth={1.5}
|
||||
glare={!reducedMotion}
|
||||
containerClassName="h-auto"
|
||||
className="auth-card-dark h-auto overflow-hidden rounded-[26px] border border-white/12 bg-[#111216]/88 shadow-[0_40px_120px_rgba(0,0,0,.48)] backdrop-blur-2xl"
|
||||
>
|
||||
<section aria-label={copy.title} className="flex min-h-[620px] flex-col sm:min-h-[640px]">
|
||||
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-blue-300/80">{copy.eyebrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
|
||||
</header>
|
||||
<div className="border-b border-white/[0.08] px-6 sm:px-8">
|
||||
<Tabs
|
||||
className="auth-card-tabs"
|
||||
activeKey={activeTab}
|
||||
items={AUTH_TABS}
|
||||
onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
|
||||
/>
|
||||
</div>
|
||||
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<Outlet />
|
||||
</div>
|
||||
</section>
|
||||
</CometCard>
|
||||
</SpotlightSurface>
|
||||
<div className="auth-card-dark h-auto overflow-hidden rounded-lg bg-[#121318]/94 shadow-[0_28px_80px_rgba(0,0,0,.34)] backdrop-blur-2xl">
|
||||
<section aria-label={copy.title} className={`flex flex-col ${activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
|
||||
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-blue-300/80">{copy.eyebrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
|
||||
</header>
|
||||
<div className="px-6 sm:px-8">
|
||||
<Tabs
|
||||
className="auth-card-tabs"
|
||||
activeKey={activeTab}
|
||||
items={AUTH_TABS}
|
||||
onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
|
||||
/>
|
||||
</div>
|
||||
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<Outlet />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LoginPage() {
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<AuthField label="用户名 / 邮箱"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required /></AuthField>
|
||||
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" autoComplete="current-password" required /></AuthField>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPosition="end">登录</Button>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">登录</Button>
|
||||
{linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 登录</Button></> : null}
|
||||
</form>
|
||||
);
|
||||
@@ -53,6 +53,6 @@ function AuthField({ label, children }: { label: string; children: ReactNode })
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/projects";
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function RegisterPage() {
|
||||
<AuthField label="确认密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} placeholder="再次输入密码" autoComplete="new-password" required disabled={disabled} /></AuthField>
|
||||
</div>
|
||||
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled} icon={<ArrowRight className="size-4" />} iconPosition="end">创建账号</Button>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled} icon={<ArrowRight className="size-4" />} iconPlacement="end">创建账号</Button>
|
||||
{settings?.linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 注册 / 登录</Button></> : null}
|
||||
</form>
|
||||
);
|
||||
@@ -121,6 +121,6 @@ function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amb
|
||||
}
|
||||
|
||||
function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/projects";
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
|
||||
const {
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
clearCanvasFiles,
|
||||
createAndOpenProject,
|
||||
currentProject,
|
||||
@@ -798,7 +798,7 @@ function InfiniteCanvasPage() {
|
||||
hoveredNodeId,
|
||||
dragPreview,
|
||||
collapsingBatchIds,
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
directorScenes: currentProject?.directorScenes,
|
||||
toolbarNodeId,
|
||||
infoNodeId,
|
||||
@@ -1089,7 +1089,7 @@ function InfiniteCanvasPage() {
|
||||
const handleGenerateNode = useCanvasGenerationExecutor({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
@@ -1145,7 +1145,7 @@ function InfiniteCanvasPage() {
|
||||
const handleRetryNode = useCanvasGenerationRetry({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture
|
||||
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import type { UpdreamSkill } from "@/services/api/skills";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -21,7 +21,7 @@ import { executeTextGeneration } from "./canvas-text-generation-executor";
|
||||
type UseCanvasGenerationExecutorOptions = {
|
||||
projectId: string;
|
||||
domainProjectId?: string;
|
||||
activatedSkills: UpdreamSkill[];
|
||||
addedSkills: Skill[];
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
@@ -47,7 +47,7 @@ export type CanvasNodeGenerationOptions = {
|
||||
export function useCanvasGenerationExecutor({
|
||||
projectId,
|
||||
domainProjectId,
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
setNodes,
|
||||
@@ -142,7 +142,7 @@ export function useCanvasGenerationExecutor({
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedPrompt = expandSkillMentions(rawGenerationContext.prompt, activatedSkills);
|
||||
const expandedPrompt = expandSkillMentions(rawGenerationContext.prompt, addedSkills);
|
||||
const effectivePrompt = expandedPrompt.trim();
|
||||
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
|
||||
if (mode === "audio" && generationContext.characterReferences.length) {
|
||||
@@ -233,6 +233,6 @@ export function useCanvasGenerationExecutor({
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
},
|
||||
[activatedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
|
||||
[addedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/gener
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { storeGeneratedAudio } from "@/services/api/audio";
|
||||
import { storeGeneratedVideo } from "@/services/api/video";
|
||||
import type { UpdreamSkill } from "@/services/api/skills";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -36,7 +36,7 @@ import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/ty
|
||||
type UseCanvasGenerationRetryOptions = {
|
||||
projectId: string;
|
||||
domainProjectId?: string;
|
||||
activatedSkills: UpdreamSkill[];
|
||||
addedSkills: Skill[];
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
@@ -50,7 +50,7 @@ const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
|
||||
export function useCanvasGenerationRetry({ projectId, domainProjectId, activatedSkills, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
|
||||
export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkills, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
|
||||
const { message } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
@@ -93,7 +93,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item)));
|
||||
return;
|
||||
}
|
||||
const context = rawContext ? { ...rawContext, prompt: expandSkillMentions(rawContext.prompt, activatedSkills) } : null;
|
||||
const context = rawContext ? { ...rawContext, prompt: expandSkillMentions(rawContext.prompt, addedSkills) } : null;
|
||||
const prompt = (context?.characterReferences.length ? context.prompt : savedImageMetadata?.prompt || context?.prompt || "").trim();
|
||||
if (!prompt) {
|
||||
message.warning("找不到提示词,无法重试");
|
||||
@@ -210,7 +210,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
},
|
||||
[activatedSkills, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
|
||||
[addedSkills, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router";
|
||||
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listActivatedSkills, type UpdreamSkill } from "@/services/api/skills";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
|
||||
@@ -72,7 +72,7 @@ export function useCanvasProjectLifecycle({
|
||||
const renameProject = useCanvasStore((state) => state.renameProject);
|
||||
const deleteProjects = useCanvasStore((state) => state.deleteProjects);
|
||||
const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId));
|
||||
const [activatedSkills, setActivatedSkills] = useState<UpdreamSkill[]>([]);
|
||||
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
|
||||
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -130,12 +130,12 @@ export function useCanvasProjectLifecycle({
|
||||
useEffect(() => {
|
||||
if (!projectLoaded) return;
|
||||
let cancelled = false;
|
||||
listActivatedSkills()
|
||||
listAddedSkills()
|
||||
.then(({ skills }) => {
|
||||
if (!cancelled) setActivatedSkills(skills);
|
||||
if (!cancelled) setAddedSkills(skills);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setActivatedSkills([]);
|
||||
if (!cancelled) setAddedSkills([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -218,7 +218,7 @@ export function useCanvasProjectLifecycle({
|
||||
}, [cleanupCanvasFiles, projectId]);
|
||||
|
||||
return {
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
clearCanvasFiles,
|
||||
createAndOpenProject,
|
||||
currentProject,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
|
||||
import { shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import type { UpdreamSkill } from "@/services/api/skills";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
|
||||
@@ -24,7 +24,7 @@ type UseCanvasRenderModelOptions = {
|
||||
hoveredNodeId: string | null;
|
||||
dragPreview: DragPreview;
|
||||
collapsingBatchIds: Set<string>;
|
||||
activatedSkills: UpdreamSkill[];
|
||||
addedSkills: Skill[];
|
||||
directorScenes?: DirectorScene[];
|
||||
toolbarNodeId: string | null;
|
||||
infoNodeId: string | null;
|
||||
@@ -55,7 +55,7 @@ export function useCanvasRenderModel({
|
||||
hoveredNodeId,
|
||||
dragPreview,
|
||||
collapsingBatchIds,
|
||||
activatedSkills,
|
||||
addedSkills,
|
||||
directorScenes,
|
||||
toolbarNodeId,
|
||||
infoNodeId,
|
||||
@@ -245,7 +245,7 @@ export function useCanvasRenderModel({
|
||||
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
|
||||
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId), [activeNodeId, connections, dialogNodeId, semanticNodes]);
|
||||
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
|
||||
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(activatedSkills), [activatedSkills]);
|
||||
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
|
||||
const mentionReferencesByNodeId = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
|
||||
semanticNodes.forEach((node) => map.set(node.id, [...buildNodeMentionReferences(node, semanticNodes, connections), ...skillMentionReferences]));
|
||||
|
||||
@@ -1,79 +1,67 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Button, Collapse, Drawer, Input, Select, Skeleton } from "antd";
|
||||
import { Check, Flame, Heart, RefreshCw, Search, ShieldCheck, Sparkles, Star, UserRound, Zap } from "lucide-react";
|
||||
import { App, Button, Dropdown, Input, Select, Tooltip } from "antd";
|
||||
import { Check, Heart, Library, LoaderCircle, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { renderSkillPrompt } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { activateSkill, deactivateSkill, favoriteSkill, getCommunitySkill, listActivatedSkills, listCommunitySkills, listFavoriteSkills, skillImageUrl, unfavoriteSkill, type UpdreamSkill, type UpdreamSkillSort } from "@/services/api/skills";
|
||||
import { PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { fallbackSkillCategories, formatSkillCount, groupSkills } from "@/pages/skills/skill-catalog";
|
||||
import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer";
|
||||
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
|
||||
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
|
||||
|
||||
type SkillTab = "featured" | "all" | "activated" | "favorites";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const tabOptions: { label: string; value: SkillTab }[] = [
|
||||
{ label: "官方精选技能", value: "featured" },
|
||||
{ label: "全部技能", value: "all" },
|
||||
{ label: "已激活", value: "activated" },
|
||||
{ label: "我的收藏", value: "favorites" },
|
||||
const scopeOptions = [
|
||||
{ label: "技能广场", value: "public", icon: Sparkles },
|
||||
{ label: "我的技能", value: "mine", icon: Library },
|
||||
{ label: "我创建的", value: "created", icon: UserRound },
|
||||
{ label: "我的收藏", value: "favorites", icon: Heart },
|
||||
];
|
||||
const sortOptions: { label: string; value: UpdreamSkillSort }[] = [
|
||||
{ label: "热门", value: "hot" },
|
||||
{ label: "高分", value: "top_rated" },
|
||||
{ label: "最新", value: "new" },
|
||||
|
||||
const sortOptions: { label: string; value: SkillSort }[] = [
|
||||
{ label: "最多加入", value: "popular" },
|
||||
{ label: "最新发布", value: "new" },
|
||||
{ label: "最近更新", value: "updated" },
|
||||
];
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [tab, setTab] = useState<SkillTab>("featured");
|
||||
const [sort, setSort] = useState<UpdreamSkillSort>("hot");
|
||||
const [category, setCategory] = useState("all");
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const { message, modal } = App.useApp();
|
||||
const [scope, setScope] = useState<SkillScope>("public");
|
||||
const [sort, setSort] = useState<SkillSort>("popular");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [tag, setTag] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
const [skills, setSkills] = useState<UpdreamSkill[]>([]);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [categories, setCategories] = useState<SkillCategory[]>(fallbackSkillCategories);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [activeSkill, setActiveSkill] = useState<UpdreamSkill | null>(null);
|
||||
const [mutatingDir, setMutatingDir] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const isPagedTab = tab === "featured" || tab === "all";
|
||||
const [activeSkill, setActiveSkill] = useState<Skill | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [mutatingID, setMutatingID] = useState("");
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 260);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const request =
|
||||
tab === "activated"
|
||||
? listActivatedSkills().then(({ skills }) => ({ skills, total: skills.length, page: 1, page_size: skills.length || pageSize, categories: [] as string[] }))
|
||||
: tab === "favorites"
|
||||
? listFavoriteSkills().then(({ skills }) => ({ skills, total: skills.length, page: 1, page_size: skills.length || pageSize, categories: [] as string[] }))
|
||||
: listCommunitySkills({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort: tab === "featured" ? "hot" : sort,
|
||||
search: debouncedSearch,
|
||||
categories: category === "all" ? undefined : [category],
|
||||
});
|
||||
|
||||
request
|
||||
setLoadError("");
|
||||
listSkills({ page, page_size: pageSize, scope, sort, search: debouncedSearch || undefined, tag: tag === "all" ? undefined : tag })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setSkills(result.skills);
|
||||
setTotal(result.total);
|
||||
if (result.categories.length) setCategories((current) => Array.from(new Set([...current, ...result.categories])).sort((a, b) => a.localeCompare(b, "zh-CN")));
|
||||
setTotal(result.total_count);
|
||||
if (result.categories.length) setCategories(result.categories);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setSkills([]);
|
||||
setTotal(0);
|
||||
message.error(error instanceof Error ? error.message : "技能加载失败");
|
||||
setLoadError(error instanceof Error ? error.message : "技能加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -81,329 +69,218 @@ export default function SkillsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [category, debouncedSearch, message, page, pageSize, reloadKey, sort, tab]);
|
||||
}, [debouncedSearch, page, pageSize, reloadKey, scope, sort, tag]);
|
||||
|
||||
const visibleSkills = useMemo(() => {
|
||||
if (isPagedTab || !debouncedSearch) return skills;
|
||||
const query = debouncedSearch.toLowerCase();
|
||||
return skills.filter((skill) => `${skill.name} ${skill.description} ${skill.uploader_name}`.toLowerCase().includes(query));
|
||||
}, [debouncedSearch, isPagedTab, skills]);
|
||||
const displayedSkills = isPagedTab ? visibleSkills : visibleSkills.slice((page - 1) * pageSize, page * pageSize);
|
||||
const displayedTotal = isPagedTab ? total : visibleSkills.length;
|
||||
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
|
||||
const filtersActive = Boolean(search || tag !== "all" || sort !== "popular");
|
||||
|
||||
const openSkill = async (skill: UpdreamSkill) => {
|
||||
const openSkill = async (skill: Skill) => {
|
||||
setActiveSkill(skill);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const result = await getCommunitySkill(skill.dir);
|
||||
const result = await getSkill(skill.skill_id);
|
||||
setActiveSkill(result.skill);
|
||||
patchSkill(result.skill);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "技能详情加载失败");
|
||||
setActiveSkill(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patchSkill = (next: UpdreamSkill) => {
|
||||
setSkills((items) =>
|
||||
items.flatMap((item) => {
|
||||
if (item.dir !== next.dir) return [item];
|
||||
const merged = mergeSkill(item, next);
|
||||
if (tab === "activated" && !merged.activated) return [];
|
||||
if (tab === "favorites" && !merged.liked) return [];
|
||||
return [merged];
|
||||
}),
|
||||
);
|
||||
setActiveSkill((current) => (current?.dir === next.dir ? mergeSkill(current, next) : current));
|
||||
};
|
||||
|
||||
const toggleActivation = async (skill: UpdreamSkill) => {
|
||||
setMutatingDir(skill.dir);
|
||||
const openEditor = async (skill?: Skill) => {
|
||||
if (!skill) {
|
||||
setEditingSkill(null);
|
||||
setEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = skill.activated ? await deactivateSkill(skill.dir) : await activateSkill(skill.dir);
|
||||
patchSkill(result.skill);
|
||||
message.success(result.skill.activated ? "已激活" : "已取消激活");
|
||||
const result = skill.instruction ? { skill } : await getSkill(skill.skill_id);
|
||||
setActiveSkill(null);
|
||||
setEditingSkill(result.skill);
|
||||
setEditorOpen(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "状态更新失败");
|
||||
} finally {
|
||||
setMutatingDir(null);
|
||||
message.error(error instanceof Error ? error.message : "技能读取失败");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = async (skill: UpdreamSkill) => {
|
||||
setMutatingDir(skill.dir);
|
||||
const patchSkill = (next: Skill) => {
|
||||
setSkills((items) => items.map((item) => item.skill_id === next.skill_id ? { ...item, ...next, instruction: next.instruction || item.instruction } : item));
|
||||
setActiveSkill((current) => current?.skill_id === next.skill_id ? { ...current, ...next, instruction: next.instruction || current.instruction } : current);
|
||||
};
|
||||
|
||||
const toggleAdded = async (skill: Skill) => {
|
||||
if (skill.is_owner) return;
|
||||
setMutatingID(skill.skill_id);
|
||||
try {
|
||||
const result = skill.liked ? await unfavoriteSkill(skill.dir) : await favoriteSkill(skill.dir);
|
||||
const result = skill.is_added ? await removeSkill(skill.skill_id) : await addSkill(skill.skill_id);
|
||||
patchSkill(result.skill);
|
||||
message.success(result.skill.liked ? "已收藏" : "已取消收藏");
|
||||
message.success(result.skill.is_added ? "已加入我的技能" : "已从我的技能移除");
|
||||
if (scope === "mine" && !result.skill.is_added) reload();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "收藏更新失败");
|
||||
message.error(error instanceof Error ? error.message : "技能状态更新失败");
|
||||
} finally {
|
||||
setMutatingDir(null);
|
||||
setMutatingID("");
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
setPage(1);
|
||||
setDebouncedSearch(search.trim());
|
||||
setReloadKey((value) => value + 1);
|
||||
const toggleLiked = async (skill: Skill) => {
|
||||
setMutatingID(skill.skill_id);
|
||||
try {
|
||||
const result = skill.is_like ? await unlikeSkill(skill.skill_id) : await likeSkill(skill.skill_id);
|
||||
patchSkill(result.skill);
|
||||
message.success(result.skill.is_like ? "已收藏" : "已取消收藏");
|
||||
if (scope === "favorites" && !result.skill.is_like) reload();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "收藏状态更新失败");
|
||||
} finally {
|
||||
setMutatingID("");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (skill: Skill) => {
|
||||
modal.confirm({
|
||||
title: `删除“${skill.skill_name}”?`,
|
||||
content: "删除后,其他用户将无法继续使用该技能,已有加入和收藏关系也会一并移除。",
|
||||
okText: "删除技能",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteSkill(skill.skill_id);
|
||||
setActiveSkill(null);
|
||||
message.success("技能已删除");
|
||||
reload();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "技能删除失败");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspacePage grid>
|
||||
<PageHeader
|
||||
icon="skills"
|
||||
title="技能库"
|
||||
description="浏览 Updream 技能,管理激活与收藏。"
|
||||
meta={<span className="text-xs text-foreground/45">{displayedTotal} 个技能</span>}
|
||||
actions={
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ListToolbar
|
||||
active={Boolean(search || category !== "all" || tab !== "featured" || sort !== "hot")}
|
||||
onReset={() => {
|
||||
setSearch("");
|
||||
setDebouncedSearch("");
|
||||
setCategory("all");
|
||||
setTab("featured");
|
||||
setSort("hot");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
className="w-full sm:w-80"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={search}
|
||||
placeholder="搜索技能或作者"
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="w-44"
|
||||
value={tab}
|
||||
options={tabOptions}
|
||||
onChange={(value) => {
|
||||
setTab(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{isPagedTab ? (
|
||||
<Select
|
||||
className="w-32"
|
||||
disabled={tab === "featured"}
|
||||
value={tab === "featured" ? "hot" : sort}
|
||||
options={sortOptions}
|
||||
onChange={(value) => {
|
||||
setSort(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</ListToolbar>
|
||||
<WorkspacePage>
|
||||
<PageHeader icon="skills" title="技能库" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>创建技能</Button>} />
|
||||
|
||||
{isPagedTab && categories.length ? (
|
||||
<div className="thin-scrollbar flex gap-1 overflow-x-auto border-b border-border/70 py-2" aria-label="技能分类">
|
||||
{["all", ...categories].map((value) => (
|
||||
<button key={value} type="button" className={`h-7 shrink-0 rounded px-2.5 text-xs transition-colors ${category === value ? "bg-[color:var(--workspace-accent-soft)] text-[color:var(--workspace-accent)]" : "text-foreground/55 hover:bg-foreground/[.05] hover:text-foreground"}`} onClick={() => { setCategory(value); setPage(1); }}>
|
||||
{value === "all" ? "全部分类" : value}
|
||||
</button>
|
||||
<div className="mt-1 flex flex-col border-b border-border/75 xl:flex-row xl:items-end xl:justify-between">
|
||||
<nav className="thin-scrollbar -mb-px flex min-w-0 overflow-x-auto" aria-label="技能库范围" role="tablist">
|
||||
{scopeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = scope === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`relative inline-flex h-12 shrink-0 items-center gap-2 border-b-2 px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${active ? "border-foreground font-medium text-foreground" : "border-transparent text-foreground/48 hover:text-foreground/76"}`}
|
||||
onClick={() => { setScope(option.value as SkillScope); setPage(1); }}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 py-2.5 xl:flex-nowrap xl:justify-end">
|
||||
<Input className="w-full sm:!w-72" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
|
||||
<Select className="w-[136px]" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
|
||||
<Select className="w-[124px]" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
|
||||
{filtersActive ? (
|
||||
<Tooltip title="重置筛选">
|
||||
<Button type="text" aria-label="重置筛选" icon={<RotateCcw className="size-4" />} onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
|
||||
<div className="space-y-9 py-6">
|
||||
{groupedSkills.map((group) => (
|
||||
<section key={group.value} aria-labelledby={`skill-category-${group.value}`}>
|
||||
<div className="mb-3 flex items-baseline justify-between px-0.5">
|
||||
<h2 id={`skill-category-${group.value}`} className="text-sm font-medium text-foreground/62">{group.label}</h2>
|
||||
<span className="text-[11px] text-foreground/32">{group.skills.length} 个</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{group.skills.map((skill) => <SkillCard key={skill.skill_id} skill={skill} loading={mutatingID === skill.skill_id} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<SkillSkeleton />
|
||||
) : displayedSkills.length ? (
|
||||
<CollectionGrid className="sm:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
||||
{displayedSkills.map((skill) => (
|
||||
<SkillCard key={skill.dir} skill={skill} loading={mutatingDir === skill.dir} onOpen={() => openSkill(skill)} onActivate={() => toggleActivation(skill)} onFavorite={() => toggleFavorite(skill)} />
|
||||
))}
|
||||
</CollectionGrid>
|
||||
) : (
|
||||
<WorkspaceState icon="skills" title="暂无匹配技能" description="换一个关键词、分类或技能范围继续查找。" />
|
||||
<WorkspaceState
|
||||
compact
|
||||
className="min-h-[188px]"
|
||||
icon="skills"
|
||||
title={filtersActive ? "没有找到匹配技能" : scope === "created" ? "还没有创建技能" : scope === "public" ? "技能广场还是空的" : "这里还没有技能"}
|
||||
description={filtersActive ? "换个关键词或分类试试。" : scope === "favorites" ? "收藏的公开技能会显示在这里。" : scope === "mine" ? "从技能广场加入后会显示在这里。" : "创建并公开第一个技能,其他用户就能直接加入使用。"}
|
||||
action={filtersActive
|
||||
? <Button onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选</Button>
|
||||
: (scope === "created" || scope === "public")
|
||||
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>创建技能</Button>
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaginationBar
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={displayedTotal}
|
||||
pageSizeOptions={[20, 40, 80]}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
||||
</WorkspacePage>
|
||||
|
||||
<SkillDetailModal skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingDir === activeSkill.dir)} onClose={() => setActiveSkill(null)} onActivate={toggleActivation} onFavorite={toggleFavorite} />
|
||||
<SkillDetailDrawer skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} />
|
||||
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({ skill, loading, onOpen, onActivate, onFavorite }: { skill: UpdreamSkill; loading: boolean; onOpen: () => void; onActivate: () => void; onFavorite: () => void }) {
|
||||
function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<article className="app-collection-card group h-full">
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100 dark:bg-stone-900">
|
||||
{skill.cover_url ? <img src={skillImageUrl(skill.cover_url)} alt="" className="h-full w-full object-cover" /> : <SkillCoverFallback skill={skill} />}
|
||||
<div className="absolute left-2 top-2 flex flex-wrap gap-1">
|
||||
<span className="rounded bg-white/90 px-1.5 py-0.5 text-[10px] font-medium text-stone-700 backdrop-blur dark:bg-stone-950/80 dark:text-stone-200">{featuredLabel(skill.featured_label)}</span>
|
||||
{skill.activated ? <span className="rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-medium text-white">已激活</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{skill.name}</h2>
|
||||
<span className="shrink-0 text-[10px] text-stone-400">V{skill.version || "-"}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-stone-500 dark:text-stone-400">
|
||||
{skill.uploader_avatar ? <img src={skillImageUrl(skill.uploader_avatar)} alt="" className="size-4 rounded-full object-cover" /> : <UserRound className="size-3.5" />}
|
||||
<span className="truncate">{skill.uploader_name || "未知作者"}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-stone-600 dark:text-stone-300">{skill.description || "暂无简介"}</p>
|
||||
{skill.categories?.length ? <div className="mt-2 flex gap-1 overflow-hidden">{skill.categories.slice(0, 3).map((item) => <span key={item} className="shrink-0 rounded bg-foreground/[.055] px-1.5 py-0.5 text-[10px] text-foreground/50">{item}</span>)}</div> : null}
|
||||
</div>
|
||||
<article className="flex h-[178px] min-w-0 flex-col rounded-md border border-border/55 bg-[color:var(--workspace-surface-strong)] p-4 transition-[border-color,box-shadow,background-color] duration-200 hover:border-border hover:bg-[color:var(--workspace-surface)] hover:shadow-sm">
|
||||
<div className="flex min-h-8 items-start gap-3">
|
||||
<button type="button" className="min-w-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
||||
<h3 className="line-clamp-1 text-[15px] font-semibold leading-6">{skill.skill_name}</h3>
|
||||
</button>
|
||||
{skill.is_owner ? (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "edit", label: "编辑技能" },
|
||||
{ key: "delete", label: "删除技能", danger: true },
|
||||
],
|
||||
onClick: ({ key }) => key === "edit" ? onEdit() : onDelete(),
|
||||
}}
|
||||
>
|
||||
<button type="button" aria-label="技能操作" className="-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Tooltip title={`${skill.is_added ? "从我的技能移除" : "加入我的技能"} · ${formatSkillCount(skill.added_count)} 人已加入`}>
|
||||
<button type="button" disabled={loading} aria-label={skill.is_added ? "从我的技能移除" : "加入我的技能"} className={`-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait ${skill.is_added ? "bg-foreground/[.08] text-foreground" : "text-foreground/70 hover:bg-foreground/[.07] hover:text-foreground"}`} onClick={onAdd}>
|
||||
{loading ? <LoaderCircle className="size-4 animate-spin" /> : skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="mt-1 min-h-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
||||
<p className="line-clamp-3 text-xs leading-5 text-foreground/52">{skill.description || "暂无技能简介"}</p>
|
||||
</button>
|
||||
<div className="border-t border-stone-100 px-3 py-2.5 dark:border-stone-800">
|
||||
<div className="mb-2 flex items-center gap-3 text-[10px] text-stone-500 dark:text-stone-400"><span className="inline-flex items-center gap-1"><Zap className="size-3" />{formatCount(skill.usage_count || 0)}</span><span className="inline-flex items-center gap-1"><Star className="size-3" />{skill.avg_rating ? skill.avg_rating.toFixed(1) : "-"}</span><span className="inline-flex items-center gap-1"><Heart className="size-3" />{formatCount(skill.like_count || 0)}</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="flex-1" loading={loading} type={skill.activated ? "default" : "primary"} icon={skill.activated ? <Check className="size-4" /> : <Zap className="size-4" />} onClick={onActivate}>
|
||||
{skill.activated ? "已激活" : "激活"}
|
||||
</Button>
|
||||
<Button className="!w-10" loading={loading} icon={<Heart className={`size-4 ${skill.liked ? "fill-current text-rose-500" : ""}`} />} onClick={onFavorite} aria-label={skill.liked ? "取消收藏" : "收藏"} />
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-center gap-2 border-t border-border/45 pt-3 text-[11px] text-foreground/42">
|
||||
<button type="button" disabled={loading} className="inline-flex shrink-0 items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait" aria-label={skill.is_like ? "取消收藏" : "收藏"} onClick={onLike}>
|
||||
<Heart className={`size-3.5 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />
|
||||
<span>{formatSkillCount(skill.like_count)}</span>
|
||||
</button>
|
||||
<span className="truncate">来自 · {skill.effective_user.name || "未知用户"}</span>
|
||||
{skill.is_private ? <span className="ml-auto shrink-0 text-foreground/55">仅自己</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailModal({ skill, loading, mutating, onClose, onActivate, onFavorite }: { skill: UpdreamSkill | null; loading: boolean; mutating: boolean; onClose: () => void; onActivate: (skill: UpdreamSkill) => void; onFavorite: (skill: UpdreamSkill) => void }) {
|
||||
const injectedPrompt = skill ? renderSkillPrompt(skill) : "";
|
||||
|
||||
return (
|
||||
<Drawer open={Boolean(skill)} size="large" onClose={onClose} destroyOnHidden title={skill?.name || "技能详情"}>
|
||||
{skill ? (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">{skill.cover_url ? <img src={skillImageUrl(skill.cover_url)} alt="" className="aspect-[16/8] w-full object-cover" /> : <SkillCoverFallback skill={skill} />}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="flex-1" loading={mutating} type={skill.activated ? "default" : "primary"} icon={skill.activated ? <Check className="size-4" /> : <Zap className="size-4" />} onClick={() => onActivate(skill)}>
|
||||
{skill.activated ? "已激活" : "激活"}
|
||||
</Button>
|
||||
<Button loading={mutating} icon={<Heart className={`size-4 ${skill.liked ? "fill-current text-rose-500" : ""}`} />} onClick={() => onFavorite(skill)}>收藏</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 divide-x divide-border border-y border-border py-3 text-center">
|
||||
<SkillMetric icon={<Flame className="size-3.5" />} label="热度" value={formatCount(skill.hot_score || 0)} />
|
||||
<SkillMetric icon={<Zap className="size-3.5" />} label="使用" value={formatCount(skill.usage_count || 0)} />
|
||||
<SkillMetric icon={<Heart className="size-3.5" />} label="收藏" value={formatCount(skill.like_count || 0)} />
|
||||
<SkillMetric icon={<Star className="size-3.5" />} label="评分" value={skill.avg_rating ? skill.avg_rating.toFixed(1) : "-"} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 14 }} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<DetailPanel icon={<ShieldCheck className="size-4 text-stone-500" />} title="简介">
|
||||
<p className="text-sm leading-6 text-stone-600 dark:text-stone-300">{skill.description || "暂无简介"}</p>
|
||||
</DetailPanel>
|
||||
<DetailPanel icon={<Sparkles className="size-4 text-stone-500" />} title="能力说明">
|
||||
<pre className="thin-scrollbar max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-stone-50 p-3 text-sm leading-6 text-stone-700 dark:bg-stone-900 dark:text-stone-300">{skill.detail_text || skill.description || "暂无详情"}</pre>
|
||||
</DetailPanel>
|
||||
<DetailPanel icon={<Zap className="size-4 text-stone-500" />} title="画布引用内容">
|
||||
<pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-stone-50 p-3 text-sm leading-6 text-stone-700 dark:bg-stone-900 dark:text-stone-300">{injectedPrompt}</pre>
|
||||
</DetailPanel>
|
||||
<Collapse ghost size="small" items={[{ key: "technical", label: "技术信息", children: <div className="space-y-0 text-sm"><DetailRow label="目录" value={skill.dir} /><DetailRow label="图标标识" value={skill.icon_url || "-"} /><DetailRow label="版本" value={`V${skill.version || "-"}`} /><DetailRow label="上传者 ID" value={String(skill.uploader_id ?? "-")} /><DetailRow label="审核状态" value={skill.review_status || "-"} /><DetailRow label="共享范围" value={skill.share_scope || "-"} /><DetailRow label="更新时间" value={formatDate(skill.mtime)} /></div> }]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-stone-900 dark:text-stone-100">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSkeleton() {
|
||||
return (
|
||||
<CollectionGrid>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="app-collection-card p-3">
|
||||
<Skeleton.Image active className="!h-36 !w-full !rounded-md" />
|
||||
<Skeleton active paragraph={{ rows: 3 }} className="mt-4" />
|
||||
</div>
|
||||
))}
|
||||
</CollectionGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCoverFallback({ skill }: { skill: UpdreamSkill }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-40 w-full flex-col justify-between bg-stone-100 p-4 text-stone-900 dark:bg-stone-900 dark:text-stone-100">
|
||||
<Sparkles className="size-6 text-stone-400" />
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase text-stone-500">{skill.icon_url || "skill"}</div>
|
||||
<div className="mt-1 line-clamp-2 text-lg font-semibold leading-6">{skill.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 px-2">
|
||||
<div className="flex items-center justify-center gap-1 text-stone-400">{icon}<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{value}</span></div>
|
||||
<div className="mt-0.5 text-[10px] text-stone-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[104px_minmax(0,1fr)] gap-2 border-b border-stone-200 py-1.5 last:border-b-0 dark:border-stone-800">
|
||||
<span className="text-xs text-stone-500">{label}</span>
|
||||
<span className="truncate text-xs font-medium text-stone-800 dark:text-stone-200" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSkill(current: UpdreamSkill, next: UpdreamSkill) {
|
||||
return { ...current, ...next, detail_content: next.detail_content || current.detail_content };
|
||||
}
|
||||
|
||||
function featuredLabel(label?: string) {
|
||||
if (label === "rising") return "上升";
|
||||
if (label === "new") return "新";
|
||||
if (label === "featured") return "精选";
|
||||
return label || "精选";
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
if (value >= 10000) return `${(value / 10000).toFixed(1)}w`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
return <div className="grid grid-cols-1 gap-3 py-6 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[178px] animate-pulse rounded-md border border-border/45 bg-foreground/[.035]" />)}</div>;
|
||||
}
|
||||
|
||||
28
web/src/pages/skills/skill-catalog.ts
Normal file
28
web/src/pages/skills/skill-catalog.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Skill, SkillCategory } from "@/services/api/skills";
|
||||
|
||||
export const fallbackSkillCategories: SkillCategory[] = [
|
||||
{ value: "drama", label: "短剧影视" },
|
||||
{ value: "ecommerce", label: "电商营销" },
|
||||
{ value: "creative", label: "创意设计" },
|
||||
{ value: "social", label: "社媒内容" },
|
||||
{ value: "others", label: "其他" },
|
||||
];
|
||||
|
||||
export function skillCategoryLabel(value: string, categories: SkillCategory[] = fallbackSkillCategories) {
|
||||
return categories.find((item) => item.value === value)?.label || "其他";
|
||||
}
|
||||
|
||||
export function groupSkills(skills: Skill[], categories: SkillCategory[]) {
|
||||
const ordered = categories.length ? categories : fallbackSkillCategories;
|
||||
return ordered
|
||||
.map((category) => ({ ...category, skills: skills.filter((skill) => skill.tag === category.value) }))
|
||||
.filter((group) => group.skills.length > 0);
|
||||
}
|
||||
|
||||
export function formatSkillCount(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
export function formatSkillDate(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric" }).format(new Date(value));
|
||||
}
|
||||
57
web/src/pages/skills/skill-detail-drawer.tsx
Normal file
57
web/src/pages/skills/skill-detail-drawer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Button, Drawer, Skeleton, Tooltip } from "antd";
|
||||
import { Check, ExternalLink, Heart, Pencil, Plus, Users } from "lucide-react";
|
||||
|
||||
import { formatSkillCount, formatSkillDate, skillCategoryLabel } from "@/pages/skills/skill-catalog";
|
||||
import type { Skill, SkillCategory } from "@/services/api/skills";
|
||||
|
||||
export function SkillDetailDrawer({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void }) {
|
||||
return (
|
||||
<Drawer open={Boolean(skill)} size={760} destroyOnHidden title={skill?.skill_name || "技能详情"} onClose={onClose} extra={skill?.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : undefined}>
|
||||
{skill ? (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/50">
|
||||
<span>{skillCategoryLabel(skill.tag, categories)}</span><span aria-hidden="true">/</span><span>{skill.is_private ? "仅自己可见" : "公开技能"}</span><span aria-hidden="true">/</span><span>更新于 {formatSkillDate(skill.update_time)}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-foreground/72">{skill.description}</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button type={skill.is_added ? "default" : "primary"} loading={mutating} disabled={skill.is_owner} icon={skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />} onClick={() => onAdd(skill)}>{skill.is_owner ? "我的技能" : skill.is_added ? "已加入" : "加入技能"}</Button>
|
||||
<Button loading={mutating} icon={<Heart className={`size-4 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />} onClick={() => onLike(skill)}>{skill.is_like ? "已收藏" : "收藏"}</Button>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 text-xs text-foreground/45"><Users className="size-3.5" />{formatSkillCount(skill.added_count)} 人加入</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{skill.showcase_media.length ? <SkillMediaGallery skill={skill} /> : null}
|
||||
|
||||
<section aria-labelledby="skill-instruction-title">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 id="skill-instruction-title" className="text-sm font-semibold">技能指令</h2>
|
||||
{skill.markdown_url ? <Tooltip title="打开 Markdown 原文"><a className="inline-flex size-8 items-center justify-center rounded-md text-foreground/55 hover:bg-foreground/[.06] hover:text-foreground" href={skill.markdown_url} target="_blank" rel="noreferrer" aria-label="打开 Markdown 原文"><ExternalLink className="size-4" /></a></Tooltip> : null}
|
||||
</div>
|
||||
{loading ? <Skeleton active paragraph={{ rows: 14 }} /> : <pre className="thin-scrollbar max-h-[52vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-foreground/[.025] p-4 font-mono text-xs leading-6 text-foreground/78">{skill.instruction || "暂无技能指令"}</pre>}
|
||||
</section>
|
||||
|
||||
<section className="border-t border-border/70 pt-4 text-xs leading-5 text-foreground/48">
|
||||
<div>作者:{skill.effective_user.name || "未知用户"}</div>
|
||||
{skill.extra_info ? <div className="mt-2 whitespace-pre-wrap">{skill.extra_info}</div> : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillMediaGallery({ skill }: { skill: Skill }) {
|
||||
return (
|
||||
<section aria-labelledby="skill-showcase-title">
|
||||
<h2 id="skill-showcase-title" className="mb-2 text-sm font-semibold">展示案例</h2>
|
||||
<div className="thin-scrollbar flex snap-x gap-3 overflow-x-auto pb-2">
|
||||
{skill.showcase_media.map((media, index) => (
|
||||
<div key={`${media.showcase_url}-${index}`} className="aspect-video w-[min(78vw,420px)] shrink-0 snap-start overflow-hidden rounded-md border border-border/70 bg-black/90">
|
||||
{media.type === "video" ? <video className="h-full w-full object-contain" controls playsInline preload="metadata" src={media.showcase_url} /> : <img className="h-full w-full object-contain" src={media.showcase_url} alt={`${skill.skill_name} 展示案例 ${index + 1}`} width={840} height={472} loading="lazy" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
127
web/src/pages/skills/skill-editor-drawer.tsx
Normal file
127
web/src/pages/skills/skill-editor-drawer.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { App, Button, Drawer, Form, Input, Select, Switch } from "antd";
|
||||
import { Minus, Plus, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
|
||||
import { createSkill, updateSkill, type Skill, type SkillMutationInput, type SkillShowcaseMedia } from "@/services/api/skills";
|
||||
|
||||
type SkillFormValues = Omit<SkillMutationInput, "is_private"> & { is_public: boolean };
|
||||
|
||||
export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boolean; skill: Skill | null; onClose: () => void; onSaved: (skill: Skill) => void }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [form] = Form.useForm<SkillFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.setFieldsValue({
|
||||
skill_name: skill?.skill_name || "",
|
||||
description: skill?.description || "",
|
||||
instruction: skill?.instruction || "",
|
||||
tag: skill?.tag || "creative",
|
||||
is_public: skill ? !skill.is_private : true,
|
||||
markdown_url: skill?.markdown_url || "",
|
||||
showcase_media: skill?.showcase_media || [],
|
||||
extra_info: skill?.extra_info || "",
|
||||
});
|
||||
setDirty(false);
|
||||
}, [form, open, skill]);
|
||||
|
||||
const requestClose = () => {
|
||||
if (!dirty) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
modal.confirm({ title: "放弃未保存的修改?", content: "当前填写内容不会保留。", okText: "放弃修改", okButtonProps: { danger: true }, cancelText: "继续编辑", onOk: onClose });
|
||||
};
|
||||
|
||||
const submit = async (values: SkillFormValues) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: SkillMutationInput = {
|
||||
skill_name: values.skill_name,
|
||||
description: values.description,
|
||||
instruction: values.instruction,
|
||||
tag: values.tag,
|
||||
is_private: !values.is_public,
|
||||
markdown_url: values.markdown_url || "",
|
||||
showcase_media: (values.showcase_media || []).map((item) => ({ ...item, showcase_uri: item.showcase_uri || "" })),
|
||||
extra_info: values.extra_info || "",
|
||||
};
|
||||
const result = skill ? await updateSkill(skill.skill_id, input) : await createSkill(input);
|
||||
setDirty(false);
|
||||
message.success(skill ? "技能已更新" : "技能已创建");
|
||||
onSaved(result.skill);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "技能保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
|
||||
<Form form={form} layout="vertical" requiredMark="optional" onFinish={submit} onValuesChange={() => setDirty(true)}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Form.Item name="skill_name" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>
|
||||
<Input maxLength={80} showCount placeholder="例如:短剧导演分镜" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tag" label="技能分类" rules={[{ required: true, message: "请选择技能分类" }]}>
|
||||
<Select options={fallbackSkillCategories.map(({ value, label }) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item name="description" label="技能简介" rules={[{ required: true, message: "请填写技能简介" }, { max: 500, message: "最多 500 个字符" }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={500} showCount placeholder="说明适用场景、输入条件和最终产出" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="instruction" label="技能指令" rules={[{ required: true, message: "请填写技能指令" }, { max: 100000, message: "最多 100000 个字符" }]} extra="画布引用该技能时会把这段指令发送给模型。">
|
||||
<Input.TextArea className="font-mono text-xs leading-5" autoSize={{ minRows: 14, maxRows: 28 }} maxLength={100000} showCount placeholder="使用 Markdown 编写角色、约束、流程、检查清单和输出格式" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="grid gap-x-4 sm:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<Form.Item name="markdown_url" label="Markdown 地址" rules={[{ type: "url", message: "请输入有效的 HTTP(S) 链接" }]}>
|
||||
<Input type="url" inputMode="url" spellCheck={false} placeholder="https://example.com/SKILL.md" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_public" label="公开状态" valuePropName="checked" extra="公开后其他用户可以加入使用。">
|
||||
<Switch checkedChildren="公开" unCheckedChildren="私有" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.List name="showcase_media">
|
||||
{(fields, { add, remove }) => (
|
||||
<section aria-labelledby="skill-media-title">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div><h3 id="skill-media-title" className="text-sm font-medium">展示媒体</h3><p className="mt-1 text-xs text-foreground/50">可选,最多 8 个公开图片或视频链接。</p></div>
|
||||
<Button disabled={fields.length >= 8} icon={<Plus className="size-4" />} onClick={() => add(emptyMedia())}>添加媒体</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="grid grid-cols-[112px_minmax(0,1fr)_36px] gap-2">
|
||||
<Form.Item {...field} name={[field.name, "type"]} className="mb-0" rules={[{ required: true, message: "选择类型" }]}>
|
||||
<Select options={[{ value: "image", label: "图片" }, { value: "video", label: "视频" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, "showcase_url"]} className="mb-0" rules={[{ required: true, message: "请填写媒体链接" }, { type: "url", message: "链接格式无效" }]}>
|
||||
<Input type="url" inputMode="url" spellCheck={false} placeholder="https://example.com/media" />
|
||||
</Form.Item>
|
||||
<Button aria-label="移除媒体" title="移除媒体" icon={<Minus className="size-4" />} onClick={() => remove(field.name)} />
|
||||
<Form.Item {...field} name={[field.name, "showcase_uri"]} hidden><Input /></Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.Item name="extra_info" label="补充信息" className="mt-5" rules={[{ max: 2000, message: "最多 2000 个字符" }]}>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 5 }} maxLength={2000} showCount placeholder="版本说明、依赖工具或使用注意事项" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyMedia(): SkillShowcaseMedia {
|
||||
return { type: "image", showcase_uri: "", showcase_url: "" };
|
||||
}
|
||||
@@ -3,113 +3,124 @@ import axios from "axios";
|
||||
import { compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request";
|
||||
import type { BackendEnvelope } from "@/services/api/task-center";
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_CANVAS_BACKEND_URL || "/api";
|
||||
const api = axios.create({ baseURL: apiBaseURL, withCredentials: true });
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
|
||||
|
||||
export type UpdreamSkillSort = "hot" | "top_rated" | "new";
|
||||
export type SkillSort = "popular" | "new" | "updated";
|
||||
export type SkillScope = "public" | "mine" | "created" | "favorites";
|
||||
export type SkillMediaType = "image" | "video";
|
||||
|
||||
export type UpdreamSkill = {
|
||||
dir: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
cover_url: string;
|
||||
detail_content: string;
|
||||
detail_text: string;
|
||||
categories: string[];
|
||||
version: number;
|
||||
uploader_id: number;
|
||||
uploader_name: string;
|
||||
uploader_avatar: string;
|
||||
is_private: boolean;
|
||||
review_status: string;
|
||||
ctime: string;
|
||||
mtime: string;
|
||||
featured_label?: string;
|
||||
activation_count: number;
|
||||
like_count: number;
|
||||
usage_count: number;
|
||||
comment_count: number;
|
||||
rating_count: number;
|
||||
avg_rating: number | null;
|
||||
hot_score?: number;
|
||||
liked: boolean;
|
||||
activated: boolean;
|
||||
user_rating?: number | null;
|
||||
share_scope?: string;
|
||||
share_team_id?: unknown;
|
||||
export type SkillShowcaseMedia = {
|
||||
type: SkillMediaType;
|
||||
showcase_uri: string;
|
||||
showcase_url: string;
|
||||
};
|
||||
|
||||
export type CommunitySkillList = {
|
||||
skills: UpdreamSkill[];
|
||||
total: number;
|
||||
export type Skill = {
|
||||
skill_id: string;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
instruction?: string;
|
||||
status: number;
|
||||
markdown_url: string;
|
||||
create_time: number;
|
||||
update_time: number;
|
||||
source: number;
|
||||
tag: string;
|
||||
sort_weight: number;
|
||||
is_private: boolean;
|
||||
like_count: number;
|
||||
is_like: boolean;
|
||||
owner_uid: string;
|
||||
effective_user: { name: string; avatar_url: string; uid: string };
|
||||
original_skill_id: string | null;
|
||||
showcase_media: SkillShowcaseMedia[];
|
||||
added_count: number;
|
||||
is_test: boolean;
|
||||
extra_info: string;
|
||||
is_added: boolean;
|
||||
is_owner: boolean;
|
||||
};
|
||||
|
||||
export type SkillCategory = { value: string; label: string };
|
||||
|
||||
export type SkillList = {
|
||||
skills: Skill[];
|
||||
total_count: number;
|
||||
has_more: boolean;
|
||||
next_offset: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
categories: string[];
|
||||
categories: SkillCategory[];
|
||||
};
|
||||
|
||||
export type SkillIntegrationCapabilities = {
|
||||
provider: "updream";
|
||||
publicCommunity: boolean;
|
||||
categoryFilter: boolean;
|
||||
publicRankings: boolean;
|
||||
privateAuthorization: "not_configured" | "configured";
|
||||
upload: boolean;
|
||||
comments: boolean;
|
||||
};
|
||||
|
||||
export type ListCommunitySkillsInput = {
|
||||
export type ListSkillsInput = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
sort?: UpdreamSkillSort;
|
||||
scope?: SkillScope;
|
||||
sort?: SkillSort;
|
||||
search?: string;
|
||||
categories?: string[];
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
export type SkillMutationInput = {
|
||||
skill_name: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
tag: string;
|
||||
is_private: boolean;
|
||||
markdown_url: string;
|
||||
showcase_media: SkillShowcaseMedia[];
|
||||
extra_info: string;
|
||||
};
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
const response = await promise;
|
||||
if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败");
|
||||
return response.data.data;
|
||||
try {
|
||||
const response = await promise;
|
||||
if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败");
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listCommunitySkills(input: ListCommunitySkillsInput = {}) {
|
||||
export function listSkills(input: ListSkillsInput = {}) {
|
||||
const params = serializeApiParams(compactApiParams(input as ApiParams));
|
||||
return request<CommunitySkillList>(api.get(`/skills/community?${params.toString()}`));
|
||||
return request<SkillList>(api.get(`/skills?${params.toString()}`));
|
||||
}
|
||||
|
||||
export function getSkillIntegrationCapabilities() {
|
||||
return request<{ capabilities: SkillIntegrationCapabilities }>(api.get("/skills/capabilities"));
|
||||
export function getSkill(id: string) {
|
||||
return request<{ skill: Skill }>(api.get(`/skills/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function getCommunitySkill(dir: string) {
|
||||
return request<{ skill: UpdreamSkill }>(api.get(`/skills/community/${encodeURIComponent(dir)}`));
|
||||
export function listAddedSkills() {
|
||||
return request<{ skills: Skill[] }>(api.get("/skills/added"));
|
||||
}
|
||||
|
||||
export function listActivatedSkills() {
|
||||
return request<{ skills: UpdreamSkill[] }>(api.get("/skills/activated"));
|
||||
export function createSkill(input: SkillMutationInput) {
|
||||
return request<{ skill: Skill }>(api.post("/skills", input));
|
||||
}
|
||||
|
||||
export function listFavoriteSkills() {
|
||||
return request<{ skills: UpdreamSkill[] }>(api.get("/skills/favorites"));
|
||||
export function updateSkill(id: string, input: SkillMutationInput) {
|
||||
return request<{ skill: Skill }>(api.put(`/skills/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function activateSkill(dir: string) {
|
||||
return request<{ skill: UpdreamSkill }>(api.post(`/skills/${encodeURIComponent(dir)}/activate`));
|
||||
export function deleteSkill(id: string) {
|
||||
return request<{ deleted: boolean }>(api.delete(`/skills/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function deactivateSkill(dir: string) {
|
||||
return request<{ skill: UpdreamSkill }>(api.delete(`/skills/${encodeURIComponent(dir)}/activate`));
|
||||
export function addSkill(id: string) {
|
||||
return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/add`));
|
||||
}
|
||||
|
||||
export function favoriteSkill(dir: string) {
|
||||
return request<{ skill: UpdreamSkill }>(api.post(`/skills/${encodeURIComponent(dir)}/favorite`));
|
||||
export function removeSkill(id: string) {
|
||||
return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/add`));
|
||||
}
|
||||
|
||||
export function unfavoriteSkill(dir: string) {
|
||||
return request<{ skill: UpdreamSkill }>(api.delete(`/skills/${encodeURIComponent(dir)}/favorite`));
|
||||
export function likeSkill(id: string) {
|
||||
return request<{ skill: Skill }>(api.post(`/skills/${encodeURIComponent(id)}/like`));
|
||||
}
|
||||
|
||||
export function skillImageUrl(value?: string) {
|
||||
if (!value || !/^https?:\/\//i.test(value)) return value || "";
|
||||
return `${apiBaseURL.replace(/\/$/, "")}/skills/image?url=${encodeURIComponent(value)}`;
|
||||
export function unlikeSkill(id: string) {
|
||||
return request<{ skill: Skill }>(api.delete(`/skills/${encodeURIComponent(id)}/like`));
|
||||
}
|
||||
|
||||
@@ -2842,16 +2842,81 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-nav::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-nav-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-tab {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin: 0 !important;
|
||||
padding: 12px 0;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 12px !important;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-weight: 500;
|
||||
transition: color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-tab-active::after {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
content: "";
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: rgba(255, 255, 255, 0.94) !important;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-tab-btn:focus-visible {
|
||||
border-radius: 3px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.48);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.auth-card-tabs .ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-card-dark :where(.ant-input, .ant-input-affix-wrapper) {
|
||||
border-color: transparent !important;
|
||||
background: rgba(255, 255, 255, 0.055) !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out !important;
|
||||
}
|
||||
|
||||
.auth-card-dark .ant-input-affix-wrapper > .ant-input {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.auth-card-dark :where(.ant-input:hover, .ant-input-affix-wrapper:hover) {
|
||||
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||
background: rgba(255, 255, 255, 0.07) !important;
|
||||
}
|
||||
|
||||
.auth-card-dark :where(.ant-input:focus, .ant-input-focused, .ant-input-affix-wrapper-focused) {
|
||||
border-color: transparent !important;
|
||||
background: rgba(255, 255, 255, 0.085) !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.auth-card-dark .ant-input-affix-wrapper-focused .ant-input-prefix svg {
|
||||
color: rgba(255, 255, 255, 0.62) !important;
|
||||
}
|
||||
|
||||
.auth-card-dark input:-webkit-autofill,
|
||||
|
||||
Reference in New Issue
Block a user