Version: 0.9.48.dev.260428
后端: 1.新增任务批量状态查询能力,补齐入参归一化、单次上限控制、按当前用户隔离与空结果兼容。 2.QuickTask 从纯文本升级为“正文 + business_card”输出,覆盖 task_record/task_query 两类卡片语义。 3.查询链路新增时间窗边界筛选与异常窗口兜底,SSE/timeline 同步扩展 business_card 事件并持久化。 前端: 1.助手面板接入任务状态 hydration 与增量同步,卡片状态可实时联动(完成/撤销、编辑、删除、同步中)。 2.TaskRecord/TaskQuery 卡片升级为可交互任务卡,并新增对话页任务编辑弹窗与回写闭环。 3.助手路由升级为 /assistant/:id?,支持 URL 驱动会话切换与刷新恢复。 仓库: 同步更新 business card 前端对接说明文档。
This commit is contained in:
@@ -63,6 +63,39 @@ func (th *TaskHandler) GetUserTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
||||
}
|
||||
|
||||
// BatchTaskStatus 批量查询当前用户任务的实时完成状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责解析 ids 与读取鉴权上下文中的 user_id;
|
||||
// 2. 负责调用 Service 复用任务缓存读取链路;
|
||||
// 3. 不修改任务、不触发幂等中间件、不反写 NewAgent timeline 历史 payload。
|
||||
func (th *TaskHandler) BatchTaskStatus(c *gin.Context) {
|
||||
// 1. 绑定请求参数。ids 允许为空切片,表示前端当前没有需要 hydration 的任务卡片。
|
||||
var req model.BatchTaskStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 从鉴权上下文读取 user_id,Service 会继续用该 user_id 限定任务集合。
|
||||
userID := c.GetInt("user_id")
|
||||
|
||||
// 3. 设置短超时:该接口只读缓存/任务列表,避免异常情况下长时间占用连接。
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 4. 调用 Service 做 ID 归一化与当前状态查询。
|
||||
resp, err := th.svc.BatchTaskStatus(ctx, &req, userID)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 返回统一响应结构,items 为空时仍按 success 返回,便于前端无分支处理。
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
|
||||
}
|
||||
|
||||
// CompleteTask 标记任务为已完成。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
AgentTimelineKindToolCall = "tool_call"
|
||||
AgentTimelineKindToolResult = "tool_result"
|
||||
AgentTimelineKindConfirmRequest = "confirm_request"
|
||||
AgentTimelineKindBusinessCard = "business_card"
|
||||
AgentTimelineKindScheduleCompleted = "schedule_completed"
|
||||
)
|
||||
|
||||
|
||||
@@ -113,6 +113,35 @@ type GetUserTaskResp struct {
|
||||
UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"`
|
||||
}
|
||||
|
||||
// BatchTaskStatusRequest 是任务批量状态查询请求体。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载前端从历史卡片中提取的任务 ID 列表;
|
||||
// 2. 不承载 user_id,用户身份必须来自鉴权上下文,避免越权查询;
|
||||
// 3. 不表达任务是否必须存在,不存在或无权访问的任务由 Service 静默过滤。
|
||||
type BatchTaskStatusRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchTaskStatusItem 是单个任务当前完成状态快照。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前 Task 模型未维护 UpdatedAt 字段,因此这里只返回可用的 id/is_completed;
|
||||
// 2. 该结构表示"当前状态",不用于反写 NewAgent timeline 历史 payload。
|
||||
type BatchTaskStatusItem struct {
|
||||
ID int `json:"id"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
}
|
||||
|
||||
// BatchTaskStatusResponse 是批量任务状态查询响应体。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. items 只包含当前登录用户有权访问且仍存在的任务;
|
||||
// 2. ids 为空、非法 ID 全部被过滤、或无匹配任务时,items 为空切片而不是业务错误。
|
||||
type BatchTaskStatusResponse struct {
|
||||
Items []BatchTaskStatusItem `json:"items"`
|
||||
}
|
||||
|
||||
// UserUpdateTaskRequest 是"更新任务属性"接口的请求体。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
quickTaskStageName = "quick_task"
|
||||
quickTaskBlockID = "qt_main"
|
||||
quickTaskStageName = "quick_task"
|
||||
quickTaskBlockID = "qt_main"
|
||||
quickTaskResultCardID = "quick_task.result"
|
||||
taskRecordSourceQuickNote = "quick_note"
|
||||
)
|
||||
|
||||
// QuickTaskNodeInput 描述快捷任务节点的输入。
|
||||
@@ -43,14 +45,27 @@ type quickTaskDecision struct {
|
||||
TaskID *int `json:"task_id,omitempty"`
|
||||
|
||||
// query 参数
|
||||
Quadrant *int `json:"quadrant,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
Quadrant *int `json:"quadrant,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
DeadlineAfter string `json:"deadline_after,omitempty"`
|
||||
DeadlineBefore string `json:"deadline_before,omitempty"`
|
||||
|
||||
// ask 参数
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// quickTaskActionResult 是 quick_task 执行动作后的统一回包。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. AssistantText 是本轮要补发给用户的短正文;
|
||||
// 2. BusinessCard 仅在“业务真实成功”时携带,失败/追问场景必须为空;
|
||||
// 3. 不负责直接发射,发射时机由 RunQuickTaskNode 统一控制。
|
||||
type quickTaskActionResult struct {
|
||||
AssistantText string
|
||||
BusinessCard *newagentstream.StreamBusinessCardExtra
|
||||
}
|
||||
|
||||
// RunQuickTaskNode 执行快捷任务节点:流式 LLM 提取意图 → 直接调 service → 追加结果。
|
||||
func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
||||
flowState := input.RuntimeState.EnsureCommonState()
|
||||
@@ -158,7 +173,8 @@ func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
||||
if decision == nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) == "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, "抱歉,处理任务时出了点问题,请重试。", true)
|
||||
finalText = "抱歉,处理任务时出了点问题,请重试。"
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, finalText, true)
|
||||
}
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
@@ -170,32 +186,52 @@ func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error {
|
||||
log.Printf("[DEBUG] quick_task: chat=%s action=%s raw_title=%s", flowState.ConversationID, decision.Action, decision.Title)
|
||||
|
||||
// 5. 根据意图执行操作。
|
||||
var resultText string
|
||||
result := quickTaskActionResult{}
|
||||
switch decision.Action {
|
||||
case "create":
|
||||
resultText = handleQuickTaskCreate(ctx, input, decision, flowState)
|
||||
result = handleQuickTaskCreate(ctx, input, decision, flowState)
|
||||
case "query":
|
||||
resultText = handleQuickTaskQuery(ctx, input, decision, flowState)
|
||||
result = handleQuickTaskQuery(ctx, input, decision, flowState)
|
||||
case "ask":
|
||||
resultText = decision.Question
|
||||
if resultText == "" {
|
||||
resultText = "你想记录什么呢?告诉我具体内容吧。"
|
||||
result.AssistantText = decision.Question
|
||||
if result.AssistantText == "" {
|
||||
result.AssistantText = "你想记录什么呢?告诉我具体内容吧。"
|
||||
}
|
||||
default:
|
||||
resultText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。"
|
||||
result.AssistantText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。"
|
||||
}
|
||||
|
||||
// 6. 追加操作结果文本。
|
||||
if resultText != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, resultText, false)
|
||||
fullText.WriteString(resultText)
|
||||
// 6. 追加操作结果正文。
|
||||
if result.AssistantText != "" {
|
||||
_ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.AssistantText, false)
|
||||
fullText.WriteString(result.AssistantText)
|
||||
}
|
||||
|
||||
// 7. 写入对话历史。
|
||||
finalText := fullText.String()
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
messagePersisted := false
|
||||
// 7.1 有业务卡片时,先落正文,再发卡片,保证 timeline 顺序与前端展示一致。
|
||||
// 1. 先持久化正文,确保 timeline 里的 assistant_text seq 一定早于 business_card;
|
||||
// 2. 再发 business_card,保证“短正文 + 紧跟卡片”的时序契约;
|
||||
// 3. 卡片发射失败只记日志,不回滚正文,避免用户侧出现“看不到结果文本”的回退。
|
||||
if result.BusinessCard != nil {
|
||||
finalText := fullText.String()
|
||||
if strings.TrimSpace(finalText) != "" {
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
messagePersisted = true
|
||||
}
|
||||
if emitErr := emitter.EmitBusinessCard(quickTaskResultCardID, quickTaskStageName, result.BusinessCard); emitErr != nil {
|
||||
log.Printf("[WARN] quick_task emit business_card error chat=%s err=%v", flowState.ConversationID, emitErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 7.2 非卡片路径沿用原有收口:本轮正文统一一次性写入 history。
|
||||
if !messagePersisted {
|
||||
finalText := fullText.String()
|
||||
msg := schema.AssistantMessage(finalText, nil)
|
||||
input.ConversationContext.AppendHistory(msg)
|
||||
persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg)
|
||||
}
|
||||
|
||||
flowState.Phase = newagentmodel.PhaseDone
|
||||
return nil
|
||||
@@ -207,17 +243,18 @@ func handleQuickTaskCreate(
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) string {
|
||||
) quickTaskActionResult {
|
||||
_ = ctx
|
||||
title := strings.TrimSpace(decision.Title)
|
||||
if title == "" {
|
||||
return "你想记录什么呢?告诉我具体内容吧。"
|
||||
return quickTaskActionResult{AssistantText: "你想记录什么呢?告诉我具体内容吧。"}
|
||||
}
|
||||
|
||||
var deadline *time.Time
|
||||
if raw := strings.TrimSpace(decision.DeadlineAt); raw != "" {
|
||||
parsed, err := newagentshared.ParseOptionalDeadline(raw)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err)
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err)}
|
||||
}
|
||||
deadline = parsed
|
||||
}
|
||||
@@ -245,23 +282,16 @@ func handleQuickTaskCreate(
|
||||
|
||||
log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d deadline=%v urgencyThreshold=%v urgency_raw=%q",
|
||||
flowState.ConversationID, title, priorityGroup, deadline, urgencyThreshold, decision.UrgencyThresholdAt)
|
||||
_, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, deadline, urgencyThreshold)
|
||||
taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, deadline, urgencyThreshold)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("记录失败了(%s),稍后再试试?", err)
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
flowState.UsedQuickNote = true
|
||||
|
||||
priorityLabel := newagentshared.PriorityLabelCN(priorityGroup)
|
||||
deadlineStr := ""
|
||||
if deadline != nil {
|
||||
deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "已帮你记下这条任务。",
|
||||
BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, deadline, urgencyThreshold),
|
||||
}
|
||||
|
||||
if deadlineStr != "" {
|
||||
return fmt.Sprintf("已记录:%s(%s,截止 %s)", title, priorityLabel, deadlineStr)
|
||||
}
|
||||
return fmt.Sprintf("已记录:%s(%s)", title, priorityLabel)
|
||||
}
|
||||
|
||||
// handleQuickTaskQuery 处理任务查询。
|
||||
@@ -270,7 +300,7 @@ func handleQuickTaskQuery(
|
||||
input QuickTaskNodeInput,
|
||||
decision *quickTaskDecision,
|
||||
flowState *newagentmodel.CommonState,
|
||||
) string {
|
||||
) quickTaskActionResult {
|
||||
params := newagentmodel.TaskQueryParams{
|
||||
SortBy: "deadline",
|
||||
Order: "asc",
|
||||
@@ -287,32 +317,254 @@ func handleQuickTaskQuery(
|
||||
if decision.Limit != nil && *decision.Limit > 0 && *decision.Limit <= 20 {
|
||||
params.Limit = *decision.Limit
|
||||
}
|
||||
params.DeadlineAfter = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineAfter, "deadline_after", flowState)
|
||||
params.DeadlineBefore = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineBefore, "deadline_before", flowState)
|
||||
// 1. 若模型给出了颠倒的时间窗(before<=after),当前轮降级为“不加时间窗”继续查询;
|
||||
// 2. 这样能避免误筛选成空结果,同时把异常留给日志排查;
|
||||
// 3. 这里只做兜底,不尝试替模型自动纠正语义,避免引入额外猜测。
|
||||
if params.DeadlineAfter != nil && params.DeadlineBefore != nil && !params.DeadlineBefore.After(*params.DeadlineAfter) {
|
||||
log.Printf("[WARN] quick_task: query 时间窗无效 chat=%s after=%s before=%s,已降级为无时间窗筛选",
|
||||
flowState.ConversationID,
|
||||
formatQuickTaskTime(params.DeadlineAfter),
|
||||
formatQuickTaskTime(params.DeadlineBefore),
|
||||
)
|
||||
params.DeadlineAfter = nil
|
||||
params.DeadlineBefore = nil
|
||||
}
|
||||
|
||||
results, err := input.QuickTaskDeps.QueryTasks(ctx, flowState.UserID, params)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("查询失败了(%s),稍后再试试?", err)
|
||||
return quickTaskActionResult{AssistantText: fmt.Sprintf("查询失败了(%s),稍后再试试?", err)}
|
||||
}
|
||||
|
||||
card := buildTaskQueryBusinessCard(params, results)
|
||||
if len(results) == 0 {
|
||||
return "当前没有匹配的任务。"
|
||||
return quickTaskActionResult{
|
||||
AssistantText: "我这边没查到匹配任务。",
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("找到 %d 条任务:\n", len(results)))
|
||||
for _, t := range results {
|
||||
label := newagentshared.PriorityLabelCN(t.PriorityGroup)
|
||||
line := fmt.Sprintf("- %s(%s", t.Title, label)
|
||||
if t.DeadlineAt != "" {
|
||||
line += fmt.Sprintf(",截止 %s", t.DeadlineAt)
|
||||
}
|
||||
line += ")"
|
||||
if t.IsCompleted {
|
||||
line += " ✅"
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
return quickTaskActionResult{
|
||||
AssistantText: fmt.Sprintf("我找到 %d 条任务,整理成卡片给你。", len(results)),
|
||||
BusinessCard: card,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, deadline *time.Time, urgencyThreshold *time.Time) *newagentstream.StreamBusinessCardExtra {
|
||||
data := map[string]any{
|
||||
"id": taskID,
|
||||
"title": strings.TrimSpace(title),
|
||||
"priority_group": priorityGroup,
|
||||
"priority_label": newagentshared.PriorityLabelCN(priorityGroup),
|
||||
"status": "todo",
|
||||
}
|
||||
if formatted := formatQuickTaskTime(deadline); formatted != "" {
|
||||
data["deadline_at"] = formatted
|
||||
}
|
||||
if formatted := formatQuickTaskTime(urgencyThreshold); formatted != "" {
|
||||
data["urgency_threshold_at"] = formatted
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
// 说明:
|
||||
// 1. quick_task 当前只有 action=create,未显式区分“随口记 / 正式创建任务”;
|
||||
// 2. 仅凭当前 prompt 决策无法稳定判断 source=create_task,会引入误判;
|
||||
// 3. 本轮按最小安全口径固定为 quick_note,等后续补稳定判别字段再切分。
|
||||
return &newagentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_record",
|
||||
Title: "已帮你记下",
|
||||
Summary: "一条轻量提醒已写入任务系统",
|
||||
Source: taskRecordSourceQuickNote,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func buildTaskQueryBusinessCard(params newagentmodel.TaskQueryParams, results []newagentmodel.TaskQueryResult) *newagentstream.StreamBusinessCardExtra {
|
||||
taskItems := make([]map[string]any, 0, len(results))
|
||||
for _, task := range results {
|
||||
item := map[string]any{
|
||||
"id": task.ID,
|
||||
"title": strings.TrimSpace(task.Title),
|
||||
"priority_group": task.PriorityGroup,
|
||||
"priority_label": newagentshared.PriorityLabelCN(task.PriorityGroup),
|
||||
"is_completed": task.IsCompleted,
|
||||
}
|
||||
if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" {
|
||||
item["deadline_at"] = deadline
|
||||
}
|
||||
taskItems = append(taskItems, item)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("找到 %d 条任务", len(results))
|
||||
if len(results) == 0 {
|
||||
title = "未找到匹配任务"
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"result_count": len(results),
|
||||
"shown_count": len(results),
|
||||
"tasks": taskItems,
|
||||
}
|
||||
queryFilters := buildTaskQueryFilters(params)
|
||||
if len(queryFilters) > 0 {
|
||||
data["query_filters"] = queryFilters
|
||||
}
|
||||
querySummary := buildTaskQuerySummary(queryFilters)
|
||||
if querySummary != "" {
|
||||
data["query_summary"] = querySummary
|
||||
}
|
||||
|
||||
return &newagentstream.StreamBusinessCardExtra{
|
||||
CardType: "task_query",
|
||||
Title: title,
|
||||
Summary: querySummary,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// buildTaskQueryFilter 生成查询条件的稳定结构化描述。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. key/operator/value 提供前端可依赖的机器语义;
|
||||
// 2. label/display_text 提供前端可直接展示的中文文案;
|
||||
// 3. query_summary 只能从 display_text 派生,前端不要再反向解析 summary。
|
||||
func buildTaskQueryFilter(key string, label string, value any, operator string, displayText string) map[string]any {
|
||||
filter := map[string]any{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"display_text": strings.TrimSpace(displayText),
|
||||
}
|
||||
if strings.TrimSpace(operator) != "" {
|
||||
filter["operator"] = strings.TrimSpace(operator)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func buildTaskQueryFilters(params newagentmodel.TaskQueryParams) []map[string]any {
|
||||
filters := make([]map[string]any, 0, 6)
|
||||
if params.Quadrant != nil && *params.Quadrant >= 1 && *params.Quadrant <= 4 {
|
||||
label := newagentshared.PriorityLabelCN(*params.Quadrant)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"quadrant",
|
||||
"象限",
|
||||
*params.Quadrant,
|
||||
"eq",
|
||||
fmt.Sprintf("象限:%s", label),
|
||||
))
|
||||
}
|
||||
if kw := strings.TrimSpace(params.Keyword); kw != "" {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"keyword",
|
||||
"关键词",
|
||||
kw,
|
||||
"contains",
|
||||
fmt.Sprintf("关键词:%s", kw),
|
||||
))
|
||||
}
|
||||
if params.DeadlineAfter != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineAfter)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_after",
|
||||
"截止起始",
|
||||
formatted,
|
||||
"gte",
|
||||
fmt.Sprintf("截止时间≥%s", formatted),
|
||||
))
|
||||
}
|
||||
if params.DeadlineBefore != nil {
|
||||
formatted := formatQuickTaskTime(params.DeadlineBefore)
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"deadline_before",
|
||||
"截止结束",
|
||||
formatted,
|
||||
"lt",
|
||||
fmt.Sprintf("截止时间<%s", formatted),
|
||||
))
|
||||
}
|
||||
if !params.IncludeCompleted {
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"include_completed",
|
||||
"完成状态",
|
||||
false,
|
||||
"eq",
|
||||
"仅未完成",
|
||||
))
|
||||
}
|
||||
|
||||
sortValue := "deadline_asc"
|
||||
sortDisplay := "按截止时间升序"
|
||||
switch strings.ToLower(strings.TrimSpace(params.SortBy)) {
|
||||
case "priority":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "priority_desc"
|
||||
sortDisplay = "按优先级降序"
|
||||
} else {
|
||||
sortValue = "priority_asc"
|
||||
sortDisplay = "按优先级升序"
|
||||
}
|
||||
case "id":
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "id_desc"
|
||||
sortDisplay = "按创建顺序倒序"
|
||||
} else {
|
||||
sortValue = "id_asc"
|
||||
sortDisplay = "按创建顺序正序"
|
||||
}
|
||||
default:
|
||||
if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" {
|
||||
sortValue = "deadline_desc"
|
||||
sortDisplay = "按截止时间降序"
|
||||
}
|
||||
}
|
||||
filters = append(filters, buildTaskQueryFilter(
|
||||
"sort",
|
||||
"排序",
|
||||
sortValue,
|
||||
"eq",
|
||||
sortDisplay,
|
||||
))
|
||||
return filters
|
||||
}
|
||||
|
||||
func buildTaskQuerySummary(filters []map[string]any) string {
|
||||
parts := make([]string, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
if text, ok := filter["display_text"].(string); ok && strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// parseQuickTaskQueryDeadlineBoundary 解析 quick_task 查询时间窗边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 query 的 deadline_after/deadline_before 文本解析成时间;
|
||||
// 2. 解析失败时仅记录日志并返回 nil,不中断查询主链路;
|
||||
// 3. 不负责时间窗合法性校验(如 before<=after),该校验由调用方统一处理。
|
||||
func parseQuickTaskQueryDeadlineBoundary(raw string, field string, flowState *newagentmodel.CommonState) *time.Time {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := newagentshared.ParseOptionalDeadline(value)
|
||||
if err != nil {
|
||||
chatID := ""
|
||||
if flowState != nil {
|
||||
chatID = flowState.ConversationID
|
||||
}
|
||||
log.Printf("[WARN] quick_task: query %s 解析失败 chat=%s raw=%q err=%v,已降级为无该筛选条件", field, chatID, value, err)
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func formatQuickTaskTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// quickNoteFallbackPriority 根据截止时间推断默认优先级。
|
||||
|
||||
@@ -14,20 +14,21 @@ const chatRoutingSystemPrompt = `
|
||||
|
||||
路由规则:
|
||||
- direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。
|
||||
- quick_task:用户明确想记录/添加/修改/删除一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。
|
||||
- execute:需要用工具处理的日程类请求(查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- quick_task:用户明确想记录/添加一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事""今天/明天/本周有什么事要做")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。
|
||||
- execute:需要用工具处理的日程编排请求(明确查课表/日程块、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。
|
||||
- deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。
|
||||
- plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。
|
||||
|
||||
quick_task 判别要点:
|
||||
- 用户明确要"记/添加/提醒"一个待办 → quick_task
|
||||
- 用户要查看/筛选/列出任务清单 → quick_task
|
||||
- 用户要修改/删除某个任务 → quick_task
|
||||
- 用户问"今天/明天/本周有什么待办/任务/事情要做"这类时间窗任务查询 → quick_task
|
||||
- 用户明确在查课表/日程块、排课、移动安排 → execute
|
||||
- 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute
|
||||
- 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问
|
||||
|
||||
任务类设计路由要点:
|
||||
- 普通"创建/修改任务类"默认走 execute(由 execute 负责补字段与写入)。
|
||||
- 普通"创建/修改任务类配置(task class)"默认走 execute(由 execute 负责补字段与写入)。
|
||||
- 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。
|
||||
- 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const quickTaskSystemPrompt = `
|
||||
JSON 字段说明:
|
||||
- action:只能是 create / query / ask
|
||||
- create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;urgency_threshold_at 满足条件时填写,条件在下面
|
||||
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选
|
||||
- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选)
|
||||
- ask 时:question 必填
|
||||
|
||||
规则:
|
||||
@@ -33,6 +33,7 @@ JSON 字段说明:
|
||||
4. 未提供的可选字段直接省略,不要填 null 或空字符串
|
||||
5. JSON 中不要包含 speak 字段,给用户看的话放在 </SMARTFLOW_DECISION> 标签之后
|
||||
6. 紧急分界时间,即任务从"重要不紧急"自动轮换到"重要且紧急"的时间点;格式同 deadline_at ——当 priority_group=2 时必填,你必须根据 deadline 自动推算一个合理的紧急分界时间(通常为 deadline 前 24-48 小时),不要等用户提供;priority_group 为 1、3、4 或无截止时间时不要输出此字段
|
||||
7. query 里出现相对日期窗口(如"明天有什么事要做")时,优先输出明确边界:deadline_after="明天 00:00",deadline_before="后天 00:00",按 [after, before) 语义筛选
|
||||
|
||||
示例:
|
||||
|
||||
@@ -44,6 +45,9 @@ JSON 字段说明:
|
||||
<SMARTFLOW_DECISION>{"action":"query","limit":5}</SMARTFLOW_DECISION>
|
||||
我帮你查一下当前的任务。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"query","deadline_after":"明天 00:00","deadline_before":"后天 00:00","limit":10}</SMARTFLOW_DECISION>
|
||||
我帮你查一下明天要做的事。
|
||||
|
||||
<SMARTFLOW_DECISION>{"action":"ask","question":"你想记录什么呢?告诉我具体内容吧。"}</SMARTFLOW_DECISION>
|
||||
你想记录什么呢?告诉我具体内容吧。`
|
||||
|
||||
|
||||
@@ -358,6 +358,18 @@ func (e *ChunkEmitter) EmitScheduleCompleted(blockID, stage string) error {
|
||||
return e.emitExtraOnly(NewScheduleCompletedExtra(blockID, stage))
|
||||
}
|
||||
|
||||
// EmitBusinessCard 输出一次业务结果卡片事件。
|
||||
//
|
||||
// 协议约束:
|
||||
// 1. 只走 extra,不附带 content/reasoning;
|
||||
// 2. card 为空时直接跳过,避免发出缺少关键字段的空卡片。
|
||||
func (e *ChunkEmitter) EmitBusinessCard(blockID, stage string, card *StreamBusinessCardExtra) error {
|
||||
if e == nil || e.emit == nil || card == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emitExtraOnly(NewBusinessCardExtra(blockID, stage, card))
|
||||
}
|
||||
|
||||
// EmitFinish 统一输出 stop 结束块,并带上 finish extra。
|
||||
func (e *ChunkEmitter) EmitFinish(blockID, stage string) error {
|
||||
if e == nil || e.emit == nil {
|
||||
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
StreamExtraKindToolResult StreamExtraKind = "tool_result"
|
||||
StreamExtraKindConfirm StreamExtraKind = "confirm_request"
|
||||
StreamExtraKindInterrupt StreamExtraKind = "interrupt"
|
||||
StreamExtraKindBusinessCard StreamExtraKind = "business_card"
|
||||
StreamExtraKindFinish StreamExtraKind = "finish"
|
||||
StreamExtraKindScheduleCompleted StreamExtraKind = "schedule_completed"
|
||||
)
|
||||
@@ -63,18 +64,19 @@ const (
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Kind / Stage / BlockID 提供前端排版和分组所需的最小元信息;
|
||||
// 2. Status / Tool / Confirm / Interrupt 只存展示层真正需要的摘要,不直接耦合后端完整状态对象;
|
||||
// 2. Status / Tool / Confirm / Interrupt / BusinessCard 只存展示层真正需要的摘要,不直接耦合后端完整状态对象;
|
||||
// 3. Meta 留给后续做灰度扩展,避免每加一种小字段都要立刻改 DTO 结构。
|
||||
type OpenAIChunkExtra struct {
|
||||
Kind StreamExtraKind `json:"kind,omitempty"`
|
||||
BlockID string `json:"block_id,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
DisplayMode StreamDisplayMode `json:"display_mode,omitempty"`
|
||||
Status *StreamStatusExtra `json:"status,omitempty"`
|
||||
Tool *StreamToolExtra `json:"tool,omitempty"`
|
||||
Confirm *StreamConfirmExtra `json:"confirm,omitempty"`
|
||||
Interrupt *StreamInterruptExtra `json:"interrupt,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
Kind StreamExtraKind `json:"kind,omitempty"`
|
||||
BlockID string `json:"block_id,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
DisplayMode StreamDisplayMode `json:"display_mode,omitempty"`
|
||||
Status *StreamStatusExtra `json:"status,omitempty"`
|
||||
Tool *StreamToolExtra `json:"tool,omitempty"`
|
||||
Confirm *StreamConfirmExtra `json:"confirm,omitempty"`
|
||||
Interrupt *StreamInterruptExtra `json:"interrupt,omitempty"`
|
||||
BusinessCard *StreamBusinessCardExtra `json:"business_card,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// StreamStatusExtra 表示普通阶段状态或提示性事件。
|
||||
@@ -105,6 +107,20 @@ type StreamInterruptExtra struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// StreamBusinessCardExtra 表示一张业务结果卡片。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. CardType 只允许前端已约定的卡片类型(task_query/task_record);
|
||||
// 2. Source 仅在 task_record 时有语义,其他卡片类型可为空;
|
||||
// 3. Data 承载“可直接渲染的最小快照”,避免前端再二次补拉才能看到结果。
|
||||
type StreamBusinessCardExtra struct {
|
||||
CardType string `json:"card_type,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。
|
||||
func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) {
|
||||
return ToOpenAIStreamWithExtra(chunk, requestID, modelName, created, includeRole, nil)
|
||||
@@ -263,6 +279,17 @@ func NewInterruptExtra(blockID, stage, interactionID, interactionType, summary s
|
||||
}
|
||||
}
|
||||
|
||||
// NewBusinessCardExtra 创建“业务结果卡片”事件的 extra。
|
||||
func NewBusinessCardExtra(blockID, stage string, businessCard *StreamBusinessCardExtra) *OpenAIChunkExtra {
|
||||
return &OpenAIChunkExtra{
|
||||
Kind: StreamExtraKindBusinessCard,
|
||||
BlockID: blockID,
|
||||
Stage: stage,
|
||||
DisplayMode: StreamDisplayModeCard,
|
||||
BusinessCard: businessCard,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScheduleCompletedExtra 创建”排程完毕”卡片事件的 extra。
|
||||
//
|
||||
// 职责边界:
|
||||
@@ -331,5 +358,6 @@ func hasStreamExtra(extra *OpenAIChunkExtra) bool {
|
||||
extra.Tool != nil ||
|
||||
extra.Confirm != nil ||
|
||||
extra.Interrupt != nil ||
|
||||
extra.BusinessCard != nil ||
|
||||
len(extra.Meta) > 0
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
|
||||
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.POST("/batch-status", handlers.TaskHandler.BatchTaskStatus)
|
||||
}
|
||||
courseGroup := apiGroup.Group("/course")
|
||||
{
|
||||
|
||||
@@ -295,6 +295,7 @@ func canonicalizeTimelineKind(kind string, role string) string {
|
||||
model.AgentTimelineKindToolCall,
|
||||
model.AgentTimelineKindToolResult,
|
||||
model.AgentTimelineKindConfirmRequest,
|
||||
model.AgentTimelineKindBusinessCard,
|
||||
model.AgentTimelineKindScheduleCompleted:
|
||||
return normalizedKind
|
||||
case "text", "message", "query":
|
||||
@@ -343,6 +344,8 @@ func mapTimelineKindFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) (str
|
||||
return model.AgentTimelineKindToolResult, true
|
||||
case newagentstream.StreamExtraKindConfirm:
|
||||
return model.AgentTimelineKindConfirmRequest, true
|
||||
case newagentstream.StreamExtraKindBusinessCard:
|
||||
return model.AgentTimelineKindBusinessCard, true
|
||||
case newagentstream.StreamExtraKindScheduleCompleted:
|
||||
return model.AgentTimelineKindScheduleCompleted, true
|
||||
default:
|
||||
@@ -381,8 +384,27 @@ func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra)
|
||||
"summary": strings.TrimSpace(extra.Interrupt.Summary),
|
||||
}
|
||||
}
|
||||
if extra.BusinessCard != nil {
|
||||
payload["business_card"] = cloneStreamBusinessCard(extra.BusinessCard)
|
||||
}
|
||||
if len(extra.Meta) > 0 {
|
||||
payload["meta"] = cloneTimelinePayload(extra.Meta)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func cloneStreamBusinessCard(card *newagentstream.StreamBusinessCardExtra) map[string]any {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := map[string]any{
|
||||
"card_type": strings.TrimSpace(card.CardType),
|
||||
"title": strings.TrimSpace(card.Title),
|
||||
"summary": strings.TrimSpace(card.Summary),
|
||||
"source": strings.TrimSpace(card.Source),
|
||||
}
|
||||
if len(card.Data) > 0 {
|
||||
cloned["data"] = cloneTimelinePayload(card.Data)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// taskBatchStatusMaxIDs 限制批量状态查询的单次任务 ID 数量,避免大请求放大缓存/内存扫描成本。
|
||||
taskBatchStatusMaxIDs = 100
|
||||
// taskUrgencyPromoteDedupeTTL 是"同一任务平移请求"的去重锁有效期。
|
||||
//
|
||||
// 设计考虑:
|
||||
@@ -175,6 +177,65 @@ func (ts *TaskService) GetUserTasks(ctx context.Context, userID int) ([]model.Ge
|
||||
return conv.ModelToGetUserTasksResp(derivedTasks), nil
|
||||
}
|
||||
|
||||
// BatchTaskStatus 批量查询当前登录用户任务的完成状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责请求 ID 的过滤、去重和数量限制;
|
||||
// 2. 只返回当前用户有权访问且仍存在的任务,避免泄露其他用户任务状态;
|
||||
// 3. 复用 getRawUserTasks 的 Redis 任务列表缓存链路,不新增绕过缓存的 DAO 查询;
|
||||
// 4. 该接口只读,不触发 GORM cache_deleter,也不反向修改 NewAgent timeline 历史快照。
|
||||
func (ts *TaskService) BatchTaskStatus(ctx context.Context, req *model.BatchTaskStatusRequest, userID int) (*model.BatchTaskStatusResponse, error) {
|
||||
resp := &model.BatchTaskStatusResponse{
|
||||
Items: []model.BatchTaskStatusItem{},
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, respond.WrongUserID
|
||||
}
|
||||
if req == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 1. 先把前端传入的历史卡片 task id 做归一化。
|
||||
// 1.1 非法 ID 直接过滤,避免无意义匹配;
|
||||
// 1.2 保留首次出现顺序,方便前端按请求顺序回填;
|
||||
// 1.3 超过上限时截断,避免单次 hydration 请求放大服务端成本。
|
||||
validIDs := compactPositiveUniqueTaskIDsWithLimit(req.IDs, taskBatchStatusMaxIDs)
|
||||
if len(validIDs) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 2. 复用原始任务读取链路。
|
||||
// 2.1 命中 Redis 时直接读取 smartflow:tasks:{userID};
|
||||
// 2.2 未命中时由 getRawUserTasks 回源 DB 并回填缓存;
|
||||
// 2.3 用户没有任何任务时映射为空 items,符合 hydration 的“无匹配不报错”语义。
|
||||
tasks, err := ts.getRawUserTasks(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return resp, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 在当前用户任务集合内做内存匹配。
|
||||
// 3.1 不命中的 ID 可能是已删除、属于其他用户、或历史快照里的旧任务,统一静默过滤;
|
||||
// 3.2 返回字段只包含当前模型可用的完成状态,避免伪造不存在的 updated_at。
|
||||
taskByID := make(map[int]model.Task, len(tasks))
|
||||
for _, task := range tasks {
|
||||
taskByID[task.ID] = task
|
||||
}
|
||||
for _, id := range validIDs {
|
||||
task, exists := taskByID[id]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
resp.Items = append(resp.Items, model.BatchTaskStatusItem{
|
||||
ID: task.ID,
|
||||
IsCompleted: task.IsCompleted,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetTasksWithUrgencyPromotion 读取用户任务并应用读时紧急性提升 + 异步落库触发。
|
||||
//
|
||||
// 统一入口,供前端查询(GetUserTasks)和 LLM 工具查询(QueryTasksForTool)复用。
|
||||
@@ -353,6 +414,16 @@ func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) {
|
||||
// 1. 只做参数清洗;
|
||||
// 2. 不承载业务规则判断。
|
||||
func compactPositiveUniqueTaskIDs(taskIDs []int) []int {
|
||||
return compactPositiveUniqueTaskIDsWithLimit(taskIDs, 0)
|
||||
}
|
||||
|
||||
// compactPositiveUniqueTaskIDsWithLimit 对任务 ID 做"过滤非正数 + 去重 + 可选限量"。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做纯参数归一化,不查询任务、不判断权限;
|
||||
// 2. limit <= 0 表示不限制数量,供既有调用保持原行为;
|
||||
// 3. 达到 limit 后立即停止扫描,避免超长请求继续消耗 CPU。
|
||||
func compactPositiveUniqueTaskIDsWithLimit(taskIDs []int, limit int) []int {
|
||||
seen := make(map[int]struct{}, len(taskIDs))
|
||||
result := make([]int, 0, len(taskIDs))
|
||||
for _, taskID := range taskIDs {
|
||||
@@ -364,6 +435,9 @@ func compactPositiveUniqueTaskIDs(taskIDs []int) []int {
|
||||
}
|
||||
seen[taskID] = struct{}{}
|
||||
result = append(result, taskID)
|
||||
if limit > 0 && len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -184,8 +184,27 @@ interface TaskQueryCardTaskItem {
|
||||
is_completed?: boolean
|
||||
}
|
||||
|
||||
type TaskQueryFilterOperator = 'eq' | 'contains' | 'gte' | 'lt'
|
||||
|
||||
interface TaskQueryCardFilter {
|
||||
key:
|
||||
| 'quadrant'
|
||||
| 'keyword'
|
||||
| 'deadline_after'
|
||||
| 'deadline_before'
|
||||
| 'include_completed'
|
||||
| 'sort'
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
operator?: TaskQueryFilterOperator
|
||||
display_text: string
|
||||
}
|
||||
|
||||
interface TaskQueryCardData {
|
||||
// 展示摘要:只适合整段展示,不作为前端切分协议。
|
||||
query_summary?: string
|
||||
// 稳定结构化筛选条件:前端若要渲染标签/chip,应优先消费此字段。
|
||||
query_filters?: TaskQueryCardFilter[]
|
||||
result_count: number
|
||||
shown_count: number
|
||||
has_more?: boolean
|
||||
@@ -212,12 +231,15 @@ interface TaskQueryCardData {
|
||||
有条件时建议补充:
|
||||
|
||||
- `query_summary`
|
||||
- `query_filters`
|
||||
- `priority_label`
|
||||
- `deadline_at`
|
||||
- `is_completed`
|
||||
- `shown_count`
|
||||
- `has_more`
|
||||
|
||||
其中 `query_summary` 是给人看的整段摘要,不保证分隔符可解析;前端若要拆成标签,应使用 `query_filters[].display_text` 或根据 `key/operator/value` 自行格式化,禁止按 `;` 切分 `query_summary`。
|
||||
|
||||
### 5.1.5 降级规则
|
||||
|
||||
1. 若只有 `result_count` 无任务列表:
|
||||
@@ -242,6 +264,29 @@ interface TaskQueryCardData {
|
||||
"summary": "按截止时间升序",
|
||||
"data": {
|
||||
"query_summary": "关键词:离散数学;仅未完成;截止时间升序",
|
||||
"query_filters": [
|
||||
{
|
||||
"key": "keyword",
|
||||
"label": "关键词",
|
||||
"value": "离散数学",
|
||||
"operator": "contains",
|
||||
"display_text": "关键词:离散数学"
|
||||
},
|
||||
{
|
||||
"key": "include_completed",
|
||||
"label": "完成状态",
|
||||
"value": false,
|
||||
"operator": "eq",
|
||||
"display_text": "仅未完成"
|
||||
},
|
||||
{
|
||||
"key": "sort",
|
||||
"label": "排序",
|
||||
"value": "deadline_asc",
|
||||
"operator": "eq",
|
||||
"display_text": "按截止时间升序"
|
||||
}
|
||||
],
|
||||
"result_count": 4,
|
||||
"shown_count": 3,
|
||||
"has_more": true,
|
||||
|
||||
@@ -25,8 +25,23 @@ export interface TaskQueryCardTaskItem {
|
||||
is_completed?: boolean
|
||||
}
|
||||
|
||||
export interface TaskQueryCardFilter {
|
||||
key:
|
||||
| 'quadrant'
|
||||
| 'keyword'
|
||||
| 'deadline_after'
|
||||
| 'deadline_before'
|
||||
| 'include_completed'
|
||||
| 'sort'
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
operator?: 'eq' | 'contains' | 'gte' | 'lt'
|
||||
display_text: string
|
||||
}
|
||||
|
||||
export interface TaskQueryCardData {
|
||||
query_summary?: string
|
||||
query_filters?: TaskQueryCardFilter[]
|
||||
result_count: number
|
||||
shown_count: number
|
||||
has_more?: boolean
|
||||
|
||||
@@ -77,6 +77,26 @@ export async function updateTask(payload: TaskUpdatePayload) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskBatchStatusItem {
|
||||
id: number
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
export interface TaskBatchStatusResult {
|
||||
items: TaskBatchStatusItem[]
|
||||
}
|
||||
|
||||
export async function getTaskBatchStatus(ids: number[]) {
|
||||
if (ids.length === 0) return []
|
||||
try {
|
||||
const response = await http.post<ApiResponse<TaskBatchStatusResult>>('/task/batch-status', { ids })
|
||||
return response.data.data?.items ?? []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch batch status:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: number) {
|
||||
try {
|
||||
const response = await http.delete<ApiResponse<{ task_id: number }>>(
|
||||
@@ -93,3 +113,4 @@ export async function deleteTask(taskId: number) {
|
||||
throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import type { TaskQueryCardData } from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -7,6 +8,53 @@ const props = defineProps<{
|
||||
summary?: string
|
||||
}>()
|
||||
|
||||
// 注入来自 AssistantPanel 的全局任务状态管理
|
||||
const taskStatusMap = inject<Record<number, {
|
||||
is_completed: boolean,
|
||||
syncing: boolean,
|
||||
is_deleted?: boolean,
|
||||
title?: string,
|
||||
priority_group?: number,
|
||||
deadline_at?: string | null
|
||||
}>>('taskStatusMap')
|
||||
const toggleTaskStatus = inject<(id: number) => Promise<void>>('toggleTaskStatus')
|
||||
const onEditTask = inject<(task: any) => void>('onEditTask')
|
||||
const onDeleteTask = inject<(id: number) => void>('onDeleteTask')
|
||||
|
||||
const isCompleted = (id: number, fallback: boolean = false) => {
|
||||
return taskStatusMap?.[id]?.is_completed ?? fallback
|
||||
}
|
||||
|
||||
const isSyncing = (id: number) => {
|
||||
return taskStatusMap?.[id]?.syncing ?? false
|
||||
}
|
||||
|
||||
const isDeleted = (id: number) => {
|
||||
return taskStatusMap?.[id]?.is_deleted ?? false
|
||||
}
|
||||
|
||||
// 实时获取覆盖后的属性
|
||||
const getDisplayTitle = (task: any) => {
|
||||
if (task?.id && taskStatusMap?.[task.id]?.title) {
|
||||
return taskStatusMap[task.id].title
|
||||
}
|
||||
return task?.title || ''
|
||||
}
|
||||
|
||||
const getDisplayPriority = (task: any) => {
|
||||
if (task?.id && taskStatusMap?.[task.id]?.priority_group) {
|
||||
return taskStatusMap[task.id].priority_group
|
||||
}
|
||||
return task?.priority_group || 2
|
||||
}
|
||||
|
||||
const getDisplayDeadline = (task: any) => {
|
||||
if (task?.id && taskStatusMap?.[task.id]?.deadline_at !== undefined) {
|
||||
return taskStatusMap[task.id].deadline_at
|
||||
}
|
||||
return task?.deadline_at
|
||||
}
|
||||
|
||||
// 对齐首页象限体系
|
||||
const quadMeta: any = {
|
||||
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
|
||||
@@ -31,11 +79,28 @@ const getTextColor = (group: number = 2) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-card query-results" :style="{ background: getBgStyle(props.data.tasks[0]?.priority_group) }">
|
||||
<div class="business-card query-results" :style="{ background: getBgStyle(props.data.tasks?.[0] ? getDisplayPriority(props.data.tasks[0]) : 2) }">
|
||||
<header class="card-header">
|
||||
<div class="header-left">
|
||||
<p class="eyebrow">{{ summary || '查询结果' }}</p>
|
||||
<h3>{{ title || '为您找到以下任务' }}</h3>
|
||||
<p class="eyebrow">查询结果</p>
|
||||
<h3 class="card-title">{{ title || '为您找到以下任务' }}</h3>
|
||||
|
||||
<div class="filter-tags" v-if="data.query_filters && data.query_filters.length > 0">
|
||||
<span v-for="f in data.query_filters" :key="f.key" class="filter-tag">
|
||||
<template v-if="f.key === 'deadline_after' || f.key === 'deadline_before'">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</template>
|
||||
<template v-else-if="f.key === 'sort'">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="tag-icon"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</template>
|
||||
{{ f.display_text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="summary" class="query-summary-fallback">{{ summary }}</p>
|
||||
</div>
|
||||
<div class="count-badge" v-if="data.result_count > 0">
|
||||
{{ data.result_count }} 项
|
||||
@@ -44,26 +109,66 @@ const getTextColor = (group: number = 2) => {
|
||||
|
||||
<div class="card-content">
|
||||
<div v-if="data.tasks && data.tasks.length > 0" class="task-items">
|
||||
<div v-for="task in data.tasks" :key="task.id" class="task-item">
|
||||
<div class="item-check">
|
||||
<div class="check-circle" :style="{ borderColor: getTextColor(task.priority_group) }"></div>
|
||||
<div
|
||||
v-for="task in data.tasks"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{
|
||||
'is-item-completed': isCompleted(task.id, task.is_completed),
|
||||
'is-syncing': isSyncing(task.id),
|
||||
'is-deleted': isDeleted(task.id)
|
||||
}"
|
||||
@click="!isDeleted(task.id) && onEditTask?.({ ...task, title: getDisplayTitle(task), priority_group: getDisplayPriority(task), deadline_at: getDisplayDeadline(task) })"
|
||||
>
|
||||
<div class="item-check" v-if="!isDeleted(task.id)" @click.stop="toggleTaskStatus?.(task.id)">
|
||||
<div
|
||||
class="check-circle"
|
||||
:class="{
|
||||
'is-checked': isCompleted(task.id, task.is_completed),
|
||||
'is-syncing': isSyncing(task.id)
|
||||
}"
|
||||
:style="{
|
||||
borderColor: getTextColor(getDisplayPriority(task)),
|
||||
backgroundColor: isCompleted(task.id, task.is_completed) ? getTextColor(getDisplayPriority(task)) : 'transparent'
|
||||
}"
|
||||
>
|
||||
<svg v-if="isCompleted(task.id, task.is_completed) && !isSyncing(task.id)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<div v-if="isSyncing(task.id)" class="sync-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<div class="item-title">{{ task.title }}</div>
|
||||
<div class="item-meta">
|
||||
<div class="item-title">{{ isDeleted(task.id) ? '(任务已删除)' : getDisplayTitle(task) }}</div>
|
||||
<div class="item-meta" v-if="!isDeleted(task.id)">
|
||||
<span
|
||||
class="q-pill"
|
||||
v-if="task.priority_group"
|
||||
:style="{ color: getTextColor(task.priority_group), background: getTextColor(task.priority_group) + '10' }"
|
||||
v-if="getDisplayPriority(task)"
|
||||
:style="{ color: getTextColor(getDisplayPriority(task)), background: getTextColor(getDisplayPriority(task)) + '10' }"
|
||||
>
|
||||
Q{{ task.priority_group }} {{ quadMeta[task.priority_group]?.title }}
|
||||
Q{{ getDisplayPriority(task) }} {{ quadMeta[getDisplayPriority(task)]?.title }}
|
||||
</span>
|
||||
<span v-if="task.deadline_at" class="time-pill">
|
||||
<span v-if="getDisplayDeadline(task)" class="time-pill">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{{ task.deadline_at }}
|
||||
{{ getDisplayDeadline(task) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-actions" v-if="!isDeleted(task.id)">
|
||||
<button
|
||||
class="item-action-btn edit-btn"
|
||||
@click.stop="onEditTask?.({ ...task, title: getDisplayTitle(task), priority_group: getDisplayPriority(task), deadline_at: getDisplayDeadline(task) })"
|
||||
title="编辑任务"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
</button>
|
||||
<button
|
||||
class="item-action-btn delete-btn"
|
||||
@click.stop="onDeleteTask?.(task.id)"
|
||||
title="删除任务"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" 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>
|
||||
</div>
|
||||
|
||||
@@ -82,78 +187,239 @@ const getTextColor = (group: number = 2) => {
|
||||
<style scoped>
|
||||
.business-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.02);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
background: white;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: #ffffff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.business-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24px 24px 16px;
|
||||
padding: 20px 24px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: rgba(30, 41, 59, 0.5);
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0 0 6px 0;
|
||||
margin: 0 0 4px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 20px;
|
||||
.card-header h3.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
margin: 0 0 10px 0;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 100px;
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
border: 1px solid rgba(15, 23, 42, 0.03);
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.query-summary-fallback {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin: 4px 0 0;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
padding: 4px 10px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.task-items {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: rgba(15, 23, 42, 0.15);
|
||||
background: #f8fafc;
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-item:hover .task-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-action-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-action-btn.edit-btn:hover {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.item-action-btn.delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
color: #f43f5e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.task-item.is-deleted {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.8);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-item.is-syncing {
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
background: #f8fafc;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-item.is-syncing::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.6),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.item-check {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.check-circle.is-syncing {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sync-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.is-item-completed .item-title {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.item-body {
|
||||
@@ -164,8 +430,8 @@ const getTextColor = (group: number = 2) => {
|
||||
.item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #122033;
|
||||
margin-bottom: 4px;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -173,57 +439,63 @@ const getTextColor = (group: number = 2) => {
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.q-pill {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-pill {
|
||||
font-size: 9px;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-more {
|
||||
width: calc(100% - 32px);
|
||||
margin: 16px 16px 20px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
font-weight: 750;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-more:hover {
|
||||
background: white;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px 16px;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import type { TaskRecordCardData, TaskRecordSource } from '@/api/schedule_agent'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -8,6 +9,62 @@ const props = defineProps<{
|
||||
summary?: string
|
||||
}>()
|
||||
|
||||
// 注入来自 AssistantPanel 的全局任务状态管理
|
||||
const taskStatusMap = inject<Record<number, {
|
||||
is_completed: boolean,
|
||||
syncing: boolean,
|
||||
is_deleted?: boolean,
|
||||
title?: string,
|
||||
priority_group?: number,
|
||||
deadline_at?: string | null
|
||||
}>>('taskStatusMap')
|
||||
const toggleTaskStatus = inject<(id: number) => Promise<void>>('toggleTaskStatus')
|
||||
const onEditTask = inject<(task: any) => void>('onEditTask')
|
||||
const onDeleteTask = inject<(id: number) => void>('onDeleteTask')
|
||||
|
||||
const isCompleted = (id: any, fallback: boolean = false) => {
|
||||
if (!id) return fallback
|
||||
const nid = Number(id)
|
||||
return taskStatusMap?.[nid]?.is_completed ?? fallback
|
||||
}
|
||||
|
||||
const isSyncing = (id: any) => {
|
||||
if (!id) return false
|
||||
const nid = Number(id)
|
||||
return taskStatusMap?.[nid]?.syncing ?? false
|
||||
}
|
||||
|
||||
const isDeleted = (id: any) => {
|
||||
if (!id) return false
|
||||
const nid = Number(id)
|
||||
return taskStatusMap?.[nid]?.is_deleted ?? false
|
||||
}
|
||||
|
||||
// 实时获取覆盖后的属性
|
||||
const displayTitle = computed(() => {
|
||||
const id = props.data.id ? Number(props.data.id) : null
|
||||
if (id && taskStatusMap?.[id]?.title) {
|
||||
return taskStatusMap[id].title
|
||||
}
|
||||
return props.data.title
|
||||
})
|
||||
|
||||
const displayPriority = computed(() => {
|
||||
const id = props.data.id ? Number(props.data.id) : null
|
||||
if (id && taskStatusMap?.[id]?.priority_group) {
|
||||
return taskStatusMap[id].priority_group
|
||||
}
|
||||
return props.data.priority_group || 2
|
||||
})
|
||||
|
||||
const displayDeadline = computed(() => {
|
||||
const id = props.data.id ? Number(props.data.id) : null
|
||||
if (id && taskStatusMap?.[id]?.deadline_at !== undefined) {
|
||||
return taskStatusMap[id].deadline_at
|
||||
}
|
||||
return props.data.deadline_at
|
||||
})
|
||||
|
||||
// 对齐首页象限体系
|
||||
const quadMeta: any = {
|
||||
1: { title: '重要且紧急', tone: 'danger', color: '#ef4444' },
|
||||
@@ -32,32 +89,98 @@ const getTextColor = (group: number = 2) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-card creation-receipt" :style="{ background: getBgStyle(props.data.priority_group) }">
|
||||
<div
|
||||
class="business-card creation-receipt"
|
||||
:class="{ 'is-card-deleted': data.id && isDeleted(data.id) }"
|
||||
:style="{ background: getBgStyle(displayPriority) }"
|
||||
>
|
||||
<div class="receipt-inner">
|
||||
<div class="receipt-header">
|
||||
<div class="success-ring" :style="{ background: getTextColor(props.data.priority_group) + '20', color: getTextColor(props.data.priority_group) }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<div
|
||||
class="success-ring"
|
||||
:class="{
|
||||
'is-completed': isCompleted(data.id),
|
||||
'is-clickable': data.id && !isDeleted(data.id),
|
||||
'is-syncing': data.id && isSyncing(data.id)
|
||||
}"
|
||||
:style="{
|
||||
background: isDeleted(data.id) ? 'rgba(100, 116, 139, 0.1)' : (isCompleted(data.id) ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.125)'),
|
||||
color: isDeleted(data.id) ? '#64748b' : (isCompleted(data.id) ? '#22c55e' : '#ef4448')
|
||||
}"
|
||||
@click.stop="data.id && !isDeleted(data.id) && toggleTaskStatus?.(data.id)"
|
||||
>
|
||||
<svg v-if="isDeleted(data.id)" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<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>
|
||||
<svg v-else-if="isSyncing(data.id)" class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round">
|
||||
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"></path>
|
||||
</svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="success-msg">
|
||||
<strong>{{ title || (source === 'quick_note' ? '已帮您记下' : '任务已创建') }}</strong>
|
||||
<span v-if="data.priority_group">归类至:{{ quadMeta[data.priority_group].title }}</span>
|
||||
<strong v-if="isDeleted(data.id)">任务已删除</strong>
|
||||
<strong v-else-if="isCompleted(data.id)">任务已完成</strong>
|
||||
<strong v-else>已帮你记下</strong>
|
||||
<span v-if="!isDeleted(data.id)">归类至:{{ quadMeta[displayPriority]?.title || '重要不紧急' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-info-card">
|
||||
<div class="task-title">{{ data.title }}</div>
|
||||
<div
|
||||
class="task-info-card"
|
||||
:class="{
|
||||
'is-item-completed': data.id && isCompleted(data.id),
|
||||
'is-syncing': data.id && isSyncing(data.id),
|
||||
'is-deleted': data.id && isDeleted(data.id)
|
||||
}"
|
||||
@click="!isDeleted(data.id) && onEditTask?.({ ...data, title: displayTitle, priority_group: displayPriority, deadline_at: displayDeadline })"
|
||||
>
|
||||
<div class="task-main-row">
|
||||
<div class="item-check" v-if="data.id && !isDeleted(data.id)" @click.stop="toggleTaskStatus?.(data.id)">
|
||||
<div
|
||||
class="check-circle"
|
||||
:class="{
|
||||
'is-checked': isCompleted(data.id),
|
||||
'is-syncing': isSyncing(data.id)
|
||||
}"
|
||||
:style="{
|
||||
borderColor: getTextColor(displayPriority),
|
||||
backgroundColor: isCompleted(data.id) ? getTextColor(displayPriority) : 'transparent'
|
||||
}"
|
||||
>
|
||||
<svg v-if="isCompleted(data.id) && !isSyncing(data.id)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<div v-if="isSyncing(data.id)" class="sync-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-title">{{ displayTitle }}</div>
|
||||
|
||||
<div class="task-actions" v-if="data.id && !isDeleted(data.id)">
|
||||
<!-- 编辑按钮 -->
|
||||
<button
|
||||
class="item-action-btn edit-btn"
|
||||
@click.stop="onEditTask?.({ ...data, title: displayTitle, priority_group: displayPriority, deadline_at: displayDeadline })"
|
||||
title="编辑任务"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
</button>
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
class="item-action-btn delete-btn"
|
||||
@click.stop="onDeleteTask?.(data.id)"
|
||||
title="删除任务"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" 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>
|
||||
<div class="task-footer">
|
||||
<span class="task-id" v-if="data.id">ID: {{ data.id }}</span>
|
||||
<span class="task-time" v-if="data.created_at || data.deadline_at">
|
||||
{{ data.deadline_at ? '截止:' + data.deadline_at : '刚刚创建' }}
|
||||
<span class="task-time" v-if="data.created_at || displayDeadline">
|
||||
{{ displayDeadline ? '截止:' + displayDeadline : '刚刚创建' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="receipt-actions">
|
||||
<button class="btn-outline">修改详情</button>
|
||||
<button class="btn-fill" :style="{ background: getTextColor(props.data.priority_group) }">打开查看</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,12 +188,18 @@ const getTextColor = (group: number = 2) => {
|
||||
<style scoped>
|
||||
.business-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.02);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.02);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.business-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08);
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.receipt-inner {
|
||||
@@ -82,76 +211,248 @@ const getTextColor = (group: number = 2) => {
|
||||
|
||||
.receipt-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.success-ring.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.success-ring.is-clickable:hover {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.success-ring.is-completed {
|
||||
background: rgba(34, 197, 94, 0.15) !important;
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.success-msg strong {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.success-msg span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-info-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(0,0,0,0.03);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.01);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-info-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-info-card:hover .task-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-action-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-action-btn.edit-btn:hover {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.item-action-btn.delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
color: #f43f5e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.task-info-card.is-deleted {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-card-deleted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.task-info-card.is-syncing {
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-info-card.is-syncing::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.6),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.task-main-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.item-check {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.check-circle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-circle.is-syncing {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sync-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.is-item-completed .task-title {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.task-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.receipt-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
height: 42px;
|
||||
height: 44px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@@ -159,22 +460,25 @@ const getTextColor = (group: number = 2) => {
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-fill {
|
||||
height: 42px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-fill:hover {
|
||||
filter: brightness(1.1);
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import ContextWindowMeter from '@/components/assistant/ContextWindowMeter.vue'
|
||||
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
|
||||
@@ -9,6 +10,13 @@ import {
|
||||
getConversationList,
|
||||
getConversationMeta,
|
||||
} from '@/api/agent'
|
||||
import {
|
||||
completeTask,
|
||||
undoCompleteTask,
|
||||
getTaskBatchStatus,
|
||||
updateTask,
|
||||
deleteTask
|
||||
} from '@/api/task'
|
||||
import {
|
||||
getSchedulePreview,
|
||||
getConversationTimeline,
|
||||
@@ -33,7 +41,11 @@ import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.
|
||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
|
||||
import type { TimelineBusinessCardPayload } from '@/api/schedule_agent'
|
||||
import type {
|
||||
TimelineBusinessCardPayload,
|
||||
TaskQueryCardData,
|
||||
TaskRecordCardData
|
||||
} from '@/api/schedule_agent'
|
||||
|
||||
interface StreamDeltaPayload {
|
||||
content?: string
|
||||
@@ -74,6 +86,7 @@ interface StreamExtraPayload {
|
||||
status?: StreamStatusExtraPayload
|
||||
tool?: StreamToolExtraPayload
|
||||
confirm?: StreamConfirmPayload
|
||||
business_card?: TimelineBusinessCardPayload
|
||||
}
|
||||
|
||||
interface StreamEventPayload {
|
||||
@@ -180,6 +193,8 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const assistantBodyRef = ref<HTMLElement | null>(null)
|
||||
const messageViewportRef = ref<HTMLElement | null>(null)
|
||||
@@ -189,7 +204,8 @@ const conversationLoading = ref(true)
|
||||
const conversationLoadingMore = ref(false)
|
||||
const chatLoading = ref(false)
|
||||
const historyExpanded = ref(true)
|
||||
const selectedConversationId = ref('')
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
const selectedConversationId = ref(isStandaloneMode.value && route.params.id ? (route.params.id as string) : '')
|
||||
|
||||
const selectedThinkingMode = ref<ThinkingModeType>('auto')
|
||||
const selectedExecutionMode = ref<'manual' | 'always'>('manual')
|
||||
@@ -210,6 +226,18 @@ const confirmOverlayState = reactive<ConfirmOverlayState>({
|
||||
summary: '',
|
||||
})
|
||||
|
||||
// 任务编辑相关状态(同首页看板)
|
||||
const taskDialogVisible = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const editingTaskId = ref<number | null>(null)
|
||||
const saveTaskLoading = ref(false)
|
||||
const taskForm = reactive({
|
||||
title: '',
|
||||
priority_group: 2,
|
||||
deadline_at: null as Date | null,
|
||||
urgency_threshold_at: null as Date | null,
|
||||
})
|
||||
|
||||
const conversationPage = ref(1)
|
||||
const conversationPageSize = 12
|
||||
const conversationHasMore = ref(false)
|
||||
@@ -242,6 +270,169 @@ const isFineTuneModalVisible = ref(false)
|
||||
const fineTuneLoading = ref(false)
|
||||
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
|
||||
|
||||
// 任务状态叠加层,用于实时同步和交互
|
||||
interface TaskStatusState {
|
||||
is_completed: boolean
|
||||
syncing: boolean
|
||||
is_deleted?: boolean
|
||||
title?: string
|
||||
priority_group?: number
|
||||
deadline_at?: string | null
|
||||
}
|
||||
const taskStatusMap = reactive<Record<number, TaskStatusState>>({})
|
||||
|
||||
/**
|
||||
* 切换任务完成状态
|
||||
* 1. 检查当前是否正在同步,避免重复点击
|
||||
* 2. 乐观更新 UI 或标记 syncing
|
||||
* 3. 调用后端接口反转状态
|
||||
* 4. 失败时回滚并报错
|
||||
*/
|
||||
async function toggleTaskStatus(taskId: number) {
|
||||
const current = taskStatusMap[taskId]
|
||||
if (current?.syncing || current?.is_deleted) return
|
||||
|
||||
const wasCompleted = current?.is_completed ?? false
|
||||
|
||||
// 标记同步中
|
||||
if (!taskStatusMap[taskId]) {
|
||||
taskStatusMap[taskId] = { is_completed: wasCompleted, syncing: true }
|
||||
} else {
|
||||
taskStatusMap[taskId].syncing = true
|
||||
}
|
||||
|
||||
try {
|
||||
if (wasCompleted) {
|
||||
await undoCompleteTask(taskId)
|
||||
} else {
|
||||
await completeTask(taskId)
|
||||
}
|
||||
taskStatusMap[taskId].is_completed = !wasCompleted
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
taskStatusMap[taskId].syncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskDelete(taskId: number) {
|
||||
try {
|
||||
// 1. 弹出确认框,避免误删。
|
||||
await ElMessageBox.confirm('确定要删除此任务吗?删除后不可恢复。', '确认删除', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
roundButton: true
|
||||
})
|
||||
|
||||
// 2. 标记同步中
|
||||
if (!taskStatusMap[taskId]) {
|
||||
taskStatusMap[taskId] = { is_completed: false, syncing: true }
|
||||
} else {
|
||||
taskStatusMap[taskId].syncing = true
|
||||
}
|
||||
|
||||
// 3. 调用后端接口删除
|
||||
await deleteTask(taskId)
|
||||
|
||||
// 4. 标记为已删除并停止同步
|
||||
taskStatusMap[taskId].is_deleted = true
|
||||
taskStatusMap[taskId].syncing = false
|
||||
ElMessage.success('已成功删除任务')
|
||||
} catch (error: any) {
|
||||
if (error === 'cancel') return
|
||||
// 失败时恢复状态
|
||||
if (taskStatusMap[taskId]) {
|
||||
taskStatusMap[taskId].syncing = false
|
||||
}
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量刷新当前时间线中所有卡片任务的真实状态
|
||||
* 1. 扫描 businessCardEventsMap 中所有已发现的 task id
|
||||
* 2. 调用 batch-status 接口回填
|
||||
*/
|
||||
async function hydrateTaskStatuses(conversationId: string) {
|
||||
// 确保在 Vue 状态更新后执行
|
||||
await nextTick()
|
||||
|
||||
const messages = conversationMessagesMap[conversationId]
|
||||
if (!messages || messages.length === 0) return
|
||||
|
||||
const ids = new Set<number>()
|
||||
messages.forEach(msg => {
|
||||
// 同时也扫描消息本身可能附带的 extra(用于 SSE 在线消息)
|
||||
if (msg.extra?.business_card) {
|
||||
const card = msg.extra.business_card
|
||||
if (card.card_type === 'task_query') {
|
||||
const data = card.data as any
|
||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const data = card.data as any
|
||||
if (data.id) ids.add(data.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描历史恢复的卡片事件
|
||||
const cardList = businessCardEventsMap[msg.id]
|
||||
if (cardList) {
|
||||
cardList.forEach(card => {
|
||||
if (card.card_type === 'task_query') {
|
||||
const data = card.data as any
|
||||
data.tasks?.forEach((t: any) => { if (t.id) ids.add(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const data = card.data as any
|
||||
if (data.id) ids.add(data.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (ids.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const idList = Array.from(ids)
|
||||
// 初始化 syncing
|
||||
idList.forEach(id => {
|
||||
if (!taskStatusMap[id]) {
|
||||
taskStatusMap[id] = { is_completed: false, syncing: true }
|
||||
} else {
|
||||
taskStatusMap[id].syncing = true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const items = await getTaskBatchStatus(idList)
|
||||
items.forEach(item => {
|
||||
const id = Number(item.id)
|
||||
if (taskStatusMap[id]) {
|
||||
// 合并更新,避免丢失已有属性
|
||||
taskStatusMap[id].is_completed = item.is_completed
|
||||
taskStatusMap[id].syncing = false
|
||||
} else {
|
||||
taskStatusMap[id] = { is_completed: item.is_completed, syncing: false }
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Hydration] Batch status fetch failed:', err)
|
||||
} finally {
|
||||
// 兜底:确保所有 ID 退出 syncing 状态
|
||||
idList.forEach(id => {
|
||||
if (taskStatusMap[id]?.syncing) {
|
||||
taskStatusMap[id].syncing = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
provide('taskStatusMap', taskStatusMap)
|
||||
provide('toggleTaskStatus', toggleTaskStatus)
|
||||
provide('onEditTask', handleTaskEdit)
|
||||
provide('onDeleteTask', handleTaskDelete)
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
'把当前任务拆成可执行步骤',
|
||||
@@ -263,7 +454,7 @@ const shouldAutoFollowMessages = ref(true)
|
||||
const messageBottomTolerancePx = 24
|
||||
const isProgrammaticMessageScroll = ref(false)
|
||||
|
||||
const isStandaloneMode = computed(() => props.viewMode === 'standalone')
|
||||
|
||||
const shouldShowDialogConfirmOverlay = computed(() => confirmOverlayState.visible)
|
||||
|
||||
const assistantBodyStyle = computed(() => {
|
||||
@@ -877,6 +1068,7 @@ function isAssistantTimelineKind(kind: string) {
|
||||
'schedule_completed',
|
||||
'interrupt',
|
||||
'status',
|
||||
'business_card',
|
||||
])
|
||||
return assistantKinds.has(kind)
|
||||
}
|
||||
@@ -990,6 +1182,9 @@ function migrateConversationState(fromConversationId: string, toConversationId:
|
||||
|
||||
if (selectedConversationId.value === fromConversationId) {
|
||||
selectedConversationId.value = toConversationId
|
||||
if (isStandaloneMode.value) {
|
||||
router.replace({ name: 'assistant', params: { id: toConversationId } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,9 +1732,16 @@ function scheduleScrollMessagesToBottom(smooth = false, force = false) {
|
||||
}
|
||||
|
||||
async function ensureSelectedConversationAfterListLoad() {
|
||||
// 1. 根据用户最新要求:进入页面时不自动加载最后一次对话。
|
||||
// 2. 默认保持 selectedConversationId 为空,从而触发居中的“新会话”看板及动画过渡逻辑。
|
||||
// 3. 用户若需查看历史,可从左侧列表中手动点击。
|
||||
// 1. 如果 URL 中显式指定了 ID (Standalone 模式),优先根据 URL 恢复状态
|
||||
if (isStandaloneMode.value && route.params.id) {
|
||||
const urlId = route.params.id as string
|
||||
if (urlId !== selectedConversationId.value) {
|
||||
await selectConversation(urlId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 否则遵循用户最新要求:进入页面时不自动加载最后一次对话,保持新会话状态。
|
||||
/*
|
||||
if (!selectedConversationId.value && conversationList.value.length > 0) {
|
||||
await selectConversation(conversationList.value[0].conversation_id)
|
||||
@@ -1842,12 +2044,6 @@ function rebuildStateFromTimeline(conversationId: string, events: TimelineEvent[
|
||||
// 在刷新恢复场景下,我们只需设置状态即可。
|
||||
}
|
||||
break
|
||||
case 'business_card':
|
||||
if (event.payload?.business_card) {
|
||||
appendBusinessCardEvent(mid, event.payload.business_card)
|
||||
}
|
||||
break
|
||||
|
||||
case 'business_card':
|
||||
if (event.payload?.business_card) {
|
||||
appendBusinessCardEvent(mid, event.payload.business_card, event.seq)
|
||||
@@ -1871,6 +2067,7 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
}
|
||||
|
||||
if (!forceReload && conversationMessagesMap[conversationId] && unavailableHistoryMap[conversationId] !== true) {
|
||||
void hydrateTaskStatuses(conversationId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1878,6 +2075,8 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
const events = await getConversationTimeline(conversationId)
|
||||
conversationMessagesMap[conversationId] = rebuildStateFromTimeline(conversationId, events)
|
||||
unavailableHistoryMap[conversationId] = false
|
||||
// 时间线恢复后,立即启动任务状态同步(Hydration)
|
||||
void hydrateTaskStatuses(conversationId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load timeline:', error)
|
||||
unavailableHistoryMap[conversationId] = true
|
||||
@@ -1885,6 +2084,16 @@ async function loadConversationMessages(conversationId: string, forceReload = fa
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前会话消息变化,实时触发状态同步
|
||||
watch(
|
||||
() => selectedConversationId.value ? conversationMessagesMap[selectedConversationId.value]?.length : 0,
|
||||
(newLen) => {
|
||||
if (newLen > 0 && selectedConversationId.value) {
|
||||
void hydrateTaskStatuses(selectedConversationId.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function ensureConversationMeta(
|
||||
conversationId: string,
|
||||
options: EnsureConversationMetaOptions = {},
|
||||
@@ -1981,6 +2190,12 @@ async function selectConversation(conversationId: string) {
|
||||
cancelEditUserMessage()
|
||||
resetConfirmOverlay()
|
||||
selectedConversationId.value = conversationId
|
||||
|
||||
// 仅在 Standalone 模式下将状态同步到 URL,实现可刷新/可分享
|
||||
if (isStandaloneMode.value && route.params.id !== conversationId) {
|
||||
router.push({ name: 'assistant', params: { id: conversationId || undefined } })
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(conversationId),
|
||||
ensureConversationMeta(conversationId),
|
||||
@@ -1993,6 +2208,12 @@ function startNewConversation() {
|
||||
cancelEditUserMessage()
|
||||
resetConfirmOverlay()
|
||||
selectedConversationId.value = ''
|
||||
|
||||
// 清除 URL 中的 ID
|
||||
if (isStandaloneMode.value && route.params.id) {
|
||||
router.push({ name: 'assistant', params: { id: undefined } })
|
||||
}
|
||||
|
||||
messageInput.value = ''
|
||||
activeStreamingMessageId.value = ''
|
||||
shouldAutoFollowMessages.value = true
|
||||
@@ -2113,6 +2334,57 @@ function handleConfirmRejectInputEnter(event: KeyboardEvent) {
|
||||
void submitConfirmRejectMessage()
|
||||
}
|
||||
|
||||
// 任务管理逻辑(对齐首页)
|
||||
function handleTaskEdit(task: any) {
|
||||
isEditMode.value = true
|
||||
editingTaskId.value = task.id
|
||||
taskForm.title = task.title
|
||||
taskForm.priority_group = task.priority_group || 2
|
||||
taskForm.deadline_at = task.deadline_at || task.deadline ? new Date(task.deadline_at || task.deadline) : null
|
||||
taskForm.urgency_threshold_at = task.urgency_threshold_at ? new Date(task.urgency_threshold_at) : null
|
||||
taskDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSaveTask() {
|
||||
if (!taskForm.title.trim()) {
|
||||
ElMessage.warning('标题不能为空')
|
||||
return
|
||||
}
|
||||
saveTaskLoading.value = true
|
||||
try {
|
||||
if (isEditMode.value && editingTaskId.value) {
|
||||
const taskId = editingTaskId.value
|
||||
const updateData = {
|
||||
task_id: taskId,
|
||||
title: taskForm.title.trim(),
|
||||
priority_group: taskForm.priority_group,
|
||||
deadline_at: taskForm.deadline_at ? (typeof taskForm.deadline_at === 'string' ? taskForm.deadline_at : taskForm.deadline_at.toISOString()) : null,
|
||||
urgency_threshold_at: taskForm.urgency_threshold_at ? (typeof taskForm.urgency_threshold_at === 'string' ? taskForm.urgency_threshold_at : taskForm.urgency_threshold_at.toISOString()) : null,
|
||||
}
|
||||
await updateTask(updateData)
|
||||
|
||||
// 同步更新本地状态映射,让所有历史卡片实时联动
|
||||
if (taskStatusMap[taskId]) {
|
||||
taskStatusMap[taskId].title = updateData.title
|
||||
taskStatusMap[taskId].priority_group = updateData.priority_group
|
||||
// 格式化截止时间用于展示
|
||||
taskStatusMap[taskId].deadline_at = taskForm.deadline_at
|
||||
? (taskForm.deadline_at instanceof Date
|
||||
? taskForm.deadline_at.toLocaleDateString('zh-CN').replace(/\//g, '-')
|
||||
: String(taskForm.deadline_at).split('T')[0])
|
||||
: null
|
||||
}
|
||||
|
||||
ElMessage.success('任务详情已更新')
|
||||
}
|
||||
taskDialogVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
saveTaskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
||||
if (!confirmPayload) {
|
||||
return
|
||||
@@ -2298,6 +2570,33 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
|
||||
if (extra.kind === 'business_card' && extra.business_card) {
|
||||
appendBusinessCardEvent(assistantMessage.id, extra.business_card)
|
||||
scheduleScrollMessagesToBottom(true)
|
||||
|
||||
// SSE 在线接收到新卡片时,也尝试同步一次其状态(主要针对立即生成的任务)
|
||||
const card = extra.business_card
|
||||
const ids: number[] = []
|
||||
if (card.card_type === 'task_query') {
|
||||
(card.data as TaskQueryCardData).tasks?.forEach(t => { if (t.id) ids.push(t.id) })
|
||||
} else if (card.card_type === 'task_record') {
|
||||
const id = (card.data as TaskRecordCardData).id
|
||||
if (id) ids.push(id)
|
||||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
ids.forEach(id => {
|
||||
if (!taskStatusMap[id]) {
|
||||
taskStatusMap[id] = { is_completed: false, syncing: true }
|
||||
}
|
||||
})
|
||||
void getTaskBatchStatus(ids).then(items => {
|
||||
items.forEach(item => {
|
||||
taskStatusMap[item.id] = { is_completed: item.is_completed, syncing: false }
|
||||
})
|
||||
}).finally(() => {
|
||||
ids.forEach(id => {
|
||||
if (taskStatusMap[id]?.syncing) taskStatusMap[id].syncing = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2619,6 +2918,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听路由参数 id 的变化,实现前进/后退同步切换对话
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(newId) => {
|
||||
if (isStandaloneMode.value) {
|
||||
const targetId = (newId as string) || ''
|
||||
if (targetId !== selectedConversationId.value) {
|
||||
selectConversation(targetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
reasoningTicker = window.setInterval(() => {
|
||||
@@ -2626,6 +2938,12 @@ onMounted(async () => {
|
||||
}, 1000)
|
||||
window.addEventListener('resize', syncHistoryPanelWidthForViewport)
|
||||
syncHistoryPanelWidthForViewport()
|
||||
|
||||
// 如果 URL 中有 ID,则立即启动加载,不等会话列表返回,避免闪烁主页态
|
||||
if (isStandaloneMode.value && route.params.id) {
|
||||
void selectConversation(route.params.id as string)
|
||||
}
|
||||
|
||||
await loadConversationListData(true)
|
||||
syncHistoryPanelWidthForViewport()
|
||||
})
|
||||
@@ -2754,7 +3072,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section
|
||||
class="assistant-chat dashboard-item-pop"
|
||||
:class="{ 'assistant-chat--empty': !selectedMessages.length && !chatLoading }"
|
||||
:class="{ 'assistant-chat--empty': (!selectedConversationId || isDraftConversationId(selectedConversationId)) && !selectedMessages.length && !chatLoading }"
|
||||
:style="{ '--anim-delay': '0.1s' }"
|
||||
>
|
||||
<div class="assistant-chat__spacer-top" />
|
||||
@@ -2764,11 +3082,13 @@ onBeforeUnmount(() => {
|
||||
@scroll.passive="handleMessageViewportScroll"
|
||||
@wheel.passive="handleMessageViewportWheel"
|
||||
>
|
||||
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
|
||||
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
||||
</div>
|
||||
<transition name="chat-content-fade">
|
||||
<div :key="selectedConversationId" class="assistant-messages__inner">
|
||||
<div v-if="shouldShowHistoryFallback" class="assistant-chat__fallback">
|
||||
当前会话的历史消息暂时不可读,但你仍然可以继续追问;后续刷新后会自动恢复。
|
||||
</div>
|
||||
|
||||
<TransitionGroup v-if="selectedMessages.length" tag="div" name="message-stagger" class="assistant-message-list">
|
||||
<TransitionGroup v-if="selectedMessages.length" tag="div" name="message-stagger" class="assistant-message-list">
|
||||
<article
|
||||
v-for="dm in displayMessages"
|
||||
:key="dm.id"
|
||||
@@ -2932,7 +3252,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'business_card'" class="chat-message__business-card">
|
||||
<div v-else-if="block.type === 'business_card' && block.businessCard" class="chat-message__business-card">
|
||||
<BusinessCardRenderer :payload="block.businessCard" />
|
||||
</div>
|
||||
|
||||
@@ -2971,13 +3291,15 @@ onBeforeUnmount(() => {
|
||||
<span class="chat-message__time">{{ formatMessageTime(dm.createdAt) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="assistant-chat__interaction-group">
|
||||
<!-- Welcome Content (Only in empty state) -->
|
||||
<Transition name="fade-switch">
|
||||
<div v-if="!selectedMessages.length && !chatLoading" class="assistant-empty">
|
||||
<div v-if="(!selectedConversationId || isDraftConversationId(selectedConversationId)) && !selectedMessages.length && !chatLoading" class="assistant-empty">
|
||||
<div class="assistant-empty__halo" />
|
||||
<div class="assistant-empty__content">
|
||||
<strong>SmartMate AI 伙伴</strong>
|
||||
@@ -3192,6 +3514,66 @@ onBeforeUnmount(() => {
|
||||
@close="closeFineTuneModal"
|
||||
@saved="handleScheduleSaved"
|
||||
/>
|
||||
|
||||
<!-- 任务编辑弹窗 (对齐首页) -->
|
||||
<el-dialog
|
||||
v-model="taskDialogVisible"
|
||||
:title="isEditMode ? '修改任务详情' : '创建新任务'"
|
||||
width="440px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="task-edit-dialog"
|
||||
>
|
||||
<div class="task-form">
|
||||
<div class="form-item">
|
||||
<label>任务标题</label>
|
||||
<el-input
|
||||
v-model="taskForm.title"
|
||||
placeholder="你想做点什么?"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label>优先级象限</label>
|
||||
<el-radio-group v-model="taskForm.priority_group" class="priority-selector">
|
||||
<el-radio-button :value="1">重要紧急</el-radio-button>
|
||||
<el-radio-button :value="2">重要不紧急</el-radio-button>
|
||||
<el-radio-button :value="3">简单琐碎</el-radio-button>
|
||||
<el-radio-button :value="4">暂缓处理</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>截止日期</label>
|
||||
<el-date-picker
|
||||
v-model="taskForm.deadline_at"
|
||||
type="datetime"
|
||||
placeholder="选个截止时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:clearable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="taskDialogVisible = false" round>取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSaveTask"
|
||||
:loading="saveTaskLoading"
|
||||
round
|
||||
>
|
||||
保存更改
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -3213,32 +3595,29 @@ onBeforeUnmount(() => {
|
||||
|
||||
.fade-switch-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-switch-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.message-stagger-enter-active,
|
||||
.message-stagger-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.message-stagger-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
|
||||
.message-stagger-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px) scale(0.95);
|
||||
}
|
||||
|
||||
.message-stagger-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inner-fade-enter-active,
|
||||
@@ -3248,13 +3627,42 @@ onBeforeUnmount(() => {
|
||||
|
||||
.inner-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.inner-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-content-fade-enter-active,
|
||||
.chat-content-fade-leave-active {
|
||||
transition:
|
||||
opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.4s ease;
|
||||
}
|
||||
|
||||
.chat-content-fade-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.chat-content-fade-enter-active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-content-fade-enter-from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.chat-content-fade-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.assistant-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -3746,12 +4154,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
.assistant-chat__spacer-top {
|
||||
flex: 0;
|
||||
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.assistant-chat__spacer-bottom {
|
||||
flex: 0;
|
||||
transition: flex 0.75s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.assistant-chat--empty .assistant-chat__spacer-top {
|
||||
@@ -3766,10 +4172,18 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* 关键:防止转场时的水平抖动 */
|
||||
position: relative;
|
||||
/* 隐藏滚动条,保持纯净感,仅在非空时显示 */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.assistant-messages__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.assistant-chat--empty .assistant-messages {
|
||||
flex: 0;
|
||||
overflow: hidden;
|
||||
@@ -5051,6 +5465,129 @@ onBeforeUnmount(() => {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog) {
|
||||
border-radius: 32px !important;
|
||||
overflow: hidden !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 40px 100px rgba(15, 23, 42, 0.18) !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog .el-dialog__header) {
|
||||
margin: 0 !important;
|
||||
padding: 40px 40px 10px !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog .el-dialog__title) {
|
||||
font-size: 26px !important;
|
||||
font-weight: 900 !important;
|
||||
color: #0f172a !important;
|
||||
letter-spacing: -0.04em !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog .el-dialog__headerbtn) {
|
||||
top: 40px !important;
|
||||
right: 40px !important;
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
background: #f8fafc !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog .el-dialog__body) {
|
||||
padding: 10px 40px 40px !important;
|
||||
}
|
||||
|
||||
:global(.task-edit-dialog .el-dialog__footer) {
|
||||
padding: 0 40px 40px !important;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.form-item label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 14px;
|
||||
margin-left: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-input__wrapper) {
|
||||
background: #fcfdfe !important;
|
||||
box-shadow: none !important;
|
||||
border: 2px solid #f1f5f9 !important;
|
||||
border-radius: 20px !important;
|
||||
padding: 14px 22px !important;
|
||||
}
|
||||
|
||||
.task-form :deep(.el-input__wrapper.is-focus) {
|
||||
background: #ffffff !important;
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.08) !important;
|
||||
}
|
||||
|
||||
.priority-selector {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.priority-selector :deep(.el-radio-button__inner) {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
padding: 14px 4px !important;
|
||||
border-radius: 18px !important;
|
||||
}
|
||||
|
||||
.priority-selector :deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||
background: #ffffff !important;
|
||||
color: #3b82f6 !important;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06) !important;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-footer .el-button {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
height: 60px;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.dialog-footer .el-button--primary {
|
||||
background: #0f172a !important; /* Midnight flat style */
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.dialog-footer .el-button--primary:hover {
|
||||
background: #1e293b !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 25px 50px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
:global(.premium-msg-box .el-message-box__header) {
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const router = createRouter({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/assistant',
|
||||
path: '/assistant/:id?',
|
||||
name: 'assistant',
|
||||
component: AssistantView,
|
||||
meta: {
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface AssistantMessage {
|
||||
content: string
|
||||
createdAt: string
|
||||
reasoning?: string
|
||||
extra?: any
|
||||
}
|
||||
|
||||
export type ThinkingModeType = 'auto' | 'true' | 'false'
|
||||
|
||||
Reference in New Issue
Block a user