From 53e2602df4754185ed0abb50541f7e653e5ffd2e Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Thu, 23 Apr 2026 19:46:33 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.38.dev.260423=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20=E5=9B=9B=E8=B1=A1=E9=99=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=96=B0=E5=A2=9E=E4=BF=AE=E6=94=B9=E4=B8=8E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=8E=A5=E5=8F=A3=E2=80=94=E2=80=94=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AF=AD=E4=B9=89=20+=20=E7=A1=AC=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20+=20=E5=B9=82=E7=AD=89=E4=BF=A1=E6=81=AF=E7=A0=81?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20PUT/task/update=EF=BC=9A=E6=8C=87?= =?UTF-8?q?=E9=92=88=E5=AD=97=E6=AE=B5=E9=83=A8=E5=88=86=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=88title=20/=20priority=5Fgroup=20/=20deadline=5Fat=20/?= =?UTF-8?q?=20urgency=5Fthreshold=5Fat=EF=BC=89=EF=BC=8C=E4=BC=98=E5=85=88?= =?UTF-8?q?=E7=BA=A7=201~4=20=E6=A0=A1=E9=AA=8C=EF=BC=8C=E7=A9=BA=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=B5=8B=20-=20=E6=96=B0=E5=A2=9E=20DELETE?= =?UTF-8?q?=20/task/delete=EF=BC=9A=E7=A1=AC=E5=88=A0=E9=99=A4=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=88=A0=E9=99=A4=E8=BF=94=E5=9B=9E=2010003?= =?UTF-8?q?=20=E5=B9=82=E7=AD=89=E4=BF=A1=E6=81=AF=E7=A0=81=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=94=99=E8=AF=AF=E7=A0=81=20TaskUpdateNoFie?= =?UTF-8?q?lds=20(40063)=20=E4=B8=8E=20TaskAlreadyDeleted=20(10003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 1. 四象限卡片对接修改与删除 - 任务项重构为三区布局:勾选、内容点击编辑、悬浮删除按钮 - 创建弹窗复用为编辑模式,新增 urgency_threshold_at 字段 - 删除走二次确认弹窗,空状态增加 SVG 插画 2. 今日时间轴微调——色调简化为取模轮换,午休/晚餐改称午间/晚休 --- backend/api/task.go | 74 ++- backend/conv/task.go | 21 + backend/dao/task.go | 122 ++++- backend/model/task.go | 36 +- backend/respond/respond.go | 12 +- backend/routers/routers.go | 2 + backend/service/task.go | 119 ++++- docs/apifox/task-update-delete-integration.md | 209 +++++++++ frontend/src/api/task.ts | 36 +- .../components/dashboard/TaskQuadrantCard.vue | 278 +++++++----- .../components/dashboard/TodayTimeline.vue | 422 ++++++------------ frontend/src/router/index.ts | 8 +- frontend/src/types/dashboard.ts | 15 + frontend/src/views/DashboardView.vue | 168 +++++-- frontend/src/views/TaskInteractiveDemo.vue | 356 +++++++++++++++ 15 files changed, 1388 insertions(+), 490 deletions(-) create mode 100644 docs/apifox/task-update-delete-integration.md create mode 100644 frontend/src/views/TaskInteractiveDemo.vue diff --git a/backend/api/task.go b/backend/api/task.go index 56762ab..cd15c3d 100644 --- a/backend/api/task.go +++ b/backend/api/task.go @@ -85,7 +85,7 @@ func (th *TaskHandler) CompleteTask(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) defer cancel() - // 4. 调用 Service 执行“标记完成”逻辑。 + // 4. 调用 Service 执行"标记完成"逻辑。 resp, err := th.svc.CompleteTask(ctx, &req, userID) if err != nil { respond.DealWithError(c, err) @@ -96,12 +96,12 @@ func (th *TaskHandler) CompleteTask(c *gin.Context) { c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) } -// UndoCompleteTask 取消任务“已完成”勾选。 +// UndoCompleteTask 取消任务"已完成"勾选。 // // 职责边界: // 1. 负责解析请求与读取 user_id; // 2. 负责调用 Service 执行业务恢复; -// 3. 不负责“任务是否已完成”的业务判断(由 Service/DAO 负责)。 +// 3. 不负责"任务是否已完成"的业务判断(由 Service/DAO 负责)。 func (th *TaskHandler) UndoCompleteTask(c *gin.Context) { // 1. 绑定请求参数。 var req model.UserUndoCompleteTaskRequest @@ -118,7 +118,7 @@ func (th *TaskHandler) UndoCompleteTask(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) defer cancel() - // 4. 调用 Service 执行“取消已完成勾选”逻辑。 + // 4. 调用 Service 执行"取消已完成勾选"逻辑。 resp, err := th.svc.UndoCompleteTask(ctx, &req, userID) if err != nil { respond.DealWithError(c, err) @@ -128,3 +128,69 @@ func (th *TaskHandler) UndoCompleteTask(c *gin.Context) { // 5. 返回统一响应结构。 c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) } + +// UpdateTask 更新任务属性(部分更新)。 +// +// 职责边界: +// 1. 负责解析请求与读取 user_id; +// 2. 负责调用 Service 执行业务; +// 3. 不负责幂等校验(幂等由路由中间件处理)。 +func (th *TaskHandler) UpdateTask(c *gin.Context) { + // 1. 绑定请求参数。 + var req model.UserUpdateTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + fmt.Println(err) + return + } + + // 2. 从鉴权上下文读取 user_id,保证只操作当前用户任务。 + userID := c.GetInt("user_id") + + // 3. 设置短超时,避免该写接口占用连接过久。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + + // 4. 调用 Service 执行更新逻辑。 + resp, err := th.svc.UpdateTask(ctx, &req, userID) + if err != nil { + respond.DealWithError(c, err) + return + } + + // 5. 返回统一响应结构。 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) +} + +// DeleteTask 永久删除指定任务。 +// +// 职责边界: +// 1. 负责解析请求与读取 user_id; +// 2. 负责调用 Service 执行删除; +// 3. 不负责幂等校验(幂等由路由中间件处理)。 +func (th *TaskHandler) DeleteTask(c *gin.Context) { + // 1. 绑定请求参数。 + var req model.UserCompleteTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + fmt.Println(err) + return + } + + // 2. 从鉴权上下文读取 user_id,保证只操作当前用户任务。 + userID := c.GetInt("user_id") + + // 3. 设置短超时,避免该写接口占用连接过久。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + + // 4. 调用 Service 执行删除逻辑。 + taskID, err := th.svc.DeleteTask(ctx, &req, userID) + if err != nil { + respond.DealWithError(c, err) + return + } + + // 5. 返回统一响应结构。 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, gin.H{"task_id": taskID})) +} diff --git a/backend/conv/task.go b/backend/conv/task.go index 3f43057..5ba5c4f 100644 --- a/backend/conv/task.go +++ b/backend/conv/task.go @@ -55,3 +55,24 @@ func ModelToGetUserTasksResp(tasks []model.Task) []model.GetUserTaskResp { } return resp } + +// ModelToGetUserTaskResp 将单个 Task 模型转换为 GetUserTaskResp。 +func ModelToGetUserTaskResp(task *model.Task) model.GetUserTaskResp { + status := "incomplete" + if task.IsCompleted { + status = "completed" + } + deadline := "" + if task.DeadlineAt != nil { + deadline = task.DeadlineAt.Format("2006-01-02 15:04:05") + } + return model.GetUserTaskResp{ + ID: task.ID, + UserID: task.UserID, + Title: task.Title, + PriorityGroup: task.Priority, + Status: status, + Deadline: deadline, + IsCompleted: task.IsCompleted, + } +} diff --git a/backend/dao/task.go b/backend/dao/task.go index b7d392c..ac8ec98 100644 --- a/backend/dao/task.go +++ b/backend/dao/task.go @@ -46,10 +46,10 @@ func (dao *TaskDAO) GetTasksByUserID(userID int) ([]model.Task, error) { return tasks, nil } -// CompleteTaskByID 将指定任务标记为“已完成”。 +// CompleteTaskByID 将指定任务标记为"已完成"。 // // 职责边界: -// 1. 只负责“当前用户 + 指定 task_id”的完成状态更新; +// 1. 只负责"当前用户 + 指定 task_id"的完成状态更新; // 2. 不负责幂等中间件(由路由层统一挂载); // 3. 不负责业务层响应包装(由 Service 层处理)。 // @@ -62,12 +62,12 @@ func (dao *TaskDAO) GetTasksByUserID(userID int) ([]model.Task, error) { // 3.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户; // 3.2 其他 error:数据库异常。 func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, bool, error) { - // 1. 基础兜底:非法参数直接返回“记录不存在”语义,避免下游误写。 + // 1. 基础兜底:非法参数直接返回"记录不存在"语义,避免下游误写。 if userID <= 0 || taskID <= 0 { return nil, false, gorm.ErrRecordNotFound } - // 2. 先查询目标任务,明确区分“已完成”与“不存在”。 + // 2. 先查询目标任务,明确区分"已完成"与"不存在"。 var target model.Task findErr := dao.db.WithContext(ctx). Where("id = ? AND user_id = ?", taskID, userID). @@ -116,7 +116,7 @@ func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int return &target, false, nil } -// UndoCompleteTaskByID 将指定任务从“已完成”恢复为“未完成”。 +// UndoCompleteTaskByID 将指定任务从"已完成"恢复为"未完成"。 // // 职责边界: // 1. 只负责当前用户(user_id)下指定 task_id 的状态恢复; @@ -127,15 +127,15 @@ func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int // 1. *model.Task:恢复后的任务快照; // 2. error: // 2.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户; -// 2.2 respond.TaskNotCompleted:任务当前不是“已完成”状态,不能执行取消勾选; +// 2.2 respond.TaskNotCompleted:任务当前不是"已完成"状态,不能执行取消勾选; // 2.3 其他 error:数据库异常。 func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, error) { - // 1. 参数兜底:非法 user/task 参数统一按“记录不存在”处理,避免误写。 + // 1. 参数兜底:非法 user/task 参数统一按"记录不存在"处理,避免误写。 if userID <= 0 || taskID <= 0 { return nil, gorm.ErrRecordNotFound } - // 2. 先读取目标任务,明确区分“不存在”和“状态不允许恢复”。 + // 2. 先读取目标任务,明确区分"不存在"和"状态不允许恢复"。 var target model.Task findErr := dao.db.WithContext(ctx). Where("id = ? AND user_id = ?", taskID, userID). @@ -145,7 +145,7 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID } // 3. 严格业务约束:若任务当前未完成,直接返回业务错误。 - // 3.1 这是本接口和“标记完成”接口的关键差异:这里不做幂等成功。 + // 3.1 这是本接口和"标记完成"接口的关键差异:这里不做幂等成功。 if !target.IsCompleted { return nil, respond.TaskNotCompleted } @@ -164,7 +164,7 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID // 5. 并发兜底: // 5.1 若 RowsAffected=0,说明可能被并发请求先一步恢复; - // 5.2 重新读取当前状态,若已是未完成则按业务规则返回“任务未完成”错误。 + // 5.2 重新读取当前状态,若已是未完成则按业务规则返回"任务未完成"错误。 if updateResult.RowsAffected == 0 { var check model.Task checkErr := dao.db.WithContext(ctx). @@ -184,10 +184,10 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID return &target, nil } -// PromoteTaskUrgencyByIDs 批量执行“任务紧急性平移”。 +// PromoteTaskUrgencyByIDs 批量执行"任务紧急性平移"。 // // 职责边界: -// 1. 只负责把满足条件的任务从“不紧急象限”平移到“紧急象限”: +// 1. 只负责把满足条件的任务从"不紧急象限"平移到"紧急象限": // 1.1 priority=2 -> 1(重要不紧急 -> 重要且紧急); // 1.2 priority=4 -> 3(不简单不重要 -> 简单不重要); // 2. 只更新本次指定 user_id + task_ids 范围内的数据; @@ -209,7 +209,7 @@ func (dao *TaskDAO) PromoteTaskUrgencyByIDs(ctx context.Context, userID int, tas return 0, nil } - // 3. 条件更新:只更新“已到紧急分界线且仍处于非紧急象限”的任务。 + // 3. 条件更新:只更新"已到紧急分界线且仍处于非紧急象限"的任务。 result := dao.db.WithContext(ctx). Model(&model.Task{UserID: userID}). Where("user_id = ?", userID). @@ -225,7 +225,101 @@ func (dao *TaskDAO) PromoteTaskUrgencyByIDs(ctx context.Context, userID int, tas return result.RowsAffected, nil } -// compactPositiveIntIDs 对 int 切片做“去重 + 过滤非正数”。 +// UpdateTaskByID 按 task_id + user_id 更新指定字段。 +// +// 职责边界: +// 1. 只负责按 updates map 执行 SET 子句更新; +// 2. 不负责业务规则(如优先级范围校验),由 Service 层处理; +// 3. 使用 Model(&model.Task{UserID: userID}) 让 cache_deleter 回调拿到 user_id。 +// +// 返回语义: +// 1. *model.Task:更新后的完整任务快照; +// 2. error: +// 2.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户; +// 2.2 其他 error:数据库异常。 +func (dao *TaskDAO) UpdateTaskByID(ctx context.Context, userID int, taskID int, updates map[string]interface{}) (*model.Task, error) { + // 1. 参数兜底:非法参数直接返回"记录不存在"语义。 + if userID <= 0 || taskID <= 0 { + return nil, gorm.ErrRecordNotFound + } + + // 2. 先查询目标任务,确认存在且归属当前用户。 + var target model.Task + findErr := dao.db.WithContext(ctx). + Where("id = ? AND user_id = ?", taskID, userID). + First(&target).Error + if findErr != nil { + return nil, findErr + } + + // 3. 执行部分字段更新。 + // 3.1 使用 Model(&model.Task{UserID: userID}) 触发 cache_deleter。 + // 3.2 限定 id + user_id 条件,避免误更新。 + updateResult := dao.db.WithContext(ctx). + Model(&model.Task{UserID: userID}). + Where("id = ? AND user_id = ?", taskID, userID). + Updates(updates) + if updateResult.Error != nil { + return nil, updateResult.Error + } + + // 4. 更新后重新读取,保证返回完整且一致的快照。 + var updated model.Task + if err := dao.db.WithContext(ctx). + Where("id = ? AND user_id = ?", taskID, userID). + First(&updated).Error; err != nil { + return nil, err + } + + return &updated, nil +} + +// DeleteTaskByID 永久删除指定任务(硬删除)。 +// +// 职责边界: +// 1. 只负责删除 user_id + task_id 对应的记录; +// 2. 使用 Model(&model.Task{UserID: userID}) 触发 cache_deleter 删除用户任务缓存; +// 3. 不负责级联清理日程(tasks 与 schedule_events 无直接外键关联)。 +// +// 返回语义: +// 1. *model.Task:被删除的任务快照(用于响应前端); +// 2. error: +// 2.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户; +// 2.2 其他 error:数据库异常。 +func (dao *TaskDAO) DeleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, error) { + // 1. 参数兜底。 + if userID <= 0 || taskID <= 0 { + return nil, gorm.ErrRecordNotFound + } + + // 2. 先查询目标任务,确认存在且归属当前用户,同时获取快照用于响应。 + var target model.Task + findErr := dao.db.WithContext(ctx). + Where("id = ? AND user_id = ?", taskID, userID). + First(&target).Error + if findErr != nil { + return nil, findErr + } + + // 3. 执行硬删除。 + // 3.1 使用 Model(&model.Task{UserID: userID}) 触发 cache_deleter。 + deleteResult := dao.db.WithContext(ctx). + Model(&model.Task{UserID: userID}). + Where("id = ? AND user_id = ?", taskID, userID). + Delete(&model.Task{}) + if deleteResult.Error != nil { + return nil, deleteResult.Error + } + + // 4. 并发兜底:RowsAffected=0 说明被并发请求先一步删除。 + if deleteResult.RowsAffected == 0 { + return nil, gorm.ErrRecordNotFound + } + + return &target, nil +} + +// compactPositiveIntIDs 对 int 切片做"去重 + 过滤非正数"。 // // 说明: // 1. 该函数是 DAO 内部参数清洗工具,不参与任何业务判定; diff --git a/backend/model/task.go b/backend/model/task.go index 99a0c7a..31b116b 100644 --- a/backend/model/task.go +++ b/backend/model/task.go @@ -7,13 +7,13 @@ import "time" // 职责边界: // 1. 负责映射 tasks 表字段; // 2. 不负责接口入参校验和业务规则判断; -// 3. 不负责“自动平移”执行(自动平移由 Service + Outbox 事件链路负责)。 +// 3. 不负责"自动平移"执行(自动平移由 Service + Outbox 事件链路负责)。 type Task struct { // 1. 主键。 ID int `gorm:"primaryKey;autoIncrement"` // 2. 归属用户 ID。 // 2.1 单列索引用于常规按用户查任务; - // 2.2 同时参与“懒触发平移”复合索引的最左前缀。 + // 2.2 同时参与"懒触发平移"复合索引的最左前缀。 UserID int `gorm:"column:user_id;index;index:idx_user_done_threshold_priority,priority:1"` // 3. 任务标题。 Title string `gorm:"type:varchar(255)"` @@ -23,7 +23,7 @@ type Task struct { // 4.3 3=简单不重要; // 4.4 4=不简单不重要。 // - // 说明:该字段参与“懒触发平移”复合索引。 + // 说明:该字段参与"懒触发平移"复合索引。 Priority int `gorm:"not null;index:idx_user_done_threshold_priority,priority:4"` // 5. 完成状态。 // @@ -34,10 +34,10 @@ type Task struct { // 7. 紧急分界时间(自动平移阈值)。 // // 规则: - // 7.1 到达该时间后,任务可从“不紧急象限”自动平移到“紧急象限”; + // 7.1 到达该时间后,任务可从"不紧急象限"自动平移到"紧急象限"; // 7.2 该值由上游(例如 LLM 规划)给出,不在模型层做推断; // 7.3 为空表示该任务不参与自动平移; - // 7.4 该字段参与“懒触发平移”复合索引。 + // 7.4 该字段参与"懒触发平移"复合索引。 UrgencyThresholdAt *time.Time `gorm:"column:urgency_threshold_at;index:idx_user_done_threshold_priority,priority:3"` } @@ -56,7 +56,7 @@ type UserAddTaskRequest struct { DeadlineAt *time.Time `json:"deadline_at"` } -// UserCompleteTaskRequest 是“标记任务完成”接口的请求体。 +// UserCompleteTaskRequest 是"标记任务完成"接口的请求体。 // // 职责边界: // 1. 只承载目标任务 ID; @@ -65,7 +65,7 @@ type UserCompleteTaskRequest struct { TaskID int `json:"task_id"` } -// UserCompleteTaskResponse 是“标记任务完成”接口的响应体。 +// UserCompleteTaskResponse 是"标记任务完成"接口的响应体。 // // 字段语义: // 1. TaskID:本次操作的目标任务; @@ -81,7 +81,7 @@ type UserCompleteTaskResponse struct { Status string `json:"status"` } -// UserUndoCompleteTaskRequest 是“取消任务已完成勾选”接口请求体。 +// UserUndoCompleteTaskRequest 是"取消任务已完成勾选"接口请求体。 // // 职责边界: // 1. 只承载目标 task_id; @@ -90,7 +90,7 @@ type UserUndoCompleteTaskRequest struct { TaskID int `json:"task_id"` } -// UserUndoCompleteTaskResponse 是“取消任务已完成勾选”接口响应体。 +// UserUndoCompleteTaskResponse 是"取消任务已完成勾选"接口响应体。 // // 字段语义: // 1. TaskID:本次操作目标任务; @@ -112,10 +112,24 @@ type GetUserTaskResp struct { IsCompleted bool `json:"is_completed"` } -// TaskUrgencyPromoteRequestedPayload 是“任务紧急性平移请求”事件载荷。 +// UserUpdateTaskRequest 是"更新任务属性"接口的请求体。 // // 职责边界: -// 1. 只承载“哪个用户的哪些任务需要尝试平移”; +// 1. 指针字段表示"部分更新"语义:nil 表示不修改,非 nil 表示更新为指定值; +// 2. TaskID 为必填; +// 3. 不承载 user_id(由鉴权中间件注入,防止越权)。 +type UserUpdateTaskRequest struct { + TaskID int `json:"task_id"` + Title *string `json:"title"` + PriorityGroup *int `json:"priority_group"` + DeadlineAt *time.Time `json:"deadline_at"` + UrgencyThresholdAt *time.Time `json:"urgency_threshold_at"` +} + +// TaskUrgencyPromoteRequestedPayload 是"任务紧急性平移请求"事件载荷。 +// +// 职责边界: +// 1. 只承载"哪个用户的哪些任务需要尝试平移"; // 2. 不包含 outbox/kafka 协议字段(这些由基础设施层统一封装); // 3. TriggeredAt 只用于追踪触发时间,最终是否更新仍以消费时数据库条件为准。 type TaskUrgencyPromoteRequestedPayload struct { diff --git a/backend/respond/respond.go b/backend/respond/respond.go index 0e7f886..3856021 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -37,7 +37,7 @@ func DealWithError(c *gin.Context, err error) { //处理错误,返回对应的 return } var resp Response - if errors.Is(err, UserTasksEmpty) || errors.Is(err, NoOngoingOrUpcomingSchedule) { + if errors.Is(err, UserTasksEmpty) || errors.Is(err, NoOngoingOrUpcomingSchedule) || errors.Is(err, TaskAlreadyDeleted) { c.JSON(http.StatusOK, err) return } @@ -379,6 +379,16 @@ var ( //请求相关的响应 Info: "duplicate task_item_id in request", } + TaskUpdateNoFields = Response{ //更新任务未指定任何字段 + Status: "40063", + Info: "no fields to update", + } + + TaskAlreadyDeleted = Response{ //任务已删除或不存在(幂等信息码) + Status: "10003", + Info: "task already deleted or not found", + } + RouteControlInternalError = Response{ //路由控制码内部错误 Status: "50001", Info: "route control failed", diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 55d5e19..f09ab8b 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -55,6 +55,8 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d taskGroup.POST("/create", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask) taskGroup.PUT("/complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.CompleteTask) taskGroup.PUT("/undo-complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UndoCompleteTask) + taskGroup.PUT("/update", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UpdateTask) + taskGroup.DELETE("/delete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.DeleteTask) taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks) } courseGroup := apiGroup.Group("/course") diff --git a/backend/service/task.go b/backend/service/task.go index 53e8caf..c6e22b6 100644 --- a/backend/service/task.go +++ b/backend/service/task.go @@ -18,7 +18,7 @@ import ( ) const ( - // taskUrgencyPromoteDedupeTTL 是“同一任务平移请求”的去重锁有效期。 + // taskUrgencyPromoteDedupeTTL 是"同一任务平移请求"的去重锁有效期。 // // 设计考虑: // 1. 太短会导致消费稍慢时被重复投递; @@ -55,7 +55,7 @@ func NewTaskService(taskDAO *dao.TaskDAO, cacheDAO *dao.CacheDAO, eventPublisher // // 职责边界: // 1. 负责参数转换、优先级合法性校验与写库; -// 2. 不负责“紧急性自动平移”逻辑(该逻辑发生在任务读取时的懒触发链路)。 +// 2. 不负责"紧急性自动平移"逻辑(该逻辑发生在任务读取时的懒触发链路)。 func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskRequest, userID int) (*model.UserAddTaskResponse, error) { // 1. 把用户请求转换为内部模型,避免 API 层结构直接泄漏到 DAO。 taskModel := conv.UserAddTaskRequestToModel(req, userID) @@ -73,7 +73,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques return response, nil } -// CompleteTask 将用户指定任务标记为“已完成”。 +// CompleteTask 将用户指定任务标记为"已完成"。 // // 职责边界: // 1. 负责入参校验与业务错误映射; @@ -86,7 +86,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete return nil, respond.WrongTaskID } - // 2. 调用 DAO 执行“查询 + 必要时更新”。 + // 2. 调用 DAO 执行"查询 + 必要时更新"。 updatedTask, alreadyCompleted, err := ts.dao.CompleteTaskByID(ctx, userID, req.TaskID) if err != nil { // 2.1 任务不存在或不属于当前用户时,统一映射为 WrongTaskID。 @@ -113,7 +113,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete return resp, nil } -// UndoCompleteTask 取消用户任务的“已完成勾选”。 +// UndoCompleteTask 取消用户任务的"已完成勾选"。 // // 职责边界: // 1. 负责入参校验与业务错误映射; @@ -126,7 +126,7 @@ func (ts *TaskService) UndoCompleteTask(ctx context.Context, req *model.UserUndo return nil, respond.WrongTaskID } - // 2. 调用 DAO 执行“恢复未完成”逻辑。 + // 2. 调用 DAO 执行"恢复未完成"逻辑。 updatedTask, err := ts.dao.UndoCompleteTaskByID(ctx, userID, req.TaskID) if err != nil { // 2.1 任务不存在或不属于当前用户,统一映射为 WrongTaskID。 @@ -154,12 +154,12 @@ func (ts *TaskService) UndoCompleteTask(ctx context.Context, req *model.UserUndo return resp, nil } -// GetUserTasks 获取用户任务列表(含“读时紧急性派生”与“异步平移触发”)。 +// GetUserTasks 获取用户任务列表(含"读时紧急性派生"与"异步平移触发")。 // // 核心流程(步骤化): -// 1. 先读缓存,未命中再回源 DB,并把“原始模型”回填缓存; -// 2. 在内存里做“读时派生”:仅用于本次返回给前端,不直接改库; -// 3. 收集“已到紧急分界线且仍处于非紧急象限”的任务 ID; +// 1. 先读缓存,未命中再回源 DB,并把"原始模型"回填缓存; +// 2. 在内存里做"读时派生":仅用于本次返回给前端,不直接改库; +// 3. 收集"已到紧急分界线且仍处于非紧急象限"的任务 ID; // 4. 通过 Redis SETNX 去重后,发布 outbox 事件异步落库; // 5. 无论发布成功与否,都优先返回本次派生结果,保证用户读体验。 // @@ -189,7 +189,7 @@ func (ts *TaskService) GetTasksWithUrgencyPromotion(ctx context.Context, userID return derivedTasks, nil } -// getRawUserTasks 读取“原始任务模型”。 +// getRawUserTasks 读取"原始任务模型"。 // // 职责边界: // 1. 负责缓存命中/回源 DB/回填缓存; @@ -220,16 +220,16 @@ func (ts *TaskService) getRawUserTasks(ctx context.Context, userID int) ([]model return dbTasks, nil } -// deriveTaskUrgencyForRead 对任务做“读时紧急性派生”,并收集需要异步落库的任务 ID。 +// deriveTaskUrgencyForRead 对任务做"读时紧急性派生",并收集需要异步落库的任务 ID。 // // 职责边界: // 1. 只在内存里改本次返回值,不写 DB; -// 2. 只做“到线且未完成任务”的优先级映射; +// 2. 只做"到线且未完成任务"的优先级映射; // 3. 不处理去重锁和事件发布。 // // 返回语义: // 1. 第一个返回值:可直接用于响应前端的派生任务切片; -// 2. 第二个返回值:需要发“异步平移事件”的任务 ID 列表(可能为空)。 +// 2. 第二个返回值:需要发"异步平移事件"的任务 ID 列表(可能为空)。 func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, []int) { // 1. 拷贝切片,避免修改调用方持有的原始数据。 derived := make([]model.Task, len(tasks)) @@ -237,7 +237,7 @@ func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, pendingPromoteTaskIDs := make([]int, 0, len(derived)) - // 2. 逐条判断是否满足“自动平移”条件。 + // 2. 逐条判断是否满足"自动平移"条件。 for idx := range derived { current := &derived[idx] @@ -254,7 +254,7 @@ func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, continue } - // 2.4 到线后,仅把“不紧急象限”平移到对应“紧急象限”。 + // 2.4 到线后,仅把"不紧急象限"平移到对应"紧急象限"。 // 2.4.1 重要不紧急(2) -> 重要且紧急(1) // 2.4.2 不简单不重要(4) -> 简单不重要(3) switch current.Priority { @@ -271,12 +271,12 @@ func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, return derived, pendingPromoteTaskIDs } -// tryEnqueueTaskUrgencyPromote 尝试发布“任务紧急性平移请求”事件。 +// tryEnqueueTaskUrgencyPromote 尝试发布"任务紧急性平移请求"事件。 // // 职责边界: // 1. 负责 Redis 去重锁 + outbox 发布; // 2. 不负责真正落库(由消费者负责); -// 3. 发布失败时要释放本次抢到的去重锁,避免任务被长时间“误判已投递”。 +// 3. 发布失败时要释放本次抢到的去重锁,避免任务被长时间"误判已投递"。 func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID int, taskIDs []int) { // 1. 基础兜底:无发布器或无候选任务时直接返回。 if ts.eventPublisher == nil || userID <= 0 || len(taskIDs) == 0 { @@ -312,7 +312,7 @@ func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID return } - // 4. 发布 outbox 事件:这里只保证“成功入 outbox 或返回错误”,不等待消费者执行完成。 + // 4. 发布 outbox 事件:这里只保证"成功入 outbox 或返回错误",不等待消费者执行完成。 publishErr := eventsvc.PublishTaskUrgencyPromoteRequested(ctx, ts.eventPublisher, model.TaskUrgencyPromoteRequestedPayload{ UserID: userID, TaskIDs: lockedTaskIDs, @@ -331,7 +331,7 @@ func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID // releaseTaskPromoteLocks 释放任务平移去重锁。 // // 说明: -// 1. 仅用于“发布失败回滚”场景; +// 1. 仅用于"发布失败回滚"场景; // 2. 使用 Background 避免请求上下文已取消时导致锁释放失败。 func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) { if len(lockKeys) == 0 { @@ -347,7 +347,7 @@ func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) { } } -// compactPositiveUniqueTaskIDs 对任务 ID 做“过滤非正数 + 去重”。 +// compactPositiveUniqueTaskIDs 对任务 ID 做"过滤非正数 + 去重"。 // // 职责边界: // 1. 只做参数清洗; @@ -367,3 +367,80 @@ func compactPositiveUniqueTaskIDs(taskIDs []int) []int { } return result } + +// UpdateTask 更新用户指定任务的属性(部分更新)。 +// +// 职责边界: +// 1. 负责参数校验:task_id 合法性、priority_group 范围; +// 2. 负责将请求 DTO 转换为 DAO 层的 updates map; +// 3. 空请求体(无字段需要更新)返回明确业务错误; +// 4. 不负责缓存删除(由 GORM cache_deleter 回调自动处理)。 +func (ts *TaskService) UpdateTask(ctx context.Context, req *model.UserUpdateTaskRequest, userID int) (model.GetUserTaskResp, error) { + // 1. 参数兜底。 + if req == nil || userID <= 0 || req.TaskID <= 0 { + return model.GetUserTaskResp{}, respond.WrongTaskID + } + + // 2. 构造 updates map:只有非 nil 的字段才写入。 + updates := make(map[string]interface{}) + if req.Title != nil { + updates["title"] = *req.Title + } + if req.PriorityGroup != nil { + // 2.1 优先级范围校验:当前任务体系只允许 1~4。 + if *req.PriorityGroup < 1 || *req.PriorityGroup > 4 { + return model.GetUserTaskResp{}, respond.InvalidPriority + } + // 2.2 JSON 字段名是 priority_group,数据库列名是 priority。 + updates["priority"] = *req.PriorityGroup + } + if req.DeadlineAt != nil { + updates["deadline_at"] = *req.DeadlineAt + } + if req.UrgencyThresholdAt != nil { + updates["urgency_threshold_at"] = *req.UrgencyThresholdAt + } + + // 3. 空更新检测:至少需要一个可更新字段。 + if len(updates) == 0 { + return model.GetUserTaskResp{}, respond.TaskUpdateNoFields + } + + // 4. 调用 DAO 执行更新。 + updatedTask, err := ts.dao.UpdateTaskByID(ctx, userID, req.TaskID, updates) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.GetUserTaskResp{}, respond.WrongTaskID + } + return model.GetUserTaskResp{}, err + } + + // 5. 转换为响应 DTO。 + return conv.ModelToGetUserTaskResp(updatedTask), nil +} + +// DeleteTask 永久删除用户指定任务。 +// +// 职责边界: +// 1. 负责入参校验与业务错误映射; +// 2. 负责调用 DAO 执行硬删除; +// 3. 任务不存在时返回幂等信息码(TaskAlreadyDeleted); +// 4. 不负责缓存删除(由 GORM cache_deleter 回调自动处理)。 +func (ts *TaskService) DeleteTask(ctx context.Context, req *model.UserCompleteTaskRequest, userID int) (int, error) { + // 1. 参数兜底。 + if req == nil || userID <= 0 || req.TaskID <= 0 { + return 0, respond.WrongTaskID + } + + // 2. 调用 DAO 执行删除。 + deletedTask, err := ts.dao.DeleteTaskByID(ctx, userID, req.TaskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 2.1 任务不存在或不属于当前用户:按幂等语义返回信息码。 + return 0, respond.TaskAlreadyDeleted + } + return 0, err + } + + return deletedTask.ID, nil +} diff --git a/docs/apifox/task-update-delete-integration.md b/docs/apifox/task-update-delete-integration.md new file mode 100644 index 0000000..365d085 --- /dev/null +++ b/docs/apifox/task-update-delete-integration.md @@ -0,0 +1,209 @@ +# 四象限任务编辑接口 — 前端对接文档 + +## 概述 + +新增两个任务编辑接口,用于在四象限卡片上直接修改任务属性和删除任务。 + +--- + +## 通用说明 + +### 认证 + +所有请求需在 Header 中携带 JWT access_token: + +``` +Authorization: Bearer +``` + +### 幂等键 + +写操作接口需在 Header 中携带 `X-Idempotency-Key`,由前端生成唯一字符串(如 UUID)。 + +- 同一用户 + 同一幂等键的重复请求只执行一次 +- 有效期 24 小时 +- 缺少该 Header 会返回 `40037` + +### 统一响应格式 + +```json +{ + "status": "10000", // 状态码:10000=成功,4xxxx=客户端错误 + "info": "success", // 描述文案 + "data": { ... } // 业务数据(仅成功时存在) +} +``` + +--- + +## 1. 更新任务属性 + +**PUT** `/api/v1/task/update` + +### 用途 + +修改任务的标题、象限、截止时间、紧急分界时间。**部分更新语义**:只传需要修改的字段,未传的字段保持不变。 + +### 请求头 + +| Header | 必填 | 说明 | +|--------|------|------| +| `Authorization` | 是 | `Bearer ` | +| `X-Idempotency-Key` | 是 | 幂等键(UUID) | + +### 请求体 + +```json +{ + "task_id": 42, + "title": "完成需求文档 v2", + "priority_group": 2, + "deadline_at": "2026-05-15T23:59:59+08:00", + "urgency_threshold_at": "2026-05-10T00:00:00+08:00" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `task_id` | int | **是** | 目标任务 ID | +| `title` | string? | 否 | 新标题,`null` 或不传表示不修改 | +| `priority_group` | int? | 否 | 新象限(1~4),`null` 或不传表示不修改 | +| `deadline_at` | datetime? | 否 | 新截止时间(ISO 8601),`null` 或不传表示不修改 | +| `urgency_threshold_at` | datetime? | 否 | 新紧急分界时间,`null` 或不传表示不修改 | + +**priority_group 取值:** + +| 值 | 含义 | +|----|------| +| 1 | 重要且紧急 | +| 2 | 重要不紧急 | +| 3 | 简单不重要 | +| 4 | 不简单不重要 | + +> 至少需要传一个可更新字段(title / priority_group / deadline_at / urgency_threshold_at),否则返回 `40063`。 + +### 响应示例 + +**成功(200):** + +```json +{ + "status": "10000", + "info": "success", + "data": { + "id": 42, + "user_id": 1, + "title": "完成需求文档 v2", + "priority_group": 2, + "status": "incomplete", + "deadline": "2026-05-15 23:59:59", + "is_completed": false + } +} +``` + +**注意:** 响应中的 `deadline` 是格式化后的字符串(`"YYYY-MM-DD HH:mm:ss"`),与请求中的 `deadline_at`(ISO 8601)格式不同。 + +### 错误码 + +| status | info | 触发条件 | +|--------|------|----------| +| 40005 | wrong param type | JSON 解析失败 / 字段类型不匹配 | +| 40018 | invalid priority | priority_group 不在 1~4 范围 | +| 40037 | missing idempotency key | 缺少 X-Idempotency-Key Header | +| 40050 | wrong task id | task_id 不存在或不属于当前用户 | +| 40063 | no fields to update | 除 task_id 外没有传任何可更新字段 | + +--- + +## 2. 删除任务 + +**DELETE** `/api/v1/task/delete` + +### 用途 + +永久删除指定任务(硬删除,不可恢复)。 + +### 请求头 + +| Header | 必填 | 说明 | +|--------|------|------| +| `Authorization` | 是 | `Bearer ` | +| `X-Idempotency-Key` | 是 | 幂等键(UUID) | + +### 请求体 + +```json +{ + "task_id": 42 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `task_id` | int | **是** | 要删除的任务 ID | + +### 响应示例 + +**删除成功(200):** + +```json +{ + "status": "10000", + "info": "success", + "data": { + "task_id": 42 + } +} +``` + +**重复删除 / 任务不存在(200,幂等信息码):** + +```json +{ + "status": "10003", + "info": "task already deleted or not found" +} +``` + +> 注意:`10003` 也是 HTTP 200,不是错误。前端收到 10003 可视为删除成功(幂等)。 + +### 错误码 + +| status | info | 触发条件 | +|--------|------|----------| +| 40005 | wrong param type | JSON 解析失败 / 字段类型不匹配 | +| 40037 | missing idempotency key | 缺少 X-Idempotency-Key Header | +| 40050 | wrong task id | task_id <= 0 | +| 10003 | task already deleted or not found | 任务已删除或不存在(幂等,HTTP 200) | + +--- + +## 前端对接建议 + +### 更新场景 + +1. 用户在四象限卡片上编辑标题、拖拽换象限、修改截止时间时,调用 `PUT /task/update` +2. 只传变化的字段,例如拖拽换象限时只传 `{ task_id, priority_group }` +3. 成功后用返回的 `data` 刷新本地状态,无需重新拉取列表 + +### 删除场景 + +1. 用户点击删除按钮时,调用 `DELETE /task/delete` +2. 收到 `10000` 或 `10003` 都视为成功,从本地列表移除该任务 +3. 不需要二次确认删除是否成功——幂等机制保证安全 + +### 幂等键生成 + +```javascript +// 推荐使用 crypto.randomUUID() +const idempotencyKey = crypto.randomUUID(); + +// 或使用 uuid 库 +import { v4 as uuidv4 } from 'uuid'; +const idempotencyKey = uuidv4(); +``` + +### Apifox 导入 + +OpenAPI 3.0 规范文件位于:`docs/apifox/task-update-delete.openapi.yaml` diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts index 6cda41f..fde2086 100644 --- a/frontend/src/api/task.ts +++ b/frontend/src/api/task.ts @@ -1,6 +1,6 @@ import http from '@/api/http' import type { ApiResponse } from '@/types/api' -import type { TaskCreatePayload, TaskCreateResult, TaskItem, TaskMutationResult } from '@/types/dashboard' +import type { TaskUpdatePayload, TaskCreatePayload, TaskCreateResult, TaskItem, TaskMutationResult } from '@/types/dashboard' import { createIdempotencyKey } from '@/utils/idempotency' import { extractErrorMessage } from '@/utils/http' @@ -59,3 +59,37 @@ export async function undoCompleteTask(taskId: number) { throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试')) } } + +export async function updateTask(payload: TaskUpdatePayload) { + try { + const response = await http.put>( + '/task/update', + payload, + { + headers: { + 'X-Idempotency-Key': createIdempotencyKey('task-update'), + }, + } + ) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '修改任务失败,请稍后重试')) + } +} + +export async function deleteTask(taskId: number) { + try { + const response = await http.delete>( + '/task/delete', + { + data: { task_id: taskId }, + headers: { + 'X-Idempotency-Key': createIdempotencyKey('task-delete'), + }, + } + ) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试')) + } +} diff --git a/frontend/src/components/dashboard/TaskQuadrantCard.vue b/frontend/src/components/dashboard/TaskQuadrantCard.vue index d1e1228..0cde0be 100644 --- a/frontend/src/components/dashboard/TaskQuadrantCard.vue +++ b/frontend/src/components/dashboard/TaskQuadrantCard.vue @@ -16,9 +16,10 @@ const props = defineProps<{ const emit = defineEmits<{ toggle: [task: TaskItem] + edit: [task: TaskItem] + delete: [task: TaskItem] }>() -// 不再硬截断,全部展示;超出的部分通过 quadrant-list 的 max-height + overflow-y 滚动查看。 const visibleTasks = computed(() => props.tasks) @@ -38,29 +39,51 @@ const visibleTasks = computed(() => props.tasks)
- {{ emptyText }} +
+ + + + +

{{ emptyText }}

+
- + + + + +
+ {{ task.title }} + + + {{ formatDeadline(task.deadline) }} + +
+ + +
+ +
+
@@ -117,7 +140,7 @@ const visibleTasks = computed(() => props.tasks) .quadrant-card__count { min-width: 64px; padding: 8px 12px; - border-radius: 999px; + border-radius: 99px; background: rgba(255, 255, 255, 0.78); font-size: 13px; font-weight: 700; @@ -128,57 +151,78 @@ const visibleTasks = computed(() => props.tasks) .quadrant-list { display: grid; gap: 12px; - /* 卡片 header 约 70px,列表区域最多约 320px(约 4 条可见),超出部分滚动 */ max-height: 320px; overflow-y: auto; - /* 滚动条样式:轨道透明,滑块圆角淡色,hover 加深 */ scrollbar-width: thin; scrollbar-color: rgba(148, 163, 184, 0.32) transparent; } -.quadrant-list::-webkit-scrollbar { - width: 5px; +.quadrant-list::-webkit-scrollbar { width: 5px; } +.quadrant-list::-webkit-scrollbar-track { background: transparent; } +.quadrant-list::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(148, 163, 184, 0.32); } + +.quadrant-card__empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 30px 10px; } -.quadrant-list::-webkit-scrollbar-track { - background: transparent; +.empty-placeholder { + width: 100%; + height: 100%; + min-height: 150px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: #94a3b8; + transition: all 0.4s ease; } -.quadrant-list::-webkit-scrollbar-thumb { - border-radius: 999px; - background: rgba(148, 163, 184, 0.32); +.empty-placeholder:hover { + transform: translateY(-2px); } -.quadrant-list::-webkit-scrollbar-thumb:hover { - background: rgba(148, 163, 184, 0.52); +.empty-icon { + width: 40px; + height: 40px; + margin-bottom: 12px; + opacity: 0.15; /* 极低不透明度,实现融合感 */ + color: #64748b; + stroke-width: 1.2; } -.quadrant-item, -.quadrant-card__skeleton-item { - border-radius: 18px; - min-height: 72px; +.empty-placeholder p { + margin: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.6; + max-width: 180px; + color: #64748b; + opacity: 0.35; /* 文字也保持半透明融合 */ + letter-spacing: 0.02em; } .quadrant-item { width: 100%; + position: relative; + border-radius: 18px; + min-height: 72px; border: 1px solid rgba(17, 24, 39, 0.06); - background: rgba(255, 255, 255, 0.92); - display: grid; - grid-template-columns: 34px minmax(0, 1fr) auto; - gap: 14px; + background: rgba(255, 255, 255, 0.95); + display: flex; align-items: center; padding: 14px 16px; - text-align: left; - cursor: pointer; - transition: - transform 0.18s ease, - box-shadow 0.18s ease, - border-color 0.18s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; } .quadrant-item:hover { transform: translateY(-2px); - box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08); + box-shadow: 0 12px 24px -8px rgba(15, 23, 42, 0.12); border-color: rgba(37, 99, 235, 0.16); } @@ -186,113 +230,129 @@ const visibleTasks = computed(() => props.tasks) opacity: 0.82; } +/* 区域1: 勾选按钮 */ +.quadrant-item__check-btn { + border: none; + background: transparent; + padding: 0; + margin-right: 14px; + cursor: pointer; + display: flex; + align-items: center; +} + .quadrant-item__check { width: 28px; height: 28px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.3); - background: #f8fafc; + border-radius: 50%; + border: 2px solid #e2e8f0; + background: #fff; display: inline-flex; align-items: center; justify-content: center; - color: #2c8d57; - font-weight: 800; + color: #fff; + transition: all 0.2s; } -.quadrant-item__content { +.quadrant-item:hover .quadrant-item__check { + border-color: #3b82f6; +} + +.quadrant-item--completed .quadrant-item__check { + background: #10b981; + border-color: #10b981; +} + +/* 区域2: 主体文字 */ +.quadrant-item__body { + flex: 1; min-width: 0; + cursor: pointer; } -.quadrant-item__content strong, -.quadrant-item__content small { +.quadrant-item__title { display: block; -} - -.quadrant-item__content strong { font-size: 16px; color: #122033; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-bottom: 4px; + transition: all 0.3s; } -.quadrant-item--completed .quadrant-item__content strong { +.quadrant-item--completed .quadrant-item__title { color: #96a0af; text-decoration: line-through; } -.quadrant-item__content small { - margin-top: 6px; +.quadrant-item__time { + display: flex; + align-items: center; + gap: 4px; color: #768396; + font-size: 12px; } -.quadrant-item__status { - font-size: 13px; - color: #8090a5; - white-space: nowrap; -} - -.quadrant-card__empty { - min-height: 160px; +/* 区域3: 悬浮动作条 */ +.quadrant-item__actions { + position: absolute; + right: -56px; + top: 0; + bottom: 0; + width: 56px; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; - color: #8b97a7; - font-size: 16px; + border-left: 1px solid rgba(0, 0, 0, 0.03); + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + opacity: 0; } -.quadrant-card__skeleton { - display: grid; - gap: 12px; +.quadrant-item:hover .quadrant-item__actions { + right: 0; + opacity: 1; } +.action-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.action-btn.delete { color: #f43f5e; } +.action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); } + +/* --- 骨架屏 --- */ .quadrant-card__skeleton-item { + border-radius: 18px; + min-height: 72px; background: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8)); background-size: 200% 100%; animation: quadrant-shimmer 1.4s linear infinite; } @keyframes quadrant-shimmer { - 0% { - background-position: 200% 0; - } - - 100% { - background-position: -200% 0; - } + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } -.fade-switch-enter-active, -.fade-switch-leave-active { - transition: opacity 0.25s ease, transform 0.25s ease; -} +/* --- 动画 --- */ +.fade-switch-enter-active, .fade-switch-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; } +.fade-switch-enter-from { opacity: 0; transform: translateY(4px); } +.fade-switch-leave-to { opacity: 0; transform: translateY(-4px); } -.fade-switch-enter-from { - opacity: 0; - transform: translateY(4px); -} - -.fade-switch-leave-to { - opacity: 0; - transform: translateY(-4px); -} - -.list-stagger-enter-active, -.list-stagger-leave-active { - transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -.list-stagger-enter-from { - opacity: 0; - transform: translateY(12px) scale(0.98); -} - -.list-stagger-leave-to { - opacity: 0; - transform: translateX(12px) scale(0.98); -} - -.list-stagger-leave-active { - position: absolute; -} +.list-stagger-enter-active, .list-stagger-leave-active { transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } +.list-stagger-enter-from { opacity: 0; transform: translateY(12px) scale(0.98); } +.list-stagger-leave-to { opacity: 0; transform: translateX(12px) scale(0.98); } +.list-stagger-leave-active { position: absolute; } diff --git a/frontend/src/components/dashboard/TodayTimeline.vue b/frontend/src/components/dashboard/TodayTimeline.vue index 1b38b45..c0b321e 100644 --- a/frontend/src/components/dashboard/TodayTimeline.vue +++ b/frontend/src/components/dashboard/TodayTimeline.vue @@ -44,16 +44,13 @@ const props = defineProps<{ loading?: boolean }>() -// 1. 时间轴始终固定为 8 个槽位,顺序不再受当天是否有课影响。 -// 2. 课程槽位缺数据时显示“无课”,而不是直接消失,避免把后续块位挤乱。 -// 3. 午休和晚餐是纯占位块,不展示时间文本,只负责占住用户指定的位置。 const slotBlueprint: TimelineSlot[] = [ { key: 'slot-1', kind: 'event', title: '1-2节', startTime: '08:00', endTime: '09:40' }, { key: 'slot-2', kind: 'event', title: '3-4节', startTime: '10:15', endTime: '11:55' }, - { key: 'slot-noon', kind: 'pause', title: '午休' }, + { key: 'slot-noon', kind: 'pause', title: '午间' }, { key: 'slot-4', kind: 'event', title: '5-6节', startTime: '14:00', endTime: '15:40' }, { key: 'slot-5', kind: 'event', title: '7-8节', startTime: '16:15', endTime: '17:55' }, - { key: 'slot-dinner', kind: 'pause', title: '晚餐' }, + { key: 'slot-dinner', kind: 'pause', title: '晚休' }, { key: 'slot-6', kind: 'event', title: '9-10节', startTime: '19:00', endTime: '20:40' }, { key: 'slot-7', kind: 'event', title: '11-12节', startTime: '20:50', endTime: '22:30' }, ] @@ -70,25 +67,13 @@ const eventMap = computed(() => { return map }) +// 统一色调系统 function resolveCardTone(event: TodayEvent | null) { - if (!event) { - return 'neutral' - } - - if (event.type === 'course') { - return 'course' - } - - const orderToneMap: Record = { - 1: 'sky', - 2: 'violet', - 4: 'mint', - 5: 'emerald', - 6: 'amber', - 7: 'cyan', - } - - return orderToneMap[event.order] ?? 'neutral' + if (!event) return 'empty' + if (event.type === 'course') return 'primary' + + const tones = ['sky', 'violet', 'mint', 'amber', 'rose'] + return tones[event.order % tones.length] } const renderSlots = computed(() => @@ -98,7 +83,7 @@ const renderSlots = computed(() => key: slot.key, kind: 'pause', title: slot.title, - hint: '为中段留出缓冲与恢复时间', + hint: 'Rest Time', } } @@ -107,7 +92,7 @@ const renderSlots = computed(() => key: slot.key, kind: 'event', timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime), - title: event?.name || '无课', + title: event?.name || '今日无安排', locationText: event?.location || '休息时间', tone: resolveCardTone(event), } @@ -116,35 +101,39 @@ const renderSlots = computed(() => diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6f7a065..f9a2325 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,7 @@ import AssistantView from '@/views/AssistantView.vue' import DashboardView from '@/views/DashboardView.vue' import ScheduleView from '@/views/ScheduleView.vue' import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue' +import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue' const router = createRouter({ history: createWebHistory(), @@ -14,6 +15,11 @@ const router = createRouter({ path: '/', redirect: '/dashboard', }, + { + path: '/demo-task', + name: 'demo-task', + component: TaskInteractiveDemo, + }, { path: '/auth', name: 'auth', @@ -57,8 +63,6 @@ const router = createRouter({ router.beforeEach((to) => { const authStore = useAuthStore() - // 1. 进入受保护页面前,必须先确认 access token 是否存在。 - // 2. 当前阶段只做“是否登录”的前端兜底,不在这里做 token 过期解析。 if (to.meta.requiresAuth && !authStore.isAuthenticated) { return { name: 'auth', diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 1b35af8..141f208 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -5,6 +5,7 @@ export interface TaskItem { priority_group: number status: string deadline: string + urgency_threshold_at?: string | null is_completed: boolean } @@ -12,6 +13,7 @@ export interface TaskCreatePayload { title: string priority_group: number deadline_at?: string | null + urgency_threshold_at?: string | null } export interface TaskCreateResult { @@ -19,6 +21,7 @@ export interface TaskCreateResult { title: string priority_group: number deadline_at?: string | null + urgency_threshold_at?: string | null status: string created_at: string } @@ -30,6 +33,18 @@ export interface TaskMutationResult { status: string } +export interface TaskUpdatePayload { + task_id: number + title?: string | null + priority_group?: number | null + deadline_at?: string | null + urgency_threshold_at?: string | null +} + +export interface TaskDeletePayload { + task_id: number +} + export interface TaskBrief { id: number name: string diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 85bf276..394c05d 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,27 +1,28 @@ + + + +