feat: complete project workspace tags and notes UI
This commit is contained in:
@@ -29,9 +29,12 @@ func (h *Handler) Register(router gin.IRouter) {
|
||||
router.GET("/projects", h.listProjects)
|
||||
router.GET("/projects/:id/dashboard", h.dashboard)
|
||||
router.GET("/projects/:id/workspace", h.workspace)
|
||||
router.GET("/projects/:id/tags", h.listTags)
|
||||
router.POST("/projects/:id/tasks", h.createTask)
|
||||
router.PATCH("/projects/:id/tasks/:taskID", h.updateTask)
|
||||
router.POST("/projects/:id/sources", h.uploadSource)
|
||||
router.POST("/projects/:id/cron-plans", h.createCronPlan)
|
||||
router.POST("/projects/:id/tags", h.createTag)
|
||||
}
|
||||
|
||||
func (h *Handler) createProject(c *gin.Context) {
|
||||
@@ -42,13 +45,22 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
project, err := h.service.CreateProject(userID, input.Name, input.Description)
|
||||
project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{
|
||||
Name: input.Name,
|
||||
Identifier: input.Identifier,
|
||||
Icon: input.Icon,
|
||||
Background: input.Background,
|
||||
Description: input.Description,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -108,6 +120,24 @@ func (h *Handler) workspace(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, workspace)
|
||||
}
|
||||
|
||||
func (h *Handler) listTags(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tags, err := h.service.ListProjectTags(userID, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
func (h *Handler) createTask(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
@@ -123,6 +153,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"dueAt"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
@@ -138,6 +169,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
||||
Description: input.Description,
|
||||
Status: input.Status,
|
||||
DueAt: dueAt,
|
||||
Tag: input.Tag,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -146,6 +178,47 @@ func (h *Handler) createTask(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, task)
|
||||
}
|
||||
|
||||
func (h *Handler) updateTask(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
taskID, ok := parseTaskID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Completed bool `json:"completed"`
|
||||
NextProjectID uint `json:"nextProjectId"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
task, err := h.service.UpdateTask(userID, projectID, taskID, UpdateTaskInput{
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Status: input.Status,
|
||||
Completed: input.Completed,
|
||||
NextProjectID: input.NextProjectID,
|
||||
Tag: input.Tag,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadSource(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
@@ -233,6 +306,31 @@ func (h *Handler) createCronPlan(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, plan)
|
||||
}
|
||||
|
||||
func (h *Handler) createTag(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
tag, err := h.service.CreateProjectTag(userID, projectID, input.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, tag)
|
||||
}
|
||||
|
||||
func parseProjectID(c *gin.Context) (uint, bool) {
|
||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -242,6 +340,15 @@ func parseProjectID(c *gin.Context) (uint, bool) {
|
||||
return uint(projectID), true
|
||||
}
|
||||
|
||||
func parseTaskID(c *gin.Context) (uint, bool) {
|
||||
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(taskID), true
|
||||
}
|
||||
|
||||
func parseOptionalTime(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -17,9 +17,35 @@ import (
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
|
||||
router, _, _ := newProjectsHandlerTestRouter(t)
|
||||
body, err := json.Marshal(gin.H{
|
||||
"name": "Explore",
|
||||
"identifier": "EXP",
|
||||
"icon": "compass",
|
||||
"background": "#165DFF",
|
||||
"description": "RSS exploration workspace",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var project models.SenlinAgentProject
|
||||
require.NoError(t, models.DBService.Where("owner_id = ? AND identifier = ?", 1, "EXP").First(&project).Error)
|
||||
require.Equal(t, "Explore", project.Name)
|
||||
require.Equal(t, "compass", project.Icon)
|
||||
require.Equal(t, "#165DFF", project.Background)
|
||||
require.Equal(t, "RSS exploration workspace", project.Description)
|
||||
}
|
||||
|
||||
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
|
||||
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -33,6 +59,7 @@ func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
||||
require.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
require.NotNil(t, task.TagID)
|
||||
}
|
||||
|
||||
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
||||
@@ -79,6 +106,60 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
||||
require.NotNil(t, plan.NextRunAt)
|
||||
}
|
||||
|
||||
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
body, err := json.Marshal(gin.H{"name": "Design"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tags", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var tag models.SenlinAgentTag
|
||||
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
|
||||
require.Equal(t, project.ID, tag.ProjectID)
|
||||
}
|
||||
|
||||
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects/1/tags", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var tags []models.SenlinAgentTag
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
|
||||
require.Len(t, tags, 1)
|
||||
require.Equal(t, "Design", tags[0].Name)
|
||||
}
|
||||
|
||||
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
|
||||
require.NoError(t, models.DBService.Create(&task).Error)
|
||||
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/projects/1/tasks/1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var updated models.SenlinAgentTask
|
||||
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
|
||||
require.Equal(t, "Draft v2", updated.Title)
|
||||
require.Equal(t, "done", updated.Status)
|
||||
require.NotNil(t, updated.TagID)
|
||||
}
|
||||
|
||||
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
||||
t.Helper()
|
||||
newTestDB(t)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,9 @@ type Dashboard struct {
|
||||
type WorkbenchProject struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
Initials string `json:"initials"`
|
||||
UnreadCount int64 `json:"unreadCount"`
|
||||
@@ -99,11 +103,29 @@ type ProjectWorkspace struct {
|
||||
CronPlans []CronPlan `json:"cronPlans"`
|
||||
}
|
||||
|
||||
type CreateProjectInput struct {
|
||||
Name string
|
||||
Identifier string
|
||||
Icon string
|
||||
Background string
|
||||
Description string
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueAt *time.Time
|
||||
Tag string
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
Completed bool
|
||||
NextProjectID uint
|
||||
Tag string
|
||||
}
|
||||
|
||||
type CreateFileSourceInput struct {
|
||||
@@ -123,11 +145,26 @@ func NewService() *Service {
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description})
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("project name is required")
|
||||
}
|
||||
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
|
||||
identifier := strings.TrimSpace(input.Identifier)
|
||||
if identifier == "" {
|
||||
identifier = projectInitials(name)
|
||||
}
|
||||
project := &models.SenlinAgentProject{
|
||||
OwnerID: ownerID,
|
||||
Name: name,
|
||||
Identifier: identifier,
|
||||
Icon: strings.TrimSpace(input.Icon),
|
||||
Background: strings.TrimSpace(input.Background),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
}
|
||||
return project, models.DBService.Create(project).Error
|
||||
}
|
||||
|
||||
@@ -149,9 +186,14 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
||||
if status == "" {
|
||||
status = "open"
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &models.SenlinAgentTask{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
TagID: tagID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Status: status,
|
||||
@@ -160,6 +202,58 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
||||
return task, models.DBService.Create(task).Error
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var task models.SenlinAgentTask
|
||||
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextProjectID := input.NextProjectID
|
||||
if nextProjectID == 0 {
|
||||
nextProjectID = task.ProjectID
|
||||
}
|
||||
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projectIdentity, err := projectIdentity(nextProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIdentity, err := optionalTagIdentity(tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
if input.Completed {
|
||||
status = "done"
|
||||
} else {
|
||||
status = "open"
|
||||
}
|
||||
}
|
||||
task.ProjectID = nextProjectID
|
||||
task.ProjectIdentity = projectIdentity
|
||||
task.Title = title
|
||||
task.Description = strings.TrimSpace(input.Description)
|
||||
task.Status = status
|
||||
task.TagID = tagID
|
||||
task.TagIdentity = tagIdentity
|
||||
if err := models.DBService.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
@@ -206,21 +300,55 @@ func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronP
|
||||
return plan, models.DBService.Create(plan).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CreateTag(projectID, name)
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
}
|
||||
var existing models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||
return tag, models.DBService.Create(tag).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTags(projectID)
|
||||
}
|
||||
|
||||
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
tag, err := s.CreateTag(projectID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return Dashboard{}, err
|
||||
@@ -284,8 +412,11 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
|
||||
Project: WorkbenchProject{
|
||||
ID: project.ID,
|
||||
Name: project.Name,
|
||||
Identifier: project.Identifier,
|
||||
Icon: project.Icon,
|
||||
Background: project.Background,
|
||||
Description: project.Description,
|
||||
Initials: projectInitials(project.Name),
|
||||
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
|
||||
UnreadCount: counts.inbox,
|
||||
},
|
||||
Channels: channels,
|
||||
@@ -409,6 +540,10 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagNames, err := taskTagNames(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkTask, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
||||
@@ -423,12 +558,58 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
||||
Owner: owner,
|
||||
Due: displayOptionalTime(task.DueAt),
|
||||
CreatedAt: displayTime(task.CreatedAt),
|
||||
Tag: "",
|
||||
Tag: tagNames[task.ID],
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
|
||||
names := make(map[uint]string)
|
||||
tagIDs := make([]uint, 0)
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
tagIDs = append(tagIDs, *task.TagID)
|
||||
}
|
||||
}
|
||||
if len(tagIDs) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var tags []models.SenlinAgentTag
|
||||
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagByID := make(map[uint]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagByID[tag.ID] = tag.Name
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
names[task.ID] = tagByID[*task.TagID]
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func projectIdentity(projectID uint) (string, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return project.Identity, nil
|
||||
}
|
||||
|
||||
func optionalTagIdentity(tagID *uint) (*string, error) {
|
||||
if tagID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var tag models.SenlinAgentTag
|
||||
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.Identity, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
|
||||
|
||||
@@ -30,6 +30,33 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||
require.Equal(t, first.ID, firstTags[0].ProjectID)
|
||||
}
|
||||
|
||||
func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
|
||||
project, err := service.CreateProjectWithInput(1, CreateProjectInput{
|
||||
Name: "探索项目",
|
||||
Identifier: "EXP",
|
||||
Icon: "compass",
|
||||
Background: "#165DFF",
|
||||
Description: "RSS 采集和线索沉淀",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "探索项目", project.Name)
|
||||
require.Equal(t, "EXP", project.Identifier)
|
||||
require.Equal(t, "compass", project.Icon)
|
||||
require.Equal(t, "#165DFF", project.Background)
|
||||
require.Equal(t, "RSS 采集和线索沉淀", project.Description)
|
||||
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "EXP", workspace.Project.Identifier)
|
||||
require.Equal(t, "compass", workspace.Project.Icon)
|
||||
require.Equal(t, "#165DFF", workspace.Project.Background)
|
||||
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
|
||||
}
|
||||
|
||||
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
@@ -140,6 +167,7 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
||||
Description: "整理客户反馈并形成行动项",
|
||||
Status: "",
|
||||
DueAt: &dueAt,
|
||||
Tag: "客户反馈",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -148,10 +176,13 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
||||
require.Equal(t, "Follow up with client", task.Title)
|
||||
require.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
require.NotNil(t, task.TagID)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.Tasks, 1)
|
||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
||||
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
@@ -165,6 +196,58 @@ func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
|
||||
Title: "Draft proposal v2",
|
||||
Description: "Updated scope",
|
||||
Completed: true,
|
||||
Tag: "重要",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Draft proposal v2", updated.Title)
|
||||
require.Equal(t, "done", updated.Status)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "重要", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, []string{"all", "客户", "重要"}, workspace.Tags)
|
||||
}
|
||||
|
||||
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
tag, err := service.CreateProjectTag(1, project.ID, "Design")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, tag.ProjectID)
|
||||
require.Equal(t, "Design", tag.Name)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"all", "Design"}, workspace.Tags)
|
||||
}
|
||||
|
||||
func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.CreateProjectTag(1, project.ID, "Hidden")
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
|
||||
@@ -41,6 +41,9 @@ func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveOptionalIdentity(tx, &SenlinAgentTag{}, m.TagID, &m.TagIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ type SenlinAgentProject struct {
|
||||
OwnerID uint `gorm:"index;not null"`
|
||||
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||
Name string `gorm:"not null"`
|
||||
Identifier string `gorm:"index"`
|
||||
Icon string
|
||||
Background string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
@@ -11,6 +11,8 @@ type SenlinAgentTask struct {
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
AssigneeID *uint `gorm:"index"`
|
||||
AssigneeIdentity *string `gorm:"type:char(36);index"`
|
||||
TagID *uint `gorm:"index"`
|
||||
TagIdentity *string `gorm:"type:char(36);index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
|
||||
@@ -131,6 +131,18 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
uiTagID, err := tagID(tx, projectID, "UI")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feedbackTagID, err := tagID(tx, projectID, "客户反馈")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
automationTagID, err := tagID(tx, projectID, "自动化")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range []models.SenlinAgentInboxItem{
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
|
||||
@@ -142,9 +154,9 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
||||
}
|
||||
}
|
||||
for _, task := range []models.SenlinAgentTask{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &uiTagID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &automationTagID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &feedbackTagID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||
} {
|
||||
if err := firstOrCreateTask(tx, task); err != nil {
|
||||
return err
|
||||
@@ -209,12 +221,31 @@ func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
|
||||
}
|
||||
|
||||
func tagID(tx *gorm.DB, projectID uint, name string) (uint, error) {
|
||||
var tag models.SenlinAgentTag
|
||||
if err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.ID, nil
|
||||
}
|
||||
|
||||
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
||||
}
|
||||
|
||||
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
|
||||
var existing models.SenlinAgentTask
|
||||
err := tx.Where("project_id = ? AND title = ?", task.ProjectID, task.Title).First(&existing).Error
|
||||
if err == nil {
|
||||
if task.TagID != nil && existing.TagID == nil {
|
||||
return tx.Model(&existing).Update("tag_id", *task.TagID).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&task).Error
|
||||
}
|
||||
|
||||
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
|
||||
|
||||
Reference in New Issue
Block a user