feat: 工作台外壳收尾 + 冻结旧导航 + 任务延迟创建与最近任务行菜单
外壳收尾 - 后端:内置「通用智能助手」(specialist.state = system)—— 它是任务的默认归属, 不是可安装的专员,常规列表一律挡掉,只有显式带 state=system 才查得到 - config/nav.js:一级导航、二级 tab、搜索面板共用一份配置,替代原先散在 layout/ 与 config/workbench.js 里的两套,旧的那批导航导出随之下架 - NavSearch.vue / SectionTabs.vue 组件化;考试 8 个页面收进 ExamTabsView 统一外壳 - 新增 /apps 专员选择页(SpecialistPickerPage.vue) - ChatInputBar 重构:输入框的 + 拆为独立组件 PlusMenu.vue,新增 update:mode 事件 - 删除 LibraryTabs.vue(已被 SectionTabs 取代) 冻结旧导航(新增 frontend/src/_frozen/ + FROZEN.md 登记表) - 工作区胶囊条连通知铃铛一起冻:外壳重构后成了冗余的第二套导航。不删代码, 抽成自包含的 WorkspaceTabs.frozen.vue 归档,将来能原样放回 - 同源孤儿一并收拢:旧 SideNav / SectionTabs / navigation.js / Notification.vue - 统一加 .frozen 后缀,登记表逐条写明原位置 / 冻结原因 / 外部依赖 / 解冻条件 / 解冻步骤 - 已验:冻结物不进构建产物(dist 里搜不到 wb-tabs),src/ 下无代码引用, 只有 MainLayout 里指向 FROZEN.md 的指引注释 - 后端通知四接口与 store/api 层保留(BusinessAppPage 仍在用),解冻是纯前端工作量 我的任务 - 任务记录延迟到发出第一条消息时创建:新建任务只回到开场画布,不落库, 标题取第一句话,专员取输入框 + 里先选的那个(pendingSpecialistKey 暂存, 建任务时一并提交)。原实现点一次落一条「新任务」,是「最近任务」堆满空行的根因 - 最近任务行菜单:重命名(就地编辑,回车提交 / Esc 取消)、置顶、删除 (硬删无回收站,确认框写明无法恢复)。新增 worker_task.pinned 字段、 DELETE /api/worker/my-tasks/:id,列表排序改为 pinned DESC, updated_at DESC; el-dropdown 加 :persistent="false"(默认每行留一份隐藏菜单在 DOM 里) - 对照 ChatGPT / WorkBuddy 后决定不做:分享任务(纯内网)、打开文件夹(Web 无此能力)、 保存到工作空间(无 workspace 概念)、归档(没有「已归档」入口可找回) - 当前任务高亮改用 WorkBuddy 绿;空列表给出「发送第一条消息后出现在这里」提示 - PROJECT_STATE.md 补记 D21(冻结机制)/ D22(延迟建任务)/ D23(行菜单) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 通用智能助手:seed 里 state=system 的那条内置记录。「新建任务」时还没选专员,
|
||||
// 任务就先落在它名下;之后用输入框的 + 选专员/工具,任务再改挂过去。
|
||||
const generalAssistantKey = "general-assistant"
|
||||
|
||||
// 「新建任务」的占位标题。发出第一条消息后前端会把它改成消息内容。
|
||||
const myTaskPlaceholderTitle = "新任务"
|
||||
|
||||
// myTaskOwners 是"这条任务算谁的"的判定依据。scopedWorkbenchTaskQuery 也是
|
||||
// 按这两个值找人,保持一致;只写 FullName 的话没填全名的用户会跟别人混在一起。
|
||||
func myTaskOwners(user *model.User) []string {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
owners := make([]string, 0, 2)
|
||||
if name := strings.TrimSpace(user.FullName); name != "" {
|
||||
owners = append(owners, name)
|
||||
}
|
||||
if name := strings.TrimSpace(user.Username); name != "" {
|
||||
owners = append(owners, name)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// myTaskOwnerName 一条新任务记在谁名下。空全名时退回登录名。
|
||||
func myTaskOwnerName(user *model.User) string {
|
||||
owners := myTaskOwners(user)
|
||||
if len(owners) == 0 {
|
||||
return ""
|
||||
}
|
||||
return owners[0]
|
||||
}
|
||||
|
||||
// myTaskQuery 「我的任务」永远是"我自己的",管理员也只看自己的 ——
|
||||
// 要看全公司的那是工作台总览,不是这里。
|
||||
func myTaskQuery(user *model.User) *gorm.DB {
|
||||
owners := myTaskOwners(user)
|
||||
query := store.DB.Model(&model.WorkerTask{})
|
||||
if len(owners) == 0 {
|
||||
// 没有可用的归属标识,返回空集而不是全表
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
return query.Where("owner IN ?", owners)
|
||||
}
|
||||
|
||||
// ListMyTasks 当前用户的全部任务,跨专员,最近更新的在前。
|
||||
func ListMyTasks(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.WorkerTask
|
||||
if err := myTaskQuery(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)
|
||||
}
|
||||
|
||||
// DeleteMyTask 彻底删掉一条任务。worker_task 没有软删字段,删了就是删了 ——
|
||||
// 前端那边因此必须在确认框里说清楚,不能拿它当「归档」用。
|
||||
func DeleteMyTask(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 task model.WorkerTask
|
||||
if err := myTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("任务不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&task).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用智能助手名下。
|
||||
func CreateMyTask(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
// 允许空 body:点「新建任务」时前端什么都可以不带,全部走默认值。
|
||||
var req workerTaskReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
specialistKey := strings.TrimSpace(req.SpecialistKey)
|
||||
if specialistKey == "" {
|
||||
specialistKey = generalAssistantKey
|
||||
}
|
||||
var specialist model.Specialist
|
||||
if err := store.DB.Where("key = ?", specialistKey).First(&specialist).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("专员不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = myTaskPlaceholderTitle
|
||||
}
|
||||
|
||||
// 归属由服务端定死,不读请求里的 owner —— 否则任何人都能替别人建任务。
|
||||
req.SpecialistKey = specialist.Key
|
||||
req.Title = title
|
||||
req.Owner = myTaskOwnerName(user)
|
||||
task, err := buildWorkerTaskFromReq(req, user)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
task.SpecialistKey = specialist.Key
|
||||
if task.Status == "" {
|
||||
task.Status = workerTaskStatusPending
|
||||
}
|
||||
if task.Status == "" {
|
||||
task.Status = "待处理"
|
||||
}
|
||||
|
||||
if err := store.DB.Create(&task).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, task)
|
||||
}
|
||||
|
||||
// UpdateMyTask 改自己的任务:改标题,或者把它改挂到另一个专员名下
|
||||
// (输入框的 + 里选了专员就走这里)。
|
||||
func UpdateMyTask(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 task model.WorkerTask
|
||||
if err := myTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("任务不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
var req workerTaskReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
task.Title = title
|
||||
}
|
||||
if key := strings.TrimSpace(req.SpecialistKey); key != "" && key != task.SpecialistKey {
|
||||
var specialist model.Specialist
|
||||
if err := store.DB.Where("key = ?", key).First(&specialist).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("目标专员不存在"))
|
||||
return
|
||||
}
|
||||
task.SpecialistKey = specialist.Key
|
||||
}
|
||||
if summary := strings.TrimSpace(req.Summary); summary != "" {
|
||||
task.Summary = summary
|
||||
}
|
||||
if req.Pinned != nil {
|
||||
task.Pinned = *req.Pinned
|
||||
}
|
||||
|
||||
if err := store.DB.Save(&task).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, task)
|
||||
}
|
||||
@@ -46,6 +46,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview)
|
||||
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
|
||||
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
|
||||
// 员工自己的工作区:「新建任务」和侧边栏的任务列表走这三个。
|
||||
// 归属由服务端按登录用户定,请求里的 owner 一律不读。
|
||||
r.GET("/api/worker/my-tasks", middleware.Auth(cfg), ListMyTasks)
|
||||
r.POST("/api/worker/my-tasks", middleware.Auth(cfg), CreateMyTask)
|
||||
r.PUT("/api/worker/my-tasks/:id", middleware.Auth(cfg), UpdateMyTask)
|
||||
r.DELETE("/api/worker/my-tasks/:id", middleware.Auth(cfg), DeleteMyTask)
|
||||
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
|
||||
r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask)
|
||||
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
|
||||
|
||||
@@ -29,9 +29,16 @@ func ListSpecialists(c *gin.Context) {
|
||||
if !isAdmin {
|
||||
q = q.Where("state = ?", "active")
|
||||
}
|
||||
case "system":
|
||||
q = q.Where("state = ?", "system")
|
||||
default:
|
||||
q = q.Where("state = ?", state)
|
||||
}
|
||||
// state = system 的内置记录(通用智能助手)是任务的默认归属,不是可安装的专员,
|
||||
// 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。
|
||||
if c.Query("state") != "system" {
|
||||
q = q.Where("state <> ?", "system")
|
||||
}
|
||||
|
||||
var items []model.Specialist
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
|
||||
|
||||
@@ -28,6 +28,9 @@ type workerTaskReq struct {
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"due_at"`
|
||||
Context map[string]any `json:"context"`
|
||||
// Pinned 用指针:要区分「没传这个字段」和「传了 false 要取消置顶」,
|
||||
// 值类型的话取消置顶会被当成没传,永远取消不掉。
|
||||
Pinned *bool `json:"pinned"`
|
||||
}
|
||||
|
||||
type workerActionReq struct {
|
||||
|
||||
@@ -17,8 +17,10 @@ type WorkerTask struct {
|
||||
DueAt *time.Time `json:"due_at"`
|
||||
CreatedBy *uint `gorm:"index" json:"created_by"`
|
||||
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Pinned 置顶:只影响「我的任务」列表的排序,不改变任务本身的状态。
|
||||
Pinned bool `gorm:"not null;default:false" json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WorkerTask) TableName() string { return "worker_task" }
|
||||
|
||||
@@ -285,6 +285,24 @@ func seedTrainingMedia() error {
|
||||
|
||||
func seedSpecialists() error {
|
||||
items := []model.Specialist{
|
||||
{
|
||||
// 「新建任务」在还没选专员/工具之前,任务先落在这里。
|
||||
// state = system:它不是一个能装到工作区的专员,只是任务的默认归属,
|
||||
// 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。
|
||||
Key: "general-assistant",
|
||||
Label: "通用智能助手",
|
||||
Tier: "generic",
|
||||
WorkerType: "dw",
|
||||
Route: "/home",
|
||||
Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具",
|
||||
Stage: "待处理",
|
||||
Color: "#409eff",
|
||||
MarketTag: "内置",
|
||||
Version: "v1.0",
|
||||
BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续",
|
||||
State: "system",
|
||||
SortOrder: 0,
|
||||
},
|
||||
{
|
||||
Key: "training-delivery",
|
||||
Label: "培训交付专员",
|
||||
|
||||
Reference in New Issue
Block a user