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:
eaiadmin
2026-09-14 16:19:18 +08:00
co-authored by Claude Code
parent 5c3d5987d9
commit f197dbd206
46 changed files with 3884 additions and 1377 deletions
@@ -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: "培训交付专员",