Version: 0.9.38.dev.260423
后端: 1. 四象限任务新增修改与删除接口——部分更新语义 + 硬删除 + 幂等信息码 - 新增 PUT/task/update:指针字段部分更新(title / priority_group / deadline_at / urgency_threshold_at),优先级 1~4 校验,空更新检测 - 新增 DELETE /task/delete:硬删除,重复删除返回 10003 幂等信息码 - 新增错误码 TaskUpdateNoFields (40063) 与 TaskAlreadyDeleted (10003) 前端: 1. 四象限卡片对接修改与删除 - 任务项重构为三区布局:勾选、内容点击编辑、悬浮删除按钮 - 创建弹窗复用为编辑模式,新增 urgency_threshold_at 字段 - 删除走二次确认弹窗,空状态增加 SVG 插画 2. 今日时间轴微调——色调简化为取模轮换,午休/晚餐改称午间/晚休
This commit is contained in:
@@ -85,7 +85,7 @@ func (th *TaskHandler) CompleteTask(c *gin.Context) {
|
|||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 4. 调用 Service 执行“标记完成”逻辑。
|
// 4. 调用 Service 执行"标记完成"逻辑。
|
||||||
resp, err := th.svc.CompleteTask(ctx, &req, userID)
|
resp, err := th.svc.CompleteTask(ctx, &req, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.DealWithError(c, err)
|
respond.DealWithError(c, err)
|
||||||
@@ -96,12 +96,12 @@ func (th *TaskHandler) CompleteTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UndoCompleteTask 取消任务“已完成”勾选。
|
// UndoCompleteTask 取消任务"已完成"勾选。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责解析请求与读取 user_id;
|
// 1. 负责解析请求与读取 user_id;
|
||||||
// 2. 负责调用 Service 执行业务恢复;
|
// 2. 负责调用 Service 执行业务恢复;
|
||||||
// 3. 不负责“任务是否已完成”的业务判断(由 Service/DAO 负责)。
|
// 3. 不负责"任务是否已完成"的业务判断(由 Service/DAO 负责)。
|
||||||
func (th *TaskHandler) UndoCompleteTask(c *gin.Context) {
|
func (th *TaskHandler) UndoCompleteTask(c *gin.Context) {
|
||||||
// 1. 绑定请求参数。
|
// 1. 绑定请求参数。
|
||||||
var req model.UserUndoCompleteTaskRequest
|
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)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 4. 调用 Service 执行“取消已完成勾选”逻辑。
|
// 4. 调用 Service 执行"取消已完成勾选"逻辑。
|
||||||
resp, err := th.svc.UndoCompleteTask(ctx, &req, userID)
|
resp, err := th.svc.UndoCompleteTask(ctx, &req, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.DealWithError(c, err)
|
respond.DealWithError(c, err)
|
||||||
@@ -128,3 +128,69 @@ func (th *TaskHandler) UndoCompleteTask(c *gin.Context) {
|
|||||||
// 5. 返回统一响应结构。
|
// 5. 返回统一响应结构。
|
||||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
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}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,3 +55,24 @@ func ModelToGetUserTasksResp(tasks []model.Task) []model.GetUserTaskResp {
|
|||||||
}
|
}
|
||||||
return resp
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ func (dao *TaskDAO) GetTasksByUserID(userID int) ([]model.Task, error) {
|
|||||||
return tasks, nil
|
return tasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteTaskByID 将指定任务标记为“已完成”。
|
// CompleteTaskByID 将指定任务标记为"已完成"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责“当前用户 + 指定 task_id”的完成状态更新;
|
// 1. 只负责"当前用户 + 指定 task_id"的完成状态更新;
|
||||||
// 2. 不负责幂等中间件(由路由层统一挂载);
|
// 2. 不负责幂等中间件(由路由层统一挂载);
|
||||||
// 3. 不负责业务层响应包装(由 Service 层处理)。
|
// 3. 不负责业务层响应包装(由 Service 层处理)。
|
||||||
//
|
//
|
||||||
@@ -62,12 +62,12 @@ func (dao *TaskDAO) GetTasksByUserID(userID int) ([]model.Task, error) {
|
|||||||
// 3.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户;
|
// 3.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户;
|
||||||
// 3.2 其他 error:数据库异常。
|
// 3.2 其他 error:数据库异常。
|
||||||
func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, bool, error) {
|
func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, bool, error) {
|
||||||
// 1. 基础兜底:非法参数直接返回“记录不存在”语义,避免下游误写。
|
// 1. 基础兜底:非法参数直接返回"记录不存在"语义,避免下游误写。
|
||||||
if userID <= 0 || taskID <= 0 {
|
if userID <= 0 || taskID <= 0 {
|
||||||
return nil, false, gorm.ErrRecordNotFound
|
return nil, false, gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 先查询目标任务,明确区分“已完成”与“不存在”。
|
// 2. 先查询目标任务,明确区分"已完成"与"不存在"。
|
||||||
var target model.Task
|
var target model.Task
|
||||||
findErr := dao.db.WithContext(ctx).
|
findErr := dao.db.WithContext(ctx).
|
||||||
Where("id = ? AND user_id = ?", taskID, userID).
|
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
|
return &target, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UndoCompleteTaskByID 将指定任务从“已完成”恢复为“未完成”。
|
// UndoCompleteTaskByID 将指定任务从"已完成"恢复为"未完成"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责当前用户(user_id)下指定 task_id 的状态恢复;
|
// 1. 只负责当前用户(user_id)下指定 task_id 的状态恢复;
|
||||||
@@ -127,15 +127,15 @@ func (dao *TaskDAO) CompleteTaskByID(ctx context.Context, userID int, taskID int
|
|||||||
// 1. *model.Task:恢复后的任务快照;
|
// 1. *model.Task:恢复后的任务快照;
|
||||||
// 2. error:
|
// 2. error:
|
||||||
// 2.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户;
|
// 2.1 gorm.ErrRecordNotFound:任务不存在或不属于当前用户;
|
||||||
// 2.2 respond.TaskNotCompleted:任务当前不是“已完成”状态,不能执行取消勾选;
|
// 2.2 respond.TaskNotCompleted:任务当前不是"已完成"状态,不能执行取消勾选;
|
||||||
// 2.3 其他 error:数据库异常。
|
// 2.3 其他 error:数据库异常。
|
||||||
func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID int) (*model.Task, 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 {
|
if userID <= 0 || taskID <= 0 {
|
||||||
return nil, gorm.ErrRecordNotFound
|
return nil, gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 先读取目标任务,明确区分“不存在”和“状态不允许恢复”。
|
// 2. 先读取目标任务,明确区分"不存在"和"状态不允许恢复"。
|
||||||
var target model.Task
|
var target model.Task
|
||||||
findErr := dao.db.WithContext(ctx).
|
findErr := dao.db.WithContext(ctx).
|
||||||
Where("id = ? AND user_id = ?", taskID, userID).
|
Where("id = ? AND user_id = ?", taskID, userID).
|
||||||
@@ -145,7 +145,7 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 严格业务约束:若任务当前未完成,直接返回业务错误。
|
// 3. 严格业务约束:若任务当前未完成,直接返回业务错误。
|
||||||
// 3.1 这是本接口和“标记完成”接口的关键差异:这里不做幂等成功。
|
// 3.1 这是本接口和"标记完成"接口的关键差异:这里不做幂等成功。
|
||||||
if !target.IsCompleted {
|
if !target.IsCompleted {
|
||||||
return nil, respond.TaskNotCompleted
|
return nil, respond.TaskNotCompleted
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID
|
|||||||
|
|
||||||
// 5. 并发兜底:
|
// 5. 并发兜底:
|
||||||
// 5.1 若 RowsAffected=0,说明可能被并发请求先一步恢复;
|
// 5.1 若 RowsAffected=0,说明可能被并发请求先一步恢复;
|
||||||
// 5.2 重新读取当前状态,若已是未完成则按业务规则返回“任务未完成”错误。
|
// 5.2 重新读取当前状态,若已是未完成则按业务规则返回"任务未完成"错误。
|
||||||
if updateResult.RowsAffected == 0 {
|
if updateResult.RowsAffected == 0 {
|
||||||
var check model.Task
|
var check model.Task
|
||||||
checkErr := dao.db.WithContext(ctx).
|
checkErr := dao.db.WithContext(ctx).
|
||||||
@@ -184,10 +184,10 @@ func (dao *TaskDAO) UndoCompleteTaskByID(ctx context.Context, userID int, taskID
|
|||||||
return &target, nil
|
return &target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromoteTaskUrgencyByIDs 批量执行“任务紧急性平移”。
|
// PromoteTaskUrgencyByIDs 批量执行"任务紧急性平移"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责把满足条件的任务从“不紧急象限”平移到“紧急象限”:
|
// 1. 只负责把满足条件的任务从"不紧急象限"平移到"紧急象限":
|
||||||
// 1.1 priority=2 -> 1(重要不紧急 -> 重要且紧急);
|
// 1.1 priority=2 -> 1(重要不紧急 -> 重要且紧急);
|
||||||
// 1.2 priority=4 -> 3(不简单不重要 -> 简单不重要);
|
// 1.2 priority=4 -> 3(不简单不重要 -> 简单不重要);
|
||||||
// 2. 只更新本次指定 user_id + task_ids 范围内的数据;
|
// 2. 只更新本次指定 user_id + task_ids 范围内的数据;
|
||||||
@@ -209,7 +209,7 @@ func (dao *TaskDAO) PromoteTaskUrgencyByIDs(ctx context.Context, userID int, tas
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 条件更新:只更新“已到紧急分界线且仍处于非紧急象限”的任务。
|
// 3. 条件更新:只更新"已到紧急分界线且仍处于非紧急象限"的任务。
|
||||||
result := dao.db.WithContext(ctx).
|
result := dao.db.WithContext(ctx).
|
||||||
Model(&model.Task{UserID: userID}).
|
Model(&model.Task{UserID: userID}).
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
@@ -225,7 +225,101 @@ func (dao *TaskDAO) PromoteTaskUrgencyByIDs(ctx context.Context, userID int, tas
|
|||||||
return result.RowsAffected, nil
|
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 内部参数清洗工具,不参与任何业务判定;
|
// 1. 该函数是 DAO 内部参数清洗工具,不参与任何业务判定;
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import "time"
|
|||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责映射 tasks 表字段;
|
// 1. 负责映射 tasks 表字段;
|
||||||
// 2. 不负责接口入参校验和业务规则判断;
|
// 2. 不负责接口入参校验和业务规则判断;
|
||||||
// 3. 不负责“自动平移”执行(自动平移由 Service + Outbox 事件链路负责)。
|
// 3. 不负责"自动平移"执行(自动平移由 Service + Outbox 事件链路负责)。
|
||||||
type Task struct {
|
type Task struct {
|
||||||
// 1. 主键。
|
// 1. 主键。
|
||||||
ID int `gorm:"primaryKey;autoIncrement"`
|
ID int `gorm:"primaryKey;autoIncrement"`
|
||||||
// 2. 归属用户 ID。
|
// 2. 归属用户 ID。
|
||||||
// 2.1 单列索引用于常规按用户查任务;
|
// 2.1 单列索引用于常规按用户查任务;
|
||||||
// 2.2 同时参与“懒触发平移”复合索引的最左前缀。
|
// 2.2 同时参与"懒触发平移"复合索引的最左前缀。
|
||||||
UserID int `gorm:"column:user_id;index;index:idx_user_done_threshold_priority,priority:1"`
|
UserID int `gorm:"column:user_id;index;index:idx_user_done_threshold_priority,priority:1"`
|
||||||
// 3. 任务标题。
|
// 3. 任务标题。
|
||||||
Title string `gorm:"type:varchar(255)"`
|
Title string `gorm:"type:varchar(255)"`
|
||||||
@@ -23,7 +23,7 @@ type Task struct {
|
|||||||
// 4.3 3=简单不重要;
|
// 4.3 3=简单不重要;
|
||||||
// 4.4 4=不简单不重要。
|
// 4.4 4=不简单不重要。
|
||||||
//
|
//
|
||||||
// 说明:该字段参与“懒触发平移”复合索引。
|
// 说明:该字段参与"懒触发平移"复合索引。
|
||||||
Priority int `gorm:"not null;index:idx_user_done_threshold_priority,priority:4"`
|
Priority int `gorm:"not null;index:idx_user_done_threshold_priority,priority:4"`
|
||||||
// 5. 完成状态。
|
// 5. 完成状态。
|
||||||
//
|
//
|
||||||
@@ -34,10 +34,10 @@ type Task struct {
|
|||||||
// 7. 紧急分界时间(自动平移阈值)。
|
// 7. 紧急分界时间(自动平移阈值)。
|
||||||
//
|
//
|
||||||
// 规则:
|
// 规则:
|
||||||
// 7.1 到达该时间后,任务可从“不紧急象限”自动平移到“紧急象限”;
|
// 7.1 到达该时间后,任务可从"不紧急象限"自动平移到"紧急象限";
|
||||||
// 7.2 该值由上游(例如 LLM 规划)给出,不在模型层做推断;
|
// 7.2 该值由上游(例如 LLM 规划)给出,不在模型层做推断;
|
||||||
// 7.3 为空表示该任务不参与自动平移;
|
// 7.3 为空表示该任务不参与自动平移;
|
||||||
// 7.4 该字段参与“懒触发平移”复合索引。
|
// 7.4 该字段参与"懒触发平移"复合索引。
|
||||||
UrgencyThresholdAt *time.Time `gorm:"column:urgency_threshold_at;index:idx_user_done_threshold_priority,priority:3"`
|
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"`
|
DeadlineAt *time.Time `json:"deadline_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCompleteTaskRequest 是“标记任务完成”接口的请求体。
|
// UserCompleteTaskRequest 是"标记任务完成"接口的请求体。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只承载目标任务 ID;
|
// 1. 只承载目标任务 ID;
|
||||||
@@ -65,7 +65,7 @@ type UserCompleteTaskRequest struct {
|
|||||||
TaskID int `json:"task_id"`
|
TaskID int `json:"task_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCompleteTaskResponse 是“标记任务完成”接口的响应体。
|
// UserCompleteTaskResponse 是"标记任务完成"接口的响应体。
|
||||||
//
|
//
|
||||||
// 字段语义:
|
// 字段语义:
|
||||||
// 1. TaskID:本次操作的目标任务;
|
// 1. TaskID:本次操作的目标任务;
|
||||||
@@ -81,7 +81,7 @@ type UserCompleteTaskResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserUndoCompleteTaskRequest 是“取消任务已完成勾选”接口请求体。
|
// UserUndoCompleteTaskRequest 是"取消任务已完成勾选"接口请求体。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只承载目标 task_id;
|
// 1. 只承载目标 task_id;
|
||||||
@@ -90,7 +90,7 @@ type UserUndoCompleteTaskRequest struct {
|
|||||||
TaskID int `json:"task_id"`
|
TaskID int `json:"task_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserUndoCompleteTaskResponse 是“取消任务已完成勾选”接口响应体。
|
// UserUndoCompleteTaskResponse 是"取消任务已完成勾选"接口响应体。
|
||||||
//
|
//
|
||||||
// 字段语义:
|
// 字段语义:
|
||||||
// 1. TaskID:本次操作目标任务;
|
// 1. TaskID:本次操作目标任务;
|
||||||
@@ -112,10 +112,24 @@ type GetUserTaskResp struct {
|
|||||||
IsCompleted bool `json:"is_completed"`
|
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 协议字段(这些由基础设施层统一封装);
|
// 2. 不包含 outbox/kafka 协议字段(这些由基础设施层统一封装);
|
||||||
// 3. TriggeredAt 只用于追踪触发时间,最终是否更新仍以消费时数据库条件为准。
|
// 3. TriggeredAt 只用于追踪触发时间,最终是否更新仍以消费时数据库条件为准。
|
||||||
type TaskUrgencyPromoteRequestedPayload struct {
|
type TaskUrgencyPromoteRequestedPayload struct {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func DealWithError(c *gin.Context, err error) { //处理错误,返回对应的
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var resp Response
|
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)
|
c.JSON(http.StatusOK, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -379,6 +379,16 @@ var ( //请求相关的响应
|
|||||||
Info: "duplicate task_item_id in request",
|
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{ //路由控制码内部错误
|
RouteControlInternalError = Response{ //路由控制码内部错误
|
||||||
Status: "50001",
|
Status: "50001",
|
||||||
Info: "route control failed",
|
Info: "route control failed",
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
|
|||||||
taskGroup.POST("/create", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask)
|
taskGroup.POST("/create", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask)
|
||||||
taskGroup.PUT("/complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.CompleteTask)
|
taskGroup.PUT("/complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.CompleteTask)
|
||||||
taskGroup.PUT("/undo-complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UndoCompleteTask)
|
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)
|
taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks)
|
||||||
}
|
}
|
||||||
courseGroup := apiGroup.Group("/course")
|
courseGroup := apiGroup.Group("/course")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// taskUrgencyPromoteDedupeTTL 是“同一任务平移请求”的去重锁有效期。
|
// taskUrgencyPromoteDedupeTTL 是"同一任务平移请求"的去重锁有效期。
|
||||||
//
|
//
|
||||||
// 设计考虑:
|
// 设计考虑:
|
||||||
// 1. 太短会导致消费稍慢时被重复投递;
|
// 1. 太短会导致消费稍慢时被重复投递;
|
||||||
@@ -55,7 +55,7 @@ func NewTaskService(taskDAO *dao.TaskDAO, cacheDAO *dao.CacheDAO, eventPublisher
|
|||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责参数转换、优先级合法性校验与写库;
|
// 1. 负责参数转换、优先级合法性校验与写库;
|
||||||
// 2. 不负责“紧急性自动平移”逻辑(该逻辑发生在任务读取时的懒触发链路)。
|
// 2. 不负责"紧急性自动平移"逻辑(该逻辑发生在任务读取时的懒触发链路)。
|
||||||
func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskRequest, userID int) (*model.UserAddTaskResponse, error) {
|
func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskRequest, userID int) (*model.UserAddTaskResponse, error) {
|
||||||
// 1. 把用户请求转换为内部模型,避免 API 层结构直接泄漏到 DAO。
|
// 1. 把用户请求转换为内部模型,避免 API 层结构直接泄漏到 DAO。
|
||||||
taskModel := conv.UserAddTaskRequestToModel(req, userID)
|
taskModel := conv.UserAddTaskRequestToModel(req, userID)
|
||||||
@@ -73,7 +73,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteTask 将用户指定任务标记为“已完成”。
|
// CompleteTask 将用户指定任务标记为"已完成"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责入参校验与业务错误映射;
|
// 1. 负责入参校验与业务错误映射;
|
||||||
@@ -86,7 +86,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete
|
|||||||
return nil, respond.WrongTaskID
|
return nil, respond.WrongTaskID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 调用 DAO 执行“查询 + 必要时更新”。
|
// 2. 调用 DAO 执行"查询 + 必要时更新"。
|
||||||
updatedTask, alreadyCompleted, err := ts.dao.CompleteTaskByID(ctx, userID, req.TaskID)
|
updatedTask, alreadyCompleted, err := ts.dao.CompleteTaskByID(ctx, userID, req.TaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 2.1 任务不存在或不属于当前用户时,统一映射为 WrongTaskID。
|
// 2.1 任务不存在或不属于当前用户时,统一映射为 WrongTaskID。
|
||||||
@@ -113,7 +113,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UndoCompleteTask 取消用户任务的“已完成勾选”。
|
// UndoCompleteTask 取消用户任务的"已完成勾选"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责入参校验与业务错误映射;
|
// 1. 负责入参校验与业务错误映射;
|
||||||
@@ -126,7 +126,7 @@ func (ts *TaskService) UndoCompleteTask(ctx context.Context, req *model.UserUndo
|
|||||||
return nil, respond.WrongTaskID
|
return nil, respond.WrongTaskID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 调用 DAO 执行“恢复未完成”逻辑。
|
// 2. 调用 DAO 执行"恢复未完成"逻辑。
|
||||||
updatedTask, err := ts.dao.UndoCompleteTaskByID(ctx, userID, req.TaskID)
|
updatedTask, err := ts.dao.UndoCompleteTaskByID(ctx, userID, req.TaskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 2.1 任务不存在或不属于当前用户,统一映射为 WrongTaskID。
|
// 2.1 任务不存在或不属于当前用户,统一映射为 WrongTaskID。
|
||||||
@@ -154,12 +154,12 @@ func (ts *TaskService) UndoCompleteTask(ctx context.Context, req *model.UserUndo
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserTasks 获取用户任务列表(含“读时紧急性派生”与“异步平移触发”)。
|
// GetUserTasks 获取用户任务列表(含"读时紧急性派生"与"异步平移触发")。
|
||||||
//
|
//
|
||||||
// 核心流程(步骤化):
|
// 核心流程(步骤化):
|
||||||
// 1. 先读缓存,未命中再回源 DB,并把“原始模型”回填缓存;
|
// 1. 先读缓存,未命中再回源 DB,并把"原始模型"回填缓存;
|
||||||
// 2. 在内存里做“读时派生”:仅用于本次返回给前端,不直接改库;
|
// 2. 在内存里做"读时派生":仅用于本次返回给前端,不直接改库;
|
||||||
// 3. 收集“已到紧急分界线且仍处于非紧急象限”的任务 ID;
|
// 3. 收集"已到紧急分界线且仍处于非紧急象限"的任务 ID;
|
||||||
// 4. 通过 Redis SETNX 去重后,发布 outbox 事件异步落库;
|
// 4. 通过 Redis SETNX 去重后,发布 outbox 事件异步落库;
|
||||||
// 5. 无论发布成功与否,都优先返回本次派生结果,保证用户读体验。
|
// 5. 无论发布成功与否,都优先返回本次派生结果,保证用户读体验。
|
||||||
//
|
//
|
||||||
@@ -189,7 +189,7 @@ func (ts *TaskService) GetTasksWithUrgencyPromotion(ctx context.Context, userID
|
|||||||
return derivedTasks, nil
|
return derivedTasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRawUserTasks 读取“原始任务模型”。
|
// getRawUserTasks 读取"原始任务模型"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责缓存命中/回源 DB/回填缓存;
|
// 1. 负责缓存命中/回源 DB/回填缓存;
|
||||||
@@ -220,16 +220,16 @@ func (ts *TaskService) getRawUserTasks(ctx context.Context, userID int) ([]model
|
|||||||
return dbTasks, nil
|
return dbTasks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveTaskUrgencyForRead 对任务做“读时紧急性派生”,并收集需要异步落库的任务 ID。
|
// deriveTaskUrgencyForRead 对任务做"读时紧急性派生",并收集需要异步落库的任务 ID。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只在内存里改本次返回值,不写 DB;
|
// 1. 只在内存里改本次返回值,不写 DB;
|
||||||
// 2. 只做“到线且未完成任务”的优先级映射;
|
// 2. 只做"到线且未完成任务"的优先级映射;
|
||||||
// 3. 不处理去重锁和事件发布。
|
// 3. 不处理去重锁和事件发布。
|
||||||
//
|
//
|
||||||
// 返回语义:
|
// 返回语义:
|
||||||
// 1. 第一个返回值:可直接用于响应前端的派生任务切片;
|
// 1. 第一个返回值:可直接用于响应前端的派生任务切片;
|
||||||
// 2. 第二个返回值:需要发“异步平移事件”的任务 ID 列表(可能为空)。
|
// 2. 第二个返回值:需要发"异步平移事件"的任务 ID 列表(可能为空)。
|
||||||
func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, []int) {
|
func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task, []int) {
|
||||||
// 1. 拷贝切片,避免修改调用方持有的原始数据。
|
// 1. 拷贝切片,避免修改调用方持有的原始数据。
|
||||||
derived := make([]model.Task, len(tasks))
|
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))
|
pendingPromoteTaskIDs := make([]int, 0, len(derived))
|
||||||
|
|
||||||
// 2. 逐条判断是否满足“自动平移”条件。
|
// 2. 逐条判断是否满足"自动平移"条件。
|
||||||
for idx := range derived {
|
for idx := range derived {
|
||||||
current := &derived[idx]
|
current := &derived[idx]
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.4 到线后,仅把“不紧急象限”平移到对应“紧急象限”。
|
// 2.4 到线后,仅把"不紧急象限"平移到对应"紧急象限"。
|
||||||
// 2.4.1 重要不紧急(2) -> 重要且紧急(1)
|
// 2.4.1 重要不紧急(2) -> 重要且紧急(1)
|
||||||
// 2.4.2 不简单不重要(4) -> 简单不重要(3)
|
// 2.4.2 不简单不重要(4) -> 简单不重要(3)
|
||||||
switch current.Priority {
|
switch current.Priority {
|
||||||
@@ -271,12 +271,12 @@ func deriveTaskUrgencyForRead(tasks []model.Task, now time.Time) ([]model.Task,
|
|||||||
return derived, pendingPromoteTaskIDs
|
return derived, pendingPromoteTaskIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryEnqueueTaskUrgencyPromote 尝试发布“任务紧急性平移请求”事件。
|
// tryEnqueueTaskUrgencyPromote 尝试发布"任务紧急性平移请求"事件。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 负责 Redis 去重锁 + outbox 发布;
|
// 1. 负责 Redis 去重锁 + outbox 发布;
|
||||||
// 2. 不负责真正落库(由消费者负责);
|
// 2. 不负责真正落库(由消费者负责);
|
||||||
// 3. 发布失败时要释放本次抢到的去重锁,避免任务被长时间“误判已投递”。
|
// 3. 发布失败时要释放本次抢到的去重锁,避免任务被长时间"误判已投递"。
|
||||||
func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID int, taskIDs []int) {
|
func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID int, taskIDs []int) {
|
||||||
// 1. 基础兜底:无发布器或无候选任务时直接返回。
|
// 1. 基础兜底:无发布器或无候选任务时直接返回。
|
||||||
if ts.eventPublisher == nil || userID <= 0 || len(taskIDs) == 0 {
|
if ts.eventPublisher == nil || userID <= 0 || len(taskIDs) == 0 {
|
||||||
@@ -312,7 +312,7 @@ func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 发布 outbox 事件:这里只保证“成功入 outbox 或返回错误”,不等待消费者执行完成。
|
// 4. 发布 outbox 事件:这里只保证"成功入 outbox 或返回错误",不等待消费者执行完成。
|
||||||
publishErr := eventsvc.PublishTaskUrgencyPromoteRequested(ctx, ts.eventPublisher, model.TaskUrgencyPromoteRequestedPayload{
|
publishErr := eventsvc.PublishTaskUrgencyPromoteRequested(ctx, ts.eventPublisher, model.TaskUrgencyPromoteRequestedPayload{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TaskIDs: lockedTaskIDs,
|
TaskIDs: lockedTaskIDs,
|
||||||
@@ -331,7 +331,7 @@ func (ts *TaskService) tryEnqueueTaskUrgencyPromote(ctx context.Context, userID
|
|||||||
// releaseTaskPromoteLocks 释放任务平移去重锁。
|
// releaseTaskPromoteLocks 释放任务平移去重锁。
|
||||||
//
|
//
|
||||||
// 说明:
|
// 说明:
|
||||||
// 1. 仅用于“发布失败回滚”场景;
|
// 1. 仅用于"发布失败回滚"场景;
|
||||||
// 2. 使用 Background 避免请求上下文已取消时导致锁释放失败。
|
// 2. 使用 Background 避免请求上下文已取消时导致锁释放失败。
|
||||||
func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) {
|
func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) {
|
||||||
if len(lockKeys) == 0 {
|
if len(lockKeys) == 0 {
|
||||||
@@ -347,7 +347,7 @@ func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compactPositiveUniqueTaskIDs 对任务 ID 做“过滤非正数 + 去重”。
|
// compactPositiveUniqueTaskIDs 对任务 ID 做"过滤非正数 + 去重"。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只做参数清洗;
|
// 1. 只做参数清洗;
|
||||||
@@ -367,3 +367,80 @@ func compactPositiveUniqueTaskIDs(taskIDs []int) []int {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
209
docs/apifox/task-update-delete-integration.md
Normal file
209
docs/apifox/task-update-delete-integration.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# 四象限任务编辑接口 — 前端对接文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
新增两个任务编辑接口,用于在四象限卡片上直接修改任务属性和删除任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用说明
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
|
所有请求需在 Header 中携带 JWT access_token:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 幂等键
|
||||||
|
|
||||||
|
写操作接口需在 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 <access_token>` |
|
||||||
|
| `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 <access_token>` |
|
||||||
|
| `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`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import http from '@/api/http'
|
import http from '@/api/http'
|
||||||
import type { ApiResponse } from '@/types/api'
|
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 { createIdempotencyKey } from '@/utils/idempotency'
|
||||||
import { extractErrorMessage } from '@/utils/http'
|
import { extractErrorMessage } from '@/utils/http'
|
||||||
|
|
||||||
@@ -59,3 +59,37 @@ export async function undoCompleteTask(taskId: number) {
|
|||||||
throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试'))
|
throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTask(payload: TaskUpdatePayload) {
|
||||||
|
try {
|
||||||
|
const response = await http.put<ApiResponse<TaskItem>>(
|
||||||
|
'/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<ApiResponse<{ task_id: number }>>(
|
||||||
|
'/task/delete',
|
||||||
|
{
|
||||||
|
data: { task_id: taskId },
|
||||||
|
headers: {
|
||||||
|
'X-Idempotency-Key': createIdempotencyKey('task-delete'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.data.data
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggle: [task: TaskItem]
|
toggle: [task: TaskItem]
|
||||||
|
edit: [task: TaskItem]
|
||||||
|
delete: [task: TaskItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 不再硬截断,全部展示;超出的部分通过 quadrant-list 的 max-height + overflow-y 滚动查看。
|
|
||||||
const visibleTasks = computed(() => props.tasks)
|
const visibleTasks = computed(() => props.tasks)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,29 +39,51 @@ const visibleTasks = computed(() => props.tasks)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="visibleTasks.length === 0" key="empty" class="quadrant-card__empty">
|
<div v-else-if="visibleTasks.length === 0" key="empty" class="quadrant-card__empty">
|
||||||
{{ emptyText }}
|
<div class="empty-placeholder">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M12 8v1.5M12 12.5V16"></path>
|
||||||
|
</svg>
|
||||||
|
<p>{{ emptyText }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TransitionGroup v-else tag="div" name="list-stagger" class="quadrant-list" key="list">
|
<TransitionGroup v-else tag="div" name="list-stagger" class="quadrant-list" key="list">
|
||||||
<button
|
<div
|
||||||
v-for="task in visibleTasks"
|
v-for="task in visibleTasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
type="button"
|
|
||||||
class="quadrant-item"
|
class="quadrant-item"
|
||||||
:class="{ 'quadrant-item--completed': task.is_completed }"
|
:class="{ 'quadrant-item--completed': task.is_completed }"
|
||||||
@click="emit('toggle', task)"
|
|
||||||
>
|
>
|
||||||
<span class="quadrant-item__check">
|
<!-- 区域1: 独立勾选框 (阻止冒泡) -->
|
||||||
{{ task.is_completed ? '✓' : '' }}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
<span class="quadrant-item__content">
|
class="quadrant-item__check-btn"
|
||||||
<strong>{{ task.title }}</strong>
|
@click.stop="emit('toggle', task)"
|
||||||
<small>{{ formatDeadline(task.deadline) }}</small>
|
>
|
||||||
</span>
|
<span class="quadrant-item__check">
|
||||||
<span class="quadrant-item__status">
|
<svg v-if="task.is_completed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="4">
|
||||||
{{ task.is_completed ? '已完成' : '待处理' }}
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
</span>
|
</svg>
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 区域2: 文本内容 (点击触发编辑) -->
|
||||||
|
<div class="quadrant-item__body" @click="emit('edit', task)">
|
||||||
|
<strong class="quadrant-item__title">{{ task.title }}</strong>
|
||||||
|
<small class="quadrant-item__time">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
|
{{ formatDeadline(task.deadline) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 区域3: 悬浮操作区 (平滑滑入) -->
|
||||||
|
<div class="quadrant-item__actions">
|
||||||
|
<button class="action-btn delete" @click.stop="emit('delete', task)" title="删除任务">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</transition>
|
</transition>
|
||||||
</section>
|
</section>
|
||||||
@@ -117,7 +140,7 @@ const visibleTasks = computed(() => props.tasks)
|
|||||||
.quadrant-card__count {
|
.quadrant-card__count {
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 99px;
|
||||||
background: rgba(255, 255, 255, 0.78);
|
background: rgba(255, 255, 255, 0.78);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -128,57 +151,78 @@ const visibleTasks = computed(() => props.tasks)
|
|||||||
.quadrant-list {
|
.quadrant-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
/* 卡片 header 约 70px,列表区域最多约 320px(约 4 条可见),超出部分滚动 */
|
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
/* 滚动条样式:轨道透明,滑块圆角淡色,hover 加深 */
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
|
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-list::-webkit-scrollbar {
|
.quadrant-list::-webkit-scrollbar { width: 5px; }
|
||||||
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 {
|
.empty-placeholder {
|
||||||
background: transparent;
|
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 {
|
.empty-placeholder:hover {
|
||||||
border-radius: 999px;
|
transform: translateY(-2px);
|
||||||
background: rgba(148, 163, 184, 0.32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-list::-webkit-scrollbar-thumb:hover {
|
.empty-icon {
|
||||||
background: rgba(148, 163, 184, 0.52);
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.15; /* 极低不透明度,实现融合感 */
|
||||||
|
color: #64748b;
|
||||||
|
stroke-width: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item,
|
.empty-placeholder p {
|
||||||
.quadrant-card__skeleton-item {
|
margin: 0;
|
||||||
border-radius: 18px;
|
font-size: 13px;
|
||||||
min-height: 72px;
|
font-weight: 600;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 180px;
|
||||||
|
color: #64748b;
|
||||||
|
opacity: 0.35; /* 文字也保持半透明融合 */
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item {
|
.quadrant-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 18px;
|
||||||
|
min-height: 72px;
|
||||||
border: 1px solid rgba(17, 24, 39, 0.06);
|
border: 1px solid rgba(17, 24, 39, 0.06);
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
text-align: left;
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
cursor: pointer;
|
overflow: hidden;
|
||||||
transition:
|
|
||||||
transform 0.18s ease,
|
|
||||||
box-shadow 0.18s ease,
|
|
||||||
border-color 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item:hover {
|
.quadrant-item:hover {
|
||||||
transform: translateY(-2px);
|
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);
|
border-color: rgba(37, 99, 235, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,113 +230,129 @@ const visibleTasks = computed(() => props.tasks)
|
|||||||
opacity: 0.82;
|
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 {
|
.quadrant-item__check {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
border: 2px solid #e2e8f0;
|
||||||
background: #f8fafc;
|
background: #fff;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #2c8d57;
|
color: #fff;
|
||||||
font-weight: 800;
|
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;
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item__content strong,
|
.quadrant-item__title {
|
||||||
.quadrant-item__content small {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
|
||||||
|
|
||||||
.quadrant-item__content strong {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #122033;
|
color: #122033;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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;
|
color: #96a0af;
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item__content small {
|
.quadrant-item__time {
|
||||||
margin-top: 6px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
color: #768396;
|
color: #768396;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-item__status {
|
/* 区域3: 悬浮动作条 */
|
||||||
font-size: 13px;
|
.quadrant-item__actions {
|
||||||
color: #8090a5;
|
position: absolute;
|
||||||
white-space: nowrap;
|
right: -56px;
|
||||||
}
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
.quadrant-card__empty {
|
width: 56px;
|
||||||
min-height: 160px;
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #8b97a7;
|
border-left: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
font-size: 16px;
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quadrant-card__skeleton {
|
.quadrant-item:hover .quadrant-item__actions {
|
||||||
display: grid;
|
right: 0;
|
||||||
gap: 12px;
|
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 {
|
.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: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8));
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: quadrant-shimmer 1.4s linear infinite;
|
animation: quadrant-shimmer 1.4s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes quadrant-shimmer {
|
@keyframes quadrant-shimmer {
|
||||||
0% {
|
0% { background-position: 200% 0; }
|
||||||
background-position: 200% 0;
|
100% { background-position: -200% 0; }
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-switch-enter-active,
|
/* --- 动画 --- */
|
||||||
.fade-switch-leave-active {
|
.fade-switch-enter-active, .fade-switch-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }
|
||||||
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 {
|
.list-stagger-enter-active, .list-stagger-leave-active { transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||||
opacity: 0;
|
.list-stagger-enter-from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
transform: translateY(4px);
|
.list-stagger-leave-to { opacity: 0; transform: translateX(12px) scale(0.98); }
|
||||||
}
|
.list-stagger-leave-active { position: absolute; }
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,16 +44,13 @@ const props = defineProps<{
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 1. 时间轴始终固定为 8 个槽位,顺序不再受当天是否有课影响。
|
|
||||||
// 2. 课程槽位缺数据时显示“无课”,而不是直接消失,避免把后续块位挤乱。
|
|
||||||
// 3. 午休和晚餐是纯占位块,不展示时间文本,只负责占住用户指定的位置。
|
|
||||||
const slotBlueprint: TimelineSlot[] = [
|
const slotBlueprint: TimelineSlot[] = [
|
||||||
{ key: 'slot-1', kind: 'event', title: '1-2节', startTime: '08:00', endTime: '09:40' },
|
{ 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-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-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-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-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' },
|
{ key: 'slot-7', kind: 'event', title: '11-12节', startTime: '20:50', endTime: '22:30' },
|
||||||
]
|
]
|
||||||
@@ -70,25 +67,13 @@ const eventMap = computed(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 统一色调系统
|
||||||
function resolveCardTone(event: TodayEvent | null) {
|
function resolveCardTone(event: TodayEvent | null) {
|
||||||
if (!event) {
|
if (!event) return 'empty'
|
||||||
return 'neutral'
|
if (event.type === 'course') return 'primary'
|
||||||
}
|
|
||||||
|
const tones = ['sky', 'violet', 'mint', 'amber', 'rose']
|
||||||
if (event.type === 'course') {
|
return tones[event.order % tones.length]
|
||||||
return 'course'
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderToneMap: Record<number, string> = {
|
|
||||||
1: 'sky',
|
|
||||||
2: 'violet',
|
|
||||||
4: 'mint',
|
|
||||||
5: 'emerald',
|
|
||||||
6: 'amber',
|
|
||||||
7: 'cyan',
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderToneMap[event.order] ?? 'neutral'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSlots = computed<RenderSlot[]>(() =>
|
const renderSlots = computed<RenderSlot[]>(() =>
|
||||||
@@ -98,7 +83,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
key: slot.key,
|
key: slot.key,
|
||||||
kind: 'pause',
|
kind: 'pause',
|
||||||
title: slot.title,
|
title: slot.title,
|
||||||
hint: '为中段留出缓冲与恢复时间',
|
hint: 'Rest Time',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +92,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
key: slot.key,
|
key: slot.key,
|
||||||
kind: 'event',
|
kind: 'event',
|
||||||
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
|
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
|
||||||
title: event?.name || '无课',
|
title: event?.name || '今日无安排',
|
||||||
locationText: event?.location || '休息时间',
|
locationText: event?.location || '休息时间',
|
||||||
tone: resolveCardTone(event),
|
tone: resolveCardTone(event),
|
||||||
}
|
}
|
||||||
@@ -116,35 +101,39 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="timeline-card glass-panel">
|
<section class="pastel-container">
|
||||||
<header class="timeline-card__header">
|
<header class="pastel-header">
|
||||||
<div>
|
<div class="header-content">
|
||||||
<p class="timeline-card__eyebrow">今日总览</p>
|
<p class="header-label">DAILY TIMELINE</p>
|
||||||
<h2>今日日程一览</h2>
|
<h2>今日日程</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="timeline-card__caption">按时间顺序展示课程与任务安排</span>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<transition name="fade-switch" mode="out-in">
|
<transition name="grid-pop" mode="out-in">
|
||||||
<div v-if="loading" key="loading" class="timeline-skeleton">
|
<div v-if="loading" key="loading" class="pastel-grid">
|
||||||
<div v-for="slot in slotBlueprint" :key="slot.key" class="timeline-skeleton__item" />
|
<div v-for="n in 8" :key="n" class="skeleton-pill" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else key="content" class="timeline-grid">
|
<div v-else key="content" class="pastel-grid">
|
||||||
<template v-for="slot in renderSlots" :key="slot.key">
|
<template v-for="slot in renderSlots" :key="slot.key">
|
||||||
<article
|
<article
|
||||||
v-if="slot.kind === 'event'"
|
v-if="slot.kind === 'event'"
|
||||||
class="timeline-event"
|
class="pastel-item"
|
||||||
:class="`timeline-event--${slot.tone}`"
|
:class="[`tone--${slot.tone}`]"
|
||||||
>
|
>
|
||||||
<span class="timeline-event__time">{{ slot.timeText }}</span>
|
<div class="item-time">{{ slot.timeText }}</div>
|
||||||
<strong class="timeline-event__title">{{ slot.title }}</strong>
|
<strong class="item-title">{{ slot.title }}</strong>
|
||||||
<span class="timeline-event__location">{{ slot.locationText }}</span>
|
<div class="item-footer">
|
||||||
|
<svg class="location-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="location-text">{{ slot.locationText }}</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article v-else class="timeline-placeholder timeline-placeholder--pause">
|
<article v-else class="pause-item">
|
||||||
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
|
<span class="pause-tag">{{ slot.title }}</span>
|
||||||
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
|
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,281 +142,150 @@ const renderSlots = computed<RenderSlot[]>(() =>
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.timeline-card {
|
.pastel-container {
|
||||||
min-width: 0;
|
padding: 32px;
|
||||||
border-radius: 28px;
|
background: #ffffff;
|
||||||
padding: 22px 22px 20px;
|
border-radius: 32px;
|
||||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-card__header {
|
.pastel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18px;
|
align-items: center;
|
||||||
align-items: flex-end;
|
margin-bottom: 28px;
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-card__eyebrow {
|
.header-label {
|
||||||
margin: 0 0 6px;
|
font-size: 11px;
|
||||||
font-size: 12px;
|
font-weight: 900;
|
||||||
font-weight: 700;
|
color: #c4c9d5;
|
||||||
color: #2a6fdf;
|
letter-spacing: 0.15em;
|
||||||
letter-spacing: 0.08em;
|
margin: 0 0 4px;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-card h2 {
|
.header-content h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
line-height: 1.1;
|
font-weight: 800;
|
||||||
letter-spacing: -0.03em;
|
color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-card__caption {
|
.pastel-grid {
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-grid {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 1. 使用自适应列数,避免固定列数把左侧主区撑爆。 */
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
/* 2. 但槽位顺序固定,换行只影响视觉换行,不影响时间先后顺序。 */
|
gap: 14px;
|
||||||
/* 3. 这样无论是否缺课,8 个槽位都会按既定顺序逐个渲染。 */
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event,
|
/* 核心卡片样式 */
|
||||||
.timeline-placeholder,
|
.pastel-item {
|
||||||
.timeline-skeleton__item {
|
position: relative;
|
||||||
min-width: 0;
|
border-radius: 26px;
|
||||||
min-height: 124px;
|
padding: 20px 18px;
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event {
|
|
||||||
padding: 16px 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border: 1px solid transparent;
|
min-height: 140px;
|
||||||
position: relative;
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
overflow: hidden;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event::before {
|
.pastel-item:hover {
|
||||||
content: '';
|
transform: scale(1.03) translateY(-4px);
|
||||||
position: absolute;
|
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.1);
|
||||||
left: 0;
|
z-index: 10;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event__time {
|
.item-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: #64748b;
|
opacity: 0.6;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event__title {
|
.item-title {
|
||||||
margin-top: 12px;
|
font-size: 18px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 莫兰迪色相系统 */
|
||||||
|
.tone--primary { background: #eff6ff; color: #1e40af; }
|
||||||
|
.tone--sky { background: #f0f9ff; color: #0369a1; }
|
||||||
|
.tone--violet { background: #f5f3ff; color: #5b21b6; }
|
||||||
|
.tone--mint { background: #ecfdf5; color: #065f46; }
|
||||||
|
.tone--amber { background: #fffdf2; color: #92400e; }
|
||||||
|
.tone--rose { background: #fff1f2; color: #9f1239; }
|
||||||
|
.tone--empty { background: #f8fafc; color: #64748b; }
|
||||||
|
|
||||||
|
/* 休息时间样式 */
|
||||||
|
.pause-item {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 80px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-tag {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.35;
|
font-weight: 900;
|
||||||
color: #0f172a;
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event__location {
|
/* 骨架屏 */
|
||||||
margin-top: 14px;
|
.skeleton-pill {
|
||||||
font-size: 12px;
|
min-height: 140px;
|
||||||
color: #64748b;
|
background: #f1f5f9;
|
||||||
|
border-radius: 26px;
|
||||||
|
animation: pill-shimmer 1.5s infinite linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event--course {
|
@keyframes pill-shimmer {
|
||||||
background: #eff6ff;
|
0% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-event--course::before {
|
/* 动画效果 */
|
||||||
background: #3b82f6;
|
.grid-pop-enter-active {
|
||||||
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
.grid-pop-enter-from {
|
||||||
.timeline-event--course .timeline-event__title,
|
|
||||||
.timeline-event--course .timeline-event__time {
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--sky {
|
|
||||||
background: #f0f9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--sky::before {
|
|
||||||
background: #0ea5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--sky .timeline-event__title,
|
|
||||||
.timeline-event--sky .timeline-event__time {
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--violet {
|
|
||||||
background: #f5f3ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--violet::before {
|
|
||||||
background: #8b5cf6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--violet .timeline-event__title,
|
|
||||||
.timeline-event--violet .timeline-event__time {
|
|
||||||
color: #6d28d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--mint {
|
|
||||||
background: #ecfdf5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--mint::before {
|
|
||||||
background: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--mint .timeline-event__title,
|
|
||||||
.timeline-event--mint .timeline-event__time {
|
|
||||||
color: #047857;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--emerald {
|
|
||||||
background: #dcfce7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--emerald::before {
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--emerald .timeline-event__title,
|
|
||||||
.timeline-event--emerald .timeline-event__time {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--amber {
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--amber::before {
|
|
||||||
background: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--amber .timeline-event__title,
|
|
||||||
.timeline-event--amber .timeline-event__time {
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--cyan {
|
|
||||||
background: #cffafe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--cyan::before {
|
|
||||||
background: #06b6d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--cyan .timeline-event__title,
|
|
||||||
.timeline-event--cyan .timeline-event__time {
|
|
||||||
color: #0e7490;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--neutral {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-color: rgba(15, 23, 42, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-event--neutral::before {
|
|
||||||
background: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-placeholder {
|
|
||||||
border: 1px dashed rgba(15, 23, 42, 0.15);
|
|
||||||
background: #ffffff;
|
|
||||||
display: grid;
|
|
||||||
align-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 12px;
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-placeholder--pause {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-placeholder__title {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #22324b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-placeholder__hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #7a889d;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-skeleton {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-skeleton__item {
|
|
||||||
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: skeleton-shimmer 1.4s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-shimmer {
|
|
||||||
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-from,
|
|
||||||
.fade-switch-leave-to {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: scale(0.9);
|
||||||
}
|
|
||||||
.fade-switch-leave-to {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1320px) {
|
@media (max-width: 1200px) {
|
||||||
.timeline-grid,
|
.pastel-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
.timeline-skeleton {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@media (max-width: 800px) {
|
||||||
.timeline-card__header {
|
.pastel-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.timeline-grid,
|
|
||||||
.timeline-skeleton {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AssistantView from '@/views/AssistantView.vue'
|
|||||||
import DashboardView from '@/views/DashboardView.vue'
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
import ScheduleView from '@/views/ScheduleView.vue'
|
import ScheduleView from '@/views/ScheduleView.vue'
|
||||||
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
|
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
|
||||||
|
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -14,6 +15,11 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard',
|
redirect: '/dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/demo-task',
|
||||||
|
name: 'demo-task',
|
||||||
|
component: TaskInteractiveDemo,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/auth',
|
path: '/auth',
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
@@ -57,8 +63,6 @@ const router = createRouter({
|
|||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// 1. 进入受保护页面前,必须先确认 access token 是否存在。
|
|
||||||
// 2. 当前阶段只做“是否登录”的前端兜底,不在这里做 token 过期解析。
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
return {
|
return {
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TaskItem {
|
|||||||
priority_group: number
|
priority_group: number
|
||||||
status: string
|
status: string
|
||||||
deadline: string
|
deadline: string
|
||||||
|
urgency_threshold_at?: string | null
|
||||||
is_completed: boolean
|
is_completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export interface TaskCreatePayload {
|
|||||||
title: string
|
title: string
|
||||||
priority_group: number
|
priority_group: number
|
||||||
deadline_at?: string | null
|
deadline_at?: string | null
|
||||||
|
urgency_threshold_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskCreateResult {
|
export interface TaskCreateResult {
|
||||||
@@ -19,6 +21,7 @@ export interface TaskCreateResult {
|
|||||||
title: string
|
title: string
|
||||||
priority_group: number
|
priority_group: number
|
||||||
deadline_at?: string | null
|
deadline_at?: string | null
|
||||||
|
urgency_threshold_at?: string | null
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,18 @@ export interface TaskMutationResult {
|
|||||||
status: string
|
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 {
|
export interface TaskBrief {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
|
||||||
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
|
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
|
||||||
import { completeTask, createTask, getTasks, undoCompleteTask } from '@/api/task'
|
import { completeTask, createTask, getTasks, undoCompleteTask, updateTask, deleteTask } from '@/api/task'
|
||||||
import { getTodaySchedule } from '@/api/schedule'
|
import { getTodaySchedule } from '@/api/schedule'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
import type { TaskItem, TodayEvent } from '@/types/dashboard'
|
||||||
import { formatHeaderDate } from '@/utils/date'
|
import { formatHeaderDate } from '@/utils/date'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const pageLoading = ref(true)
|
const pageLoading = ref(true)
|
||||||
const taskLoading = ref(true)
|
const taskLoading = ref(true)
|
||||||
const scheduleLoading = ref(true)
|
const scheduleLoading = ref(true)
|
||||||
const createTaskLoading = ref(false)
|
const saveTaskLoading = ref(false)
|
||||||
const logoutLoading = ref(false)
|
const logoutLoading = ref(false)
|
||||||
const createTaskDialogVisible = ref(false)
|
const taskDialogVisible = ref(false)
|
||||||
const dashboardLayoutRef = ref<HTMLElement | null>(null)
|
const isEditMode = ref(false)
|
||||||
|
const editingTaskId = ref<number | null>(null)
|
||||||
|
|
||||||
const dashboardMainRef = ref<HTMLElement | null>(null)
|
const dashboardMainRef = ref<HTMLElement | null>(null)
|
||||||
const dashboardMainInnerRef = ref<HTMLElement | null>(null)
|
const dashboardMainInnerRef = ref<HTMLElement | null>(null)
|
||||||
const dashboardTopbarRef = ref<HTMLElement | null>(null)
|
const dashboardTopbarRef = ref<HTMLElement | null>(null)
|
||||||
@@ -35,13 +36,14 @@ const taskForm = reactive<{
|
|||||||
title: string
|
title: string
|
||||||
priority_group: number
|
priority_group: number
|
||||||
deadline_at: Date | null
|
deadline_at: Date | null
|
||||||
|
urgency_threshold_at: Date | null
|
||||||
}>({
|
}>({
|
||||||
title: '',
|
title: '',
|
||||||
priority_group: 2,
|
priority_group: 2,
|
||||||
deadline_at: null,
|
deadline_at: null,
|
||||||
|
urgency_threshold_at: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const quadrantOrder = [1, 2, 3, 4] as const
|
const quadrantOrder = [1, 2, 3, 4] as const
|
||||||
|
|
||||||
const quadrantMeta: Record<
|
const quadrantMeta: Record<
|
||||||
@@ -52,25 +54,25 @@ const quadrantMeta: Record<
|
|||||||
title: '重要且紧急',
|
title: '重要且紧急',
|
||||||
caption: '优先处理',
|
caption: '优先处理',
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
emptyText: '暂无需要立刻推进的事项',
|
emptyText: '暂无关键紧急任务',
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
title: '重要不紧急',
|
title: '重要不紧急',
|
||||||
caption: '持续推进',
|
caption: '持续推进',
|
||||||
tone: 'primary',
|
tone: 'primary',
|
||||||
emptyText: '这里适合放长期投入的关键任务',
|
emptyText: '这里放长期核心任务',
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
title: '简单不重要',
|
title: '简单不重要',
|
||||||
caption: '顺手完成',
|
caption: '顺手完成',
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
emptyText: '暂无高频但低价值的小任务',
|
emptyText: '暂无琐碎低价值任务',
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
title: '不简单不重要',
|
title: '不简单不重要',
|
||||||
caption: '谨慎投入',
|
caption: '谨慎投入',
|
||||||
tone: 'slate',
|
tone: 'slate',
|
||||||
emptyText: '这里可以放暂缓事项或后续再评估的任务',
|
emptyText: '这里放辅助或暂缓任务',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +111,8 @@ async function loadScheduleData() {
|
|||||||
|
|
||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
pageLoading.value = true
|
pageLoading.value = true
|
||||||
|
|
||||||
// 锁死最少加载时间,确保骨架屏平稳滑入定型后,再进行内外数据的交叉溶解
|
|
||||||
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
|
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
|
||||||
|
|
||||||
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
|
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
|
||||||
|
|
||||||
pageLoading.value = false
|
pageLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,26 +133,82 @@ async function handleTaskToggle(task: TaskItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateTaskDialog() {
|
function openCreateTaskDialog() {
|
||||||
|
isEditMode.value = false
|
||||||
|
editingTaskId.value = null
|
||||||
taskForm.title = ''
|
taskForm.title = ''
|
||||||
taskForm.priority_group = 2
|
taskForm.priority_group = 2
|
||||||
taskForm.deadline_at = null
|
taskForm.deadline_at = null
|
||||||
createTaskDialogVisible.value = true
|
taskForm.urgency_threshold_at = null
|
||||||
|
taskDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateTask() {
|
function handleTaskEdit(task: TaskItem) {
|
||||||
if (!taskForm.title.trim()) { ElMessage.warning('请先填写任务标题'); return }
|
isEditMode.value = true
|
||||||
createTaskLoading.value = true
|
editingTaskId.value = task.id
|
||||||
|
taskForm.title = task.title
|
||||||
|
taskForm.priority_group = task.priority_group
|
||||||
|
taskForm.deadline_at = task.deadline ? new Date(task.deadline) : null
|
||||||
|
taskForm.urgency_threshold_at = task.urgency_threshold_at ? new Date(task.urgency_threshold_at) : null
|
||||||
|
taskDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskDelete(task: TaskItem) {
|
||||||
try {
|
try {
|
||||||
const created = await createTask({
|
await ElMessageBox.confirm('确定要删除该任务吗?操作不可撤销。', '确认删除', {
|
||||||
title: taskForm.title.trim(),
|
confirmButtonText: '确定删除',
|
||||||
priority_group: taskForm.priority_group,
|
cancelButtonText: '取消',
|
||||||
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
|
type: 'warning',
|
||||||
|
roundButton: true
|
||||||
})
|
})
|
||||||
tasks.value.unshift({ id: created.id, user_id: 0, title: created.title, priority_group: created.priority_group, status: created.status, deadline: created.deadline_at ?? '', is_completed: false })
|
|
||||||
createTaskDialogVisible.value = false
|
await deleteTask(task.id)
|
||||||
ElMessage.success('任务已添加')
|
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '创建任务失败') }
|
ElMessage.success('任务已成功删除')
|
||||||
finally { createTaskLoading.value = false }
|
} catch (error) {
|
||||||
|
if (error === 'cancel') return
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '任务删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveTask() {
|
||||||
|
if (!taskForm.title.trim()) { ElMessage.warning('请先填写任务标题'); return }
|
||||||
|
saveTaskLoading.value = true
|
||||||
|
try {
|
||||||
|
if (isEditMode.value && editingTaskId.value) {
|
||||||
|
// 执行更新交互
|
||||||
|
const updated = await updateTask({
|
||||||
|
task_id: editingTaskId.value,
|
||||||
|
title: taskForm.title.trim(),
|
||||||
|
priority_group: taskForm.priority_group,
|
||||||
|
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
|
||||||
|
urgency_threshold_at: taskForm.urgency_threshold_at ? taskForm.urgency_threshold_at.toISOString() : null,
|
||||||
|
})
|
||||||
|
const idx = tasks.value.findIndex(t => t.id === updated.id)
|
||||||
|
if (idx !== -1) tasks.value[idx] = updated
|
||||||
|
ElMessage.success('任务已更新')
|
||||||
|
} else {
|
||||||
|
// 执行创建交互
|
||||||
|
const created = await createTask({
|
||||||
|
title: taskForm.title.trim(),
|
||||||
|
priority_group: taskForm.priority_group,
|
||||||
|
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
|
||||||
|
urgency_threshold_at: taskForm.urgency_threshold_at ? taskForm.urgency_threshold_at.toISOString() : null,
|
||||||
|
})
|
||||||
|
tasks.value.unshift({
|
||||||
|
id: created.id,
|
||||||
|
user_id: 0,
|
||||||
|
title: created.title,
|
||||||
|
priority_group: created.priority_group,
|
||||||
|
status: created.status,
|
||||||
|
deadline: created.deadline_at ?? '',
|
||||||
|
is_completed: false,
|
||||||
|
urgency_threshold_at: created.urgency_threshold_at
|
||||||
|
})
|
||||||
|
ElMessage.success('任务已添加')
|
||||||
|
}
|
||||||
|
taskDialogVisible.value = false
|
||||||
|
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存任务失败') }
|
||||||
|
finally { saveTaskLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@@ -164,8 +218,6 @@ async function handleLogout() {
|
|||||||
finally { logoutLoading.value = false; await router.push('/auth') }
|
finally { logoutLoading.value = false; await router.push('/auth') }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCourseImportEntry() { ElMessage.info('课表导入入口已预留') }
|
|
||||||
|
|
||||||
function syncDashboardMainScale() {
|
function syncDashboardMainScale() {
|
||||||
const main = dashboardMainRef.value
|
const main = dashboardMainRef.value
|
||||||
const inner = dashboardMainInnerRef.value
|
const inner = dashboardMainInnerRef.value
|
||||||
@@ -241,6 +293,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
:tasks="groupedTasks[group]"
|
:tasks="groupedTasks[group]"
|
||||||
:loading="taskLoading || pageLoading"
|
:loading="taskLoading || pageLoading"
|
||||||
@toggle="handleTaskToggle"
|
@toggle="handleTaskToggle"
|
||||||
|
@edit="handleTaskEdit"
|
||||||
|
@delete="handleTaskDelete"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -261,8 +315,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="createTaskDialogVisible"
|
v-model="taskDialogVisible"
|
||||||
title="添加新任务"
|
:title="isEditMode ? '编辑任务详情' : '添加新任务'"
|
||||||
width="440px"
|
width="440px"
|
||||||
align-center
|
align-center
|
||||||
class="dashboard-dialog premium-dialog"
|
class="dashboard-dialog premium-dialog"
|
||||||
@@ -279,21 +333,32 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
<el-option :value="4" label="4 - 不简单不重要" />
|
<el-option :value="4" label="4 - 不简单不重要" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="截止时间">
|
<div class="dialog-double-row">
|
||||||
<el-date-picker
|
<el-form-item label="截止时间" class="half">
|
||||||
v-model="taskForm.deadline_at"
|
<el-date-picker
|
||||||
type="datetime"
|
v-model="taskForm.deadline_at"
|
||||||
placeholder="可选"
|
type="datetime"
|
||||||
class="dashboard-dialog__select"
|
placeholder="截止时间"
|
||||||
popper-class="premium-select-popper"
|
class="dashboard-dialog__select"
|
||||||
/>
|
popper-class="premium-select-popper"
|
||||||
</el-form-item>
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="紧急阈值" class="half">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="taskForm.urgency_threshold_at"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="进入紧急的时间点"
|
||||||
|
class="dashboard-dialog__select"
|
||||||
|
popper-class="premium-select-popper"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="premium-dialog__footer">
|
<div class="premium-dialog__footer">
|
||||||
<button class="premium-btn premium-btn--ghost" @click="createTaskDialogVisible = false">取消</button>
|
<button class="premium-btn premium-btn--ghost" @click="taskDialogVisible = false">取消</button>
|
||||||
<button class="premium-btn premium-btn--primary" :disabled="createTaskLoading" @click="handleCreateTask">
|
<button class="premium-btn premium-btn--primary" :disabled="saveTaskLoading" @click="handleSaveTask">
|
||||||
{{ createTaskLoading ? '保存中...' : '确认添加' }}
|
{{ saveTaskLoading ? '保存中...' : (isEditMode ? '保存修改' : '确认添加') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -424,6 +489,17 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-double-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dialog-double-row .half {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* 关键:强制子项可收缩 */
|
||||||
|
}
|
||||||
|
|
||||||
.premium-btn {
|
.premium-btn {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
@@ -486,11 +562,13 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.premium-dialog .el-input__wrapper),
|
:global(.premium-dialog .el-input__wrapper),
|
||||||
:global(.premium-dialog .el-select__wrapper) {
|
:global(.premium-dialog .el-select__wrapper),
|
||||||
|
:global(.premium-dialog .el-date-editor.el-input__wrapper) {
|
||||||
background-color: #f8fafc !important;
|
background-color: #f8fafc !important;
|
||||||
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
|
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
padding: 4px 12px !important;
|
padding: 4px 12px !important;
|
||||||
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.premium-dialog .el-input__wrapper.is-focus),
|
:global(.premium-dialog .el-input__wrapper.is-focus),
|
||||||
|
|||||||
356
frontend/src/views/TaskInteractiveDemo.vue
Normal file
356
frontend/src/views/TaskInteractiveDemo.vue
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
// --- 模拟数据 ---
|
||||||
|
const tasks = ref([
|
||||||
|
{ id: 1, title: '完成系统架构重构方案', is_completed: false, ddl: '2024-04-25T10:00:00', priority: 1 },
|
||||||
|
{ id: 2, title: '准备技术分享 PPT', is_completed: true, ddl: '2024-04-24T14:00:00', priority: 2 },
|
||||||
|
{ id: 3, title: '代码 Review:用户模块', is_completed: false, ddl: '2024-04-23T18:00:00', priority: 3 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const quadrantMap = {
|
||||||
|
1: { label: '重要且紧急', color: '#ef4444' },
|
||||||
|
2: { label: '重要不紧急', color: '#3b82f6' },
|
||||||
|
3: { label: '简单不重要', color: '#f59e0b' },
|
||||||
|
4: { label: '不简单不重要', color: '#64748b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 交互状态 ---
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
|
const currentEditingTask = reactive({
|
||||||
|
id: 0,
|
||||||
|
title: '',
|
||||||
|
ddl: '',
|
||||||
|
priority: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. 切换状态 (独立响应区)
|
||||||
|
const toggleTask = (task: any) => {
|
||||||
|
task.is_completed = !task.is_completed
|
||||||
|
ElMessage.success({
|
||||||
|
message: task.is_completed ? '标记为已完成' : '已恢复为待办',
|
||||||
|
customClass: 'premium-msg'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 编辑任务 (正中主响应区)
|
||||||
|
const openEdit = (task: any) => {
|
||||||
|
currentEditingTask.id = task.id
|
||||||
|
currentEditingTask.title = task.title
|
||||||
|
currentEditingTask.ddl = task.ddl
|
||||||
|
currentEditingTask.priority = task.priority
|
||||||
|
editDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 删除任务 (悬浮侧响应区)
|
||||||
|
const deleteTask = (task: any) => {
|
||||||
|
ElMessageBox.confirm('确定要删除这个任务吗?此操作不可撤销。', '确认删除', {
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
confirmButtonClass: 'flat-btn-danger',
|
||||||
|
cancelButtonClass: 'flat-btn-ghost',
|
||||||
|
center: true,
|
||||||
|
}).then(() => {
|
||||||
|
tasks.value = tasks.value.filter(t => t.id !== task.id)
|
||||||
|
ElMessage.success('任务已安全移除')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
const task = tasks.value.find(t => t.id === currentEditingTask.id)
|
||||||
|
if (task) {
|
||||||
|
task.title = currentEditingTask.title
|
||||||
|
task.ddl = currentEditingTask.ddl
|
||||||
|
task.priority = currentEditingTask.priority
|
||||||
|
editDialogVisible.value = false
|
||||||
|
ElMessage.success('任务已更新')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSimpleDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '无截止时间'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getMonth() + 1}-${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="demo-page">
|
||||||
|
<div class="demo-card">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
<p class="subtitle">UPGRADED INTERACTION</p>
|
||||||
|
<h2>全参数扁平化示例</h2>
|
||||||
|
</div>
|
||||||
|
<span class="count-badge">{{ tasks.length }} 项</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="task-list">
|
||||||
|
<TransitionGroup name="task-anime">
|
||||||
|
<div
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="task-item"
|
||||||
|
:class="{ 'is-completed': task.is_completed }"
|
||||||
|
>
|
||||||
|
<!-- 区域1: 独立勾选框 -->
|
||||||
|
<div class="check-box-wrapper" @click.stop="toggleTask(task)">
|
||||||
|
<div class="check-box-inner">
|
||||||
|
<svg v-if="task.is_completed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="4">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 区域2: 文本主体内容 (点击触发编辑) -->
|
||||||
|
<div class="task-body" @click="openEdit(task)">
|
||||||
|
<div class="task-text-row">
|
||||||
|
<span class="task-text">{{ task.title }}</span>
|
||||||
|
<span class="priority-tag" :style="{ color: quadrantMap[task.priority as keyof typeof quadrantMap].color }">
|
||||||
|
{{ quadrantMap[task.priority as keyof typeof quadrantMap].label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
|
{{ formatSimpleDate(task.ddl) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 区域3: 悬浮操作菜单 -->
|
||||||
|
<div class="hover-actions-panel">
|
||||||
|
<button class="action-btn-mini delete-btn" @click.stop="deleteTask(task)">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-tip">快捷操作:点击任务主体进行全参数编辑</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 深度扁平化编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="editDialogVisible"
|
||||||
|
title="编辑详细参数"
|
||||||
|
width="440px"
|
||||||
|
align-center
|
||||||
|
class="flat-dialog"
|
||||||
|
:show-close="false"
|
||||||
|
>
|
||||||
|
<div class="flat-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<label>任务标题</label>
|
||||||
|
<input v-model="currentEditingTask.title" class="flat-input" placeholder="输入任务标题..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-item half">
|
||||||
|
<label>优先级象限</label>
|
||||||
|
<el-select v-model="currentEditingTask.priority" popper-class="flat-select-popper" class="flat-select">
|
||||||
|
<el-option v-for="(v, k) in quadrantMap" :key="k" :label="v.label" :value="Number(k)" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="form-item half">
|
||||||
|
<label>截止日期</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="currentEditingTask.ddl"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
||||||
|
class="flat-picker"
|
||||||
|
popper-class="flat-picker-popper"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flat-footer">
|
||||||
|
<button class="flat-btn ghost" @click="editDialogVisible = false">放弃修改</button>
|
||||||
|
<button class="flat-btn primary" @click="saveEdit">保存并同步</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 基础布局 */
|
||||||
|
.demo-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fdfdfd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.03);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表样式 */
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
position: relative;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-box-inner {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
margin-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-completed .check-box-inner {
|
||||||
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-body { flex: 1; min-width: 0; }
|
||||||
|
.task-text-row { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||||||
|
.task-text { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||||||
|
.is-completed .task-text { color: #94a3b8; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.priority-tag { font-size: 11px; font-weight: 700; background: rgba(0,0,0,0.03); padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.task-meta { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
|
||||||
|
|
||||||
|
.hover-actions-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover .hover-actions-panel { opacity: 1; }
|
||||||
|
.action-btn-mini { border: none; background: transparent; color: #ef4444; cursor: pointer; padding: 4px; border-radius: 6px; }
|
||||||
|
.action-btn-mini:hover { background: #fee2e2; }
|
||||||
|
|
||||||
|
/* 深度扁平化弹窗 - 关键美化部分 */
|
||||||
|
:global(.flat-dialog) {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #f1f5f9 !important;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.flat-dialog .el-dialog__header) {
|
||||||
|
padding: 24px 28px 10px !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.flat-dialog .el-dialog__title) {
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.flat-dialog .el-dialog__body) {
|
||||||
|
padding: 10px 28px 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-form { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.form-row { display: flex; gap: 16px; }
|
||||||
|
.form-item { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.form-item.half { flex: 1; }
|
||||||
|
.form-item label { font-size: 12px; font-weight: 800; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|
||||||
|
/* 纯扁平输入框 */
|
||||||
|
.flat-input {
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.flat-input:focus { border-color: #3b82f6; background: #fff; }
|
||||||
|
|
||||||
|
/* Element Plus 深度覆盖 */
|
||||||
|
:global(.flat-select .el-select__wrapper),
|
||||||
|
:global(.flat-picker.el-input__wrapper) {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
height: 42px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-footer { display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #f1f5f9; padding-top: 20px; }
|
||||||
|
.flat-btn { height: 42px; padding: 0 20px; border-radius: 10px; font-weight: 700; font-size: 13px; cursor: pointer; border: none; transition: all 0.2s; }
|
||||||
|
.flat-btn.primary { background: #0f172a; color: #fff; }
|
||||||
|
.flat-btn.primary:hover { background: #334155; }
|
||||||
|
.flat-btn.ghost { background: transparent; color: #64748b; }
|
||||||
|
.flat-btn.ghost:hover { background: #f1f5f9; }
|
||||||
|
|
||||||
|
.footer-tip { margin-top: 24px; font-size: 12px; color: #94a3b8; text-align: center; }
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
.task-anime-enter-active { transition: all 0.3s ease; }
|
||||||
|
.task-anime-enter-from { opacity: 0; transform: scale(0.95); }
|
||||||
|
.task-anime-leave-to { opacity: 0; transform: scale(1.05); }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user