feat: complete project workspace tags and notes UI

This commit is contained in:
2026-07-21 00:46:09 +08:00
parent 54958382f1
commit c613d4c997
28 changed files with 1621 additions and 284 deletions

View File

@@ -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 {