chore: 工作台产品化进行中的改动
把工作区里其余在制品一并入库,主要是工作台产品化的推进:
后端:新增 capability_definition / project / my_app_center / office_skill
接口与 action_definition / skill_definition / project / user_app_center
模型,config 加路由健康上报。
前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
项目管理、应用中心、能力目录页,以及配套 api / store / config;
聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
等组件。
清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
其它:文档与启动脚本同步。
(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eai_agentplatform/backend/internal/middleware"
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
"eai_agentplatform/backend/internal/web"
|
||||
)
|
||||
|
||||
// 项目名的长度上限。前端弹窗的计数器读的也是这个数 —— 两边必须一致,
|
||||
// 否则前端数到 40 说没超,后端一剪,用户看到的名字和自己打的不一样。
|
||||
const projectNameMaxLen = 40
|
||||
|
||||
type projectReq struct {
|
||||
Name string `json:"name"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
// Instruction 用指针:值类型分不出「没传」和「传了空串」,指令就永远清不掉 ——
|
||||
// 详情页把指令删空再保存会被当成「没改」,用户看到的是白删一次。
|
||||
// Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。
|
||||
Instruction *string `json:"instruction"`
|
||||
SpecialistKeys []string `json:"specialist_keys"`
|
||||
SkillKeys []string `json:"skill_keys"`
|
||||
ConnectorKeys []string `json:"connector_keys"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// projectQuery 「我的项目」永远只列自己的,管理员也只看自己的 —— 跟
|
||||
// myTaskQuery 是同一条规矩,所以归属判定直接复用 myTaskOwners,
|
||||
// 不另写一套(两套判定迟早会漂)。
|
||||
func projectQuery(user *model.User) *gorm.DB {
|
||||
owners := myTaskOwners(user)
|
||||
query := store.DB.Model(&model.Project{})
|
||||
if len(owners) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("owner IN ?", owners)
|
||||
}
|
||||
|
||||
// encodeKeys 把 key 列表存成 JSON 字符串。空列表存空串而不是 "[]",
|
||||
// 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。
|
||||
func encodeKeys(keys []string) string {
|
||||
cleaned := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if trimmed := strings.TrimSpace(key); trimmed != "" {
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ListProjects 当前用户的项目,置顶在前、最近动过的靠前。
|
||||
func ListProjects(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.Project
|
||||
if err := projectQuery(user).
|
||||
Order("pinned DESC, updated_at DESC, id DESC").
|
||||
Limit(50).
|
||||
Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// CreateProject 建一个项目。空 body 也收(跟「新建任务」一样,全走默认值),
|
||||
// 但名字是必填的 —— 一个没名字的项目在列表里没法认。
|
||||
func CreateProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
var req projectReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
web.Fail(c, web.NewBadRequest("项目名称不能为空"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > projectNameMaxLen {
|
||||
name = string([]rune(name)[:projectNameMaxLen])
|
||||
}
|
||||
|
||||
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
|
||||
web.Fail(c, web.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
instruction := ""
|
||||
if req.Instruction != nil {
|
||||
instruction = strings.TrimSpace(*req.Instruction)
|
||||
}
|
||||
|
||||
project := model.Project{
|
||||
Name: name,
|
||||
Instruction: instruction,
|
||||
TemplateKey: strings.TrimSpace(req.TemplateKey),
|
||||
// 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。
|
||||
Owner: myTaskOwnerName(user),
|
||||
SpecialistKeys: encodeKeys(req.SpecialistKeys),
|
||||
SkillKeys: encodeKeys(req.SkillKeys),
|
||||
ConnectorKeys: encodeKeys(req.ConnectorKeys),
|
||||
}
|
||||
if err := store.DB.Create(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, project)
|
||||
}
|
||||
|
||||
// UpdateProject 改名 / 改指令 / 置顶。只有显式传了的字段才覆盖:
|
||||
// 没传 name 就别把名字清空,没传 pinned 就别把它当 false。
|
||||
func UpdateProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var req projectReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if name := strings.TrimSpace(req.Name); name != "" {
|
||||
if len([]rune(name)) > projectNameMaxLen {
|
||||
name = string([]rune(name)[:projectNameMaxLen])
|
||||
}
|
||||
project.Name = name
|
||||
}
|
||||
// 传了空串就是「清空指令」,得让它生效 —— 见 projectReq 里 Instruction 的注释。
|
||||
if req.Instruction != nil {
|
||||
project.Instruction = strings.TrimSpace(*req.Instruction)
|
||||
}
|
||||
if req.TemplateKey != "" {
|
||||
project.TemplateKey = strings.TrimSpace(req.TemplateKey)
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
project.Pinned = *req.Pinned
|
||||
}
|
||||
// 三个能力列表:给了就用给的(空数组也算给了,表示清空),
|
||||
// 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。
|
||||
if req.SpecialistKeys != nil {
|
||||
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
|
||||
web.Fail(c, web.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
project.SpecialistKeys = encodeKeys(req.SpecialistKeys)
|
||||
}
|
||||
if req.SkillKeys != nil {
|
||||
project.SkillKeys = encodeKeys(req.SkillKeys)
|
||||
}
|
||||
if req.ConnectorKeys != nil {
|
||||
project.ConnectorKeys = encodeKeys(req.ConnectorKeys)
|
||||
}
|
||||
|
||||
if err := store.DB.Save(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, project)
|
||||
}
|
||||
|
||||
// DeleteProject 删项目。
|
||||
//
|
||||
// 项目里的任务**不删**,只把 project_id 置空 —— 任务是「做过的事」,
|
||||
// 删一个分组不该把它一起抹掉。前端确认框里必须把这一点说明白,
|
||||
// 否则用户会以为连任务一起没了。
|
||||
func DeleteProject(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Model(&model.WorkerTask{}).
|
||||
Where("project_id = ?", project.ID).
|
||||
Update("project_id", nil).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除项目失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
// ListProjectTasks 项目下的任务。项目必须是自己的 —— 别人的项目直接 404,
|
||||
// 不区分「不存在」和「不是你的」。
|
||||
func ListProjectTasks(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var project model.Project
|
||||
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("项目不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.WorkerTask
|
||||
if err := store.DB.Where("project_id = ?", project.ID).
|
||||
Order("updated_at DESC, id DESC").
|
||||
Limit(100).
|
||||
Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询项目任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, items)
|
||||
}
|
||||
|
||||
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
|
||||
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
|
||||
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
|
||||
// 校验它们只会让老项目在清单变动后改不动。
|
||||
func validateSpecialistKeys(keys []string) error {
|
||||
for _, key := range keys {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
var specialist model.Specialist
|
||||
if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil {
|
||||
return errors.New("专员不存在:" + trimmed)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user