Version: 0.9.31.dev.260419

后端:
1. 日程暂存接口——前端拖拽调整后保存到 Redis 快照
  - api/agent.go:新增 SaveScheduleState handler,解析绝对时间格式请求体,3 秒超时保护
  - routers/routers.go:注册 POST /schedule-state
  - model/agent.go:新增 SaveScheduleStatePlacedItem / SaveScheduleStateRequest 结构体
  - respond/respond.go:新增 5 个排程状态错误码(40058~40062)
  - 新增 service/agentsvc/agent_schedule_state.go:Load 快照 → ApplyPlacedItems → Save 回 Redis,校验归属
  - 新增 newAgent/conv/schedule_state_apply.go:ApplyPlacedItems 绝对坐标→相对 day_index 转换,去重/坐标/嵌入关系校验
2. SchedulePersistor 持久化层全面下线
  - 删除 newAgent/conv/schedule_persist.go(280 行,DiffScheduleState → applyChange → 事务写库整条链路)
  - model/state_store.go:移除 SchedulePersistor 接口
  - model/graph_run_state.go / node/execute.go / node/agent_nodes.go / service/agent.go / service/agent_newagent.go /
  cmd/start.go:移除 SchedulePersistor 字段、参数、注入六处
3. schedule_completed 事件推送——deliver 节点排程完毕信号
  - model/common_state.go:新增 HasScheduleChanges 标记,ResetForNextRun 清理
  - node/execute.go / node/rough_build.go:写工具和粗排成功后置 HasScheduleChanges=true
  - node/deliver.go:IsCompleted && HasScheduleChanges 时调用 EmitScheduleCompleted
  - stream/emitter.go:新增 EmitScheduleCompleted 方法
  - stream/openai.go:新增 StreamExtraKindScheduleCompleted + NewScheduleCompletedExtra
4. 预览接口补全 task_class_id
  - model/agent.go:GetSchedulePlanPreviewResponse 新增 TaskClassIDs
  - model/schedule.go:HybridScheduleEntry 新增 TaskClassID
  - conv/schedule_preview.go / service/agent_schedule_preview.go / service/schedule.go:三处透传填充
前端:
5. 排程完毕卡片 + 精排弹窗集成
  - 新增 api/schedule_agent.ts:getSchedulePreview / saveScheduleState / applyBatchIntoSchedule
  - types/dashboard.ts:新增 HybridScheduleEntry / SchedulePreviewData / PlacedItem 类型
  - components/dashboard/AssistantPanel.vue:监听 schedule_completed 事件异步拉取排程渲染卡片,集成 ScheduleResultCard + ScheduleFineTuneModal;confirm 交互从文本消息改为 resume 协议(approve/reject/cancel)
6. ToolTracePrototypeView 原型页新增日程小卡片 + 拖拽编排弹窗演示
7. DashboardView import 区域尺寸微调
This commit is contained in:
Losita
2026-04-19 13:53:07 +08:00
parent 146b94fd50
commit 668af5f6c0
31 changed files with 2805 additions and 383 deletions

View File

@@ -304,3 +304,43 @@ func (api *AgentHandler) GetContextStats(c *gin.Context) {
var raw json.RawMessage = json.RawMessage(statsJSON) var raw json.RawMessage = json.RawMessage(statsJSON)
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, raw)) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, raw))
} }
// SaveScheduleState 前端暂存日程调整到 Redis 快照。
//
// 设计说明:
// 1. 前端在 confirm 卡片上拖拽调整任务位置后,调用此接口以绝对时间格式提交放置项;
// 2. 后端将绝对坐标转换为 ScheduleState 内部的相对 day_index只修改 task_item不动课程
// 3. 不触发 LLM 调用、不写 MySQL、不刷新预览缓存。
//
// 降级策略:
// 1. 快照不存在TTL 过期或会话未进入排程)返回 400让前端提示用户重新对话
// 2. 坐标越界、task_item_id 不存在等校验错误统一返回 400。
func (api *AgentHandler) SaveScheduleState(c *gin.Context) {
// 1. 解析请求体。
var req model.SaveScheduleStateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
// 2. 校验 conversation_id。
conversationID := strings.TrimSpace(req.ConversationID)
if conversationID == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
// 3. 从鉴权上下文取当前用户 ID。
userID := c.GetInt("user_id")
// 4. 设置短超时,防止快照读写阻塞过久。
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
// 5. 调用 service 层执行 Load → 应用放置项 → Save。
if err := api.svc.SaveScheduleState(ctx, userID, conversationID, req.Items); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, nil))
}

View File

@@ -235,7 +235,6 @@ func Start() {
}, },
})) }))
agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo)) agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo))
agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager))
agentService.SetCompactionStore(agentRepo) agentService.SetCompactionStore(agentRepo)
agentService.SetMemoryReader(memoryModule, memoryCfg) agentService.SetMemoryReader(memoryModule, memoryCfg)

View File

@@ -247,6 +247,7 @@ type GetSchedulePlanPreviewResponse struct {
Summary string `json:"summary"` Summary string `json:"summary"`
CandidatePlans []UserWeekSchedule `json:"candidate_plans"` CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"` HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
TaskClassIDs []int `json:"task_class_ids,omitempty"`
GeneratedAt time.Time `json:"generated_at"` GeneratedAt time.Time `json:"generated_at"`
} }
@@ -302,3 +303,25 @@ type ChatHistory struct {
} }
func (ChatHistory) TableName() string { return "chat_histories" } func (ChatHistory) TableName() string { return "chat_histories" }
// SaveScheduleStatePlacedItem 描述一个已放置的 task_item 的绝对时间位置。
// 与 apply-batch 的 SingleTaskClassItem 格式统一,前端两个按钮共享同一数据格式。
type SaveScheduleStatePlacedItem struct {
TaskItemID int `json:"task_item_id" binding:"required"`
Week int `json:"week" binding:"required,min=1"`
DayOfWeek int `json:"day_of_week" binding:"required,min=1,max=7"`
StartSection int `json:"start_section" binding:"required,min=1"`
EndSection int `json:"end_section" binding:"required,min=1,gtefield=StartSection"`
EmbedCourseEventID int `json:"embed_course_event_id"`
}
// SaveScheduleStateRequest 前端暂存日程调整的请求体。
//
// 职责边界:
// 1. 只承载 conversation_id 和已放置的 task_item 列表(绝对时间格式);
// 2. 后端将绝对坐标转换为 ScheduleState 内部的相对 day_index
// 3. source=event 的课程不受影响,天然过滤。
type SaveScheduleStateRequest struct {
ConversationID string `json:"conversation_id" binding:"required"`
Items []SaveScheduleStatePlacedItem `json:"items" binding:"required,dive,required"`
}

View File

@@ -143,10 +143,11 @@ type HybridScheduleEntry struct {
SectionFrom int `json:"section_from"` SectionFrom int `json:"section_from"`
SectionTo int `json:"section_to"` SectionTo int `json:"section_to"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // "course" | "task" Type string `json:"type"` // "course" | "task"
Status string `json:"status"` // "existing" | "suggested" Status string `json:"status"` // "existing" | "suggested"
TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值 TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值
EventID int `json:"event_id,omitempty"` // 仅 existing 有值 TaskClassID int `json:"task_class_id,omitempty"` // 仅 suggested 的 task 有值,对应 TaskClass.ID
EventID int `json:"event_id,omitempty"` // 仅 existing 有值
// CanBeEmbedded 表示该条 existing 课程块是否允许嵌入任务。 // CanBeEmbedded 表示该条 existing 课程块是否允许嵌入任务。
// 仅课程条目有意义task 条目默认 false。 // 仅课程条目有意义task 条目默认 false。
CanBeEmbedded bool `json:"can_be_embedded,omitempty"` CanBeEmbedded bool `json:"can_be_embedded,omitempty"`

View File

@@ -1,280 +0,0 @@
package newagentconv
import (
"context"
"fmt"
baseconv "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
schedule "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// SchedulePersistorAdapter 实现 model.SchedulePersistor 接口。
// 组合 RepoManager调用 PersistScheduleChanges 持久化变更。
type SchedulePersistorAdapter struct {
manager *dao.RepoManager
}
// NewSchedulePersistorAdapter 创建持久化适配器。
func NewSchedulePersistorAdapter(manager *dao.RepoManager) *SchedulePersistorAdapter {
return &SchedulePersistorAdapter{manager: manager}
}
// PersistScheduleChanges 实现 model.SchedulePersistor 接口。
func (a *SchedulePersistorAdapter) PersistScheduleChanges(ctx context.Context, original, modified *schedule.ScheduleState, userID int) error {
return PersistScheduleChanges(ctx, a.manager, original, modified, userID)
}
// PersistScheduleChanges 将内存中的 ScheduleState 变更持久化到数据库。
//
// 职责边界:
// 1. 调用 DiffScheduleState 计算变更;
// 2. 在事务中逐个应用变更到数据库;
// 3. 全部成功或全部回滚,保证原子性。
func PersistScheduleChanges(
ctx context.Context,
manager *dao.RepoManager,
original *schedule.ScheduleState,
modified *schedule.ScheduleState,
userID int,
) error {
changes := DiffScheduleState(original, modified)
if len(changes) == 0 {
return nil
}
return manager.Transaction(ctx, func(txM *dao.RepoManager) error {
for _, change := range changes {
if err := applyScheduleChange(ctx, txM, change, userID); err != nil {
return fmt.Errorf("应用变更失败 [%s %s]: %w", change.Type, change.Name, err)
}
}
return nil
})
}
// applyScheduleChange 应用单个变更到数据库。
func applyScheduleChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
switch change.Type {
case ChangePlace:
return applyPlaceChange(ctx, manager, change, userID)
case ChangeMove:
return applyMoveChange(ctx, manager, change, userID)
case ChangeUnplace:
return applyUnplaceChange(ctx, manager, change, userID)
default:
return fmt.Errorf("未知变更类型: %s", change.Type)
}
}
// applyPlaceChange 应用放置变更。
func applyPlaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
if len(change.NewCoords) == 0 {
return fmt.Errorf("place 变更缺少目标位置")
}
switch change.Source {
case "event":
return applyPlaceEventSource(ctx, manager, change, userID)
case "task_item":
return applyPlaceTaskItem(ctx, manager, change, userID)
default:
return fmt.Errorf("place 变更不支持的 source: %s", change.Source)
}
}
// applyPlaceEventSource 处理 source=event 的放置(为已有 Event 创建 Schedule 记录)。
func applyPlaceEventSource(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
if change.SourceID == 0 {
return fmt.Errorf("place event 变更需要有效的 source_id")
}
groups := groupCoordsByWeekDay(change.NewCoords)
for week, dayGroups := range groups {
for dayOfWeek, coords := range dayGroups {
startSection, endSection := minMaxSection(coords)
schedules := make([]model.Schedule, endSection-startSection+1)
for sec := startSection; sec <= endSection; sec++ {
schedules[sec-startSection] = model.Schedule{
UserID: userID,
Week: week,
DayOfWeek: dayOfWeek,
Section: sec,
EventID: change.SourceID,
}
}
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
return fmt.Errorf("创建 schedule 失败: %w", err)
}
}
}
return nil
}
// applyPlaceTaskItem 处理 source=task_item 的放置。
//
// 两条路径:
// 1. 嵌入水课HostEventID != 0在宿主 Schedule 记录上设置 embedded_task_id。
// 2. 普通放置HostEventID == 0新建 ScheduleEvent(type=task) + Schedule 记录。
// 两条路径最终都更新 task_items.embedded_time。
func applyPlaceTaskItem(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
if change.SourceID == 0 {
return fmt.Errorf("place task_item 变更需要有效的 source_id")
}
// task_item 只占一段连续时段,取第一个 coord 的 week/dayOfWeek
first := change.NewCoords[0]
week, dayOfWeek := first.Week, first.DayOfWeek
startSection, endSection := minMaxSection(change.NewCoords)
targetTime := &model.TargetTime{
Week: week,
DayOfWeek: dayOfWeek,
SectionFrom: startSection,
SectionTo: endSection,
}
if change.HostEventID != 0 {
// 嵌入路径:更新宿主 Schedule 记录的 embedded_task_id
if err := manager.Schedule.EmbedTaskIntoSchedule(
startSection, endSection, dayOfWeek, week, userID, change.SourceID,
); err != nil {
return fmt.Errorf("嵌入水课失败: %w", err)
}
} else {
// 普通路径:新建 ScheduleEvent + Schedule 记录
startTime, endTime, err := baseconv.RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection)
if err != nil {
return fmt.Errorf("时间转换失败: %w", err)
}
relID := change.SourceID
event := model.ScheduleEvent{
UserID: userID,
Name: change.Name,
Type: "task",
RelID: &relID,
CanBeEmbedded: false,
StartTime: startTime,
EndTime: endTime,
}
eventID, err := manager.Schedule.AddScheduleEvent(&event)
if err != nil {
return fmt.Errorf("创建 schedule_event 失败: %w", err)
}
schedules := make([]model.Schedule, endSection-startSection+1)
for i, sec := 0, startSection; sec <= endSection; i, sec = i+1, sec+1 {
schedules[i] = model.Schedule{
UserID: userID,
Week: week,
DayOfWeek: dayOfWeek,
Section: sec,
EventID: eventID,
Status: "normal",
}
}
if _, err := manager.Schedule.AddSchedules(schedules); err != nil {
return fmt.Errorf("创建 schedule 记录失败: %w", err)
}
}
if err := manager.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, change.SourceID, targetTime); err != nil {
return fmt.Errorf("更新 task_item embedded_time 失败: %w", err)
}
return nil
}
// applyMoveChange 应用移动变更。
func applyMoveChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
switch change.Source {
case "event":
if change.SourceID != 0 {
if err := manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID); err != nil {
return fmt.Errorf("删除旧位置失败: %w", err)
}
}
case "task_item":
// 清理旧位置
if change.OldHostEventID != 0 {
// 旧位置是嵌入:清空宿主的 embedded_task_id
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.OldHostEventID); err != nil {
return fmt.Errorf("清除旧嵌入关系失败: %w", err)
}
} else {
// 旧位置是普通 task event按 task_item_id 删除
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
return fmt.Errorf("删除旧 task_item 日程失败: %w", err)
}
}
}
return applyPlaceChange(ctx, manager, change, userID)
}
// applyUnplaceChange 应用移除变更。
func applyUnplaceChange(ctx context.Context, manager *dao.RepoManager, change ScheduleChange, userID int) error {
switch change.Source {
case "event":
if change.SourceID == 0 {
return fmt.Errorf("unplace event 变更需要有效的 source_id")
}
return manager.Schedule.DeleteScheduleEventAndSchedule(ctx, change.SourceID, userID)
case "task_item":
if change.SourceID == 0 {
return fmt.Errorf("unplace task_item 变更需要有效的 source_id")
}
if change.HostEventID != 0 {
// 是嵌入:清空宿主 Schedule 的 embedded_task_id
if _, err := manager.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, change.HostEventID); err != nil {
return fmt.Errorf("清除嵌入关系失败: %w", err)
}
} else {
// 普通 task event按 task_item_id 删除
if err := manager.Schedule.DeleteScheduleEventByTaskItemID(ctx, change.SourceID); err != nil {
return fmt.Errorf("删除 task_item 日程失败: %w", err)
}
}
if err := manager.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, change.SourceID); err != nil {
return fmt.Errorf("清除 task_item embedded_time 失败: %w", err)
}
return nil
default:
return fmt.Errorf("unplace 变更不支持的 source: %s", change.Source)
}
}
// ==================== 辅助函数 ====================
// intPtr 返回 int 指针,零值返回 nil。
func intPtr(v int) *int {
if v == 0 {
return nil
}
return &v
}
// groupCoordsByWeekDay 按周天分组坐标。
func groupCoordsByWeekDay(coords []SlotCoord) map[int]map[int][]SlotCoord {
result := make(map[int]map[int][]SlotCoord)
for _, coord := range coords {
if result[coord.Week] == nil {
result[coord.Week] = make(map[int][]SlotCoord)
}
result[coord.Week][coord.DayOfWeek] = append(result[coord.Week][coord.DayOfWeek], coord)
}
return result
}
// minMaxSection 返回坐标列表中的最小和最大节次。
func minMaxSection(coords []SlotCoord) (min, max int) {
if len(coords) == 0 {
return 0, 0
}
min, max = coords[0].Section, coords[0].Section
for _, c := range coords[1:] {
if c.Section < min {
min = c.Section
}
if c.Section > max {
max = c.Section
}
}
return
}

View File

@@ -71,6 +71,7 @@ func ScheduleStateToPreview(
entry.EventID = t.SourceID entry.EventID = t.SourceID
} else { } else {
entry.TaskItemID = t.SourceID entry.TaskItemID = t.SourceID
entry.TaskClassID = t.TaskClassID
} }
// 嵌入与阻塞语义。 // 嵌入与阻塞语义。

View File

@@ -0,0 +1,90 @@
package newagentconv
import (
"github.com/LoveLosita/smartflow/backend/model"
schedule "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/respond"
)
// ApplyPlacedItems 将前端提交的绝对时间放置项应用到 ScheduleState。
//
// 职责边界:
// 1. 只修改 source=task_item 的任务source=event 的课程不受影响;
// 2. 不在请求中的任务保持原样slots/status/embed 不变);
// 3. 不校验 Slots 的业务合法性(冲突等由 execute 节点兜底);
// 4. 返回 respond.XXX 错误,调用方可直接透传给 DealWithError。
func ApplyPlacedItems(
state *schedule.ScheduleState,
items []model.SaveScheduleStatePlacedItem,
) error {
// 1. 构建索引。
sourceIDToTask := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
eventSourceIDToTask := make(map[int]*schedule.ScheduleTask)
for i := range state.Tasks {
t := &state.Tasks[i]
if t.Source == "task_item" {
sourceIDToTask[t.SourceID] = t
} else if t.Source == "event" {
eventSourceIDToTask[t.SourceID] = t
}
}
// 2. 去重检查。
seen := make(map[int]struct{}, len(items))
for _, item := range items {
if _, dup := seen[item.TaskItemID]; dup {
return respond.ScheduleStateDuplicateTaskItem
}
seen[item.TaskItemID] = struct{}{}
}
// 3. 逐个处理 item。
for _, item := range items {
// 3.1 绝对坐标 → 相对 day_index。
dayIndex, ok := state.WeekDayToDay(item.Week, item.DayOfWeek)
if !ok {
return respond.ScheduleStateInvalidCoordinates
}
// 3.2 在快照中查找对应的 task_item。
task, found := sourceIDToTask[item.TaskItemID]
if !found {
return respond.ScheduleStateTaskItemNotFound
}
// 3.3 清除旧嵌入关系。
if task.EmbedHost != nil {
oldHost := state.TaskByStateID(*task.EmbedHost)
if oldHost != nil {
oldHost.EmbeddedBy = nil
}
task.EmbedHost = nil
}
// 3.4 设置新嵌入关系。
if item.EmbedCourseEventID != 0 {
hostEvent := eventSourceIDToTask[item.EmbedCourseEventID]
if hostEvent == nil {
return respond.ScheduleStateEventNotFound
}
hostStateID := hostEvent.StateID
guestStateID := task.StateID
task.EmbedHost = &hostStateID
hostEvent.EmbeddedBy = &guestStateID
}
// 3.5 更新 Slots。
task.Slots = []schedule.TaskSlot{{
Day: dayIndex,
SlotStart: item.StartSection,
SlotEnd: item.EndSection,
}}
// 3.6 pending → suggested。
if task.Status == schedule.TaskStatusPending {
task.Status = schedule.TaskStatusSuggested
}
}
return nil
}

View File

@@ -112,6 +112,9 @@ type CommonState struct {
// HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。
// 调用目的graph 分支函数据此判断是否需要走 order_guard非日程操作跳过守卫。 // 调用目的graph 分支函数据此判断是否需要走 order_guard非日程操作跳过守卫。
HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"`
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
// 调用目的deliver 节点据此判断是否向前端推送"排程完毕"卡片。
HasScheduleChanges bool `json:"has_schedule_changes,omitempty"`
// ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。 // ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。
// 预埋字段,当前阶段 Execute 节点可自行决定是否读取。 // 预埋字段,当前阶段 Execute 节点可自行决定是否读取。
@@ -222,6 +225,7 @@ func (s *CommonState) ResetForNextRun() {
// 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。 // 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。
s.AllowReorder = false s.AllowReorder = false
s.HasScheduleWriteOps = false s.HasScheduleWriteOps = false
s.HasScheduleChanges = false
s.SuggestedOrderBaseline = nil s.SuggestedOrderBaseline = nil
s.ClearTerminalOutcome() s.ClearTerminalOutcome()
} }

View File

@@ -76,7 +76,6 @@ type AgentGraphDeps struct {
StateStore AgentStateStore StateStore AgentStateStore
ToolRegistry *newagenttools.ToolRegistry ToolRegistry *newagenttools.ToolRegistry
ScheduleProvider ScheduleStateProvider // 按 DAO 注入Execute 节点按需加载 ScheduleState ScheduleProvider ScheduleStateProvider // 按 DAO 注入Execute 节点按需加载 ScheduleState
SchedulePersistor SchedulePersistor // 按 DAO 注入,用于写工具执行后持久化变更
CompactionStore CompactionStore // 按 DAO 注入,用于 Execute 上下文压缩持久化 CompactionStore CompactionStore // 按 DAO 注入,用于 Execute 上下文压缩持久化
RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口 RoughBuildFunc RoughBuildFunc // 按 Service 注入,粗排算法入口
WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口 WriteSchedulePreview WriteSchedulePreviewFunc // 按 Service 注入,排程预览写入入口

View File

@@ -73,13 +73,6 @@ type ScopedScheduleStateProvider interface {
LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*schedule.ScheduleState, error) LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*schedule.ScheduleState, error)
} }
// SchedulePersistor 定义持久化 ScheduleState 变更的接口。
// 由 Service 层或 DAO 层实现,注入到 AgentGraphDeps 中。
// 使用接口而非具体 DAO 类型,避免 model → dao 的循环依赖。
type SchedulePersistor interface {
PersistScheduleChanges(ctx context.Context, original, modified *schedule.ScheduleState, userID int) error
}
// CompactionStore 定义上下文压缩的持久化接口。 // CompactionStore 定义上下文压缩的持久化接口。
// 由 Service 层实现(组合 DAO + Redis Cache注入到各阶段 NodeInput。 // 由 Service 层实现(组合 DAO + Redis Cache注入到各阶段 NodeInput。
type CompactionStore interface { type CompactionStore interface {

View File

@@ -169,7 +169,6 @@ func (n *AgentNodes) Execute(ctx context.Context, st *newagentmodel.AgentGraphSt
ResumeNode: "execute", ResumeNode: "execute",
ToolRegistry: st.Deps.ToolRegistry, ToolRegistry: st.Deps.ToolRegistry,
ScheduleState: scheduleState, ScheduleState: scheduleState,
SchedulePersistor: st.Deps.SchedulePersistor,
CompactionStore: st.Deps.CompactionStore, CompactionStore: st.Deps.CompactionStore,
WriteSchedulePreview: st.Deps.WriteSchedulePreview, WriteSchedulePreview: st.Deps.WriteSchedulePreview,
OriginalScheduleState: st.OriginalScheduleState, OriginalScheduleState: st.OriginalScheduleState,

View File

@@ -69,6 +69,14 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error {
// 2. 调 LLM 生成交付总结。 // 2. 调 LLM 生成交付总结。
summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter) summary := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter)
// 2.1 排程完毕卡片信号:
// 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送;
// 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片;
// 3. 不携带 Redis key 或排程数据,保持信号职责单一。
if flowState.IsCompleted() && flowState.HasScheduleChanges {
_ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName)
}
// 3. 伪流式推送总结。 // 3. 伪流式推送总结。
if strings.TrimSpace(summary) != "" { if strings.TrimSpace(summary) != "" {
msg := schema.AssistantMessage(summary, nil) msg := schema.AssistantMessage(summary, nil)

View File

@@ -43,8 +43,7 @@ const (
// 3. ConversationContext 提供历史对话与置顶上下文; // 3. ConversationContext 提供历史对话与置顶上下文;
// 4. ToolRegistry 提供工具注册表; // 4. ToolRegistry 提供工具注册表;
// 5. ScheduleState 提供工具操作的内存数据源(可为 nil由调用方按需加载 // 5. ScheduleState 提供工具操作的内存数据源(可为 nil由调用方按需加载
// 6. SchedulePersistor 仍保留注入位,但当前阶段不调用,避免写库; // 6. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。
// 7. OriginalScheduleState 继续保留,供 Redis 快照恢复时维持“当前态/原始态”成对语义。
type ExecuteNodeInput struct { type ExecuteNodeInput struct {
RuntimeState *newagentmodel.AgentRuntimeState RuntimeState *newagentmodel.AgentRuntimeState
ConversationContext *newagentmodel.ConversationContext ConversationContext *newagentmodel.ConversationContext
@@ -54,7 +53,6 @@ type ExecuteNodeInput struct {
ResumeNode string ResumeNode string
ToolRegistry *newagenttools.ToolRegistry ToolRegistry *newagenttools.ToolRegistry
ScheduleState *schedule.ScheduleState ScheduleState *schedule.ScheduleState
SchedulePersistor newagentmodel.SchedulePersistor
CompactionStore newagentmodel.CompactionStore CompactionStore newagentmodel.CompactionStore
WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc WriteSchedulePreview newagentmodel.WriteSchedulePreviewFunc
OriginalScheduleState *schedule.ScheduleState OriginalScheduleState *schedule.ScheduleState
@@ -114,7 +112,6 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
conversationContext, conversationContext,
input.ToolRegistry, input.ToolRegistry,
input.ScheduleState, input.ScheduleState,
input.SchedulePersistor,
input.OriginalScheduleState, input.OriginalScheduleState,
input.WriteSchedulePreview, input.WriteSchedulePreview,
emitter, emitter,
@@ -1467,6 +1464,7 @@ func executeToolCall(
// 3.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。 // 3.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。
if registry.IsWriteTool(toolName) { if registry.IsWriteTool(toolName) {
flowState.HasScheduleWriteOps = true flowState.HasScheduleWriteOps = true
flowState.HasScheduleChanges = true
} }
// 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。 // 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。
@@ -1507,7 +1505,6 @@ func executePendingTool(
conversationContext *newagentmodel.ConversationContext, conversationContext *newagentmodel.ConversationContext,
registry *newagenttools.ToolRegistry, registry *newagenttools.ToolRegistry,
scheduleState *schedule.ScheduleState, scheduleState *schedule.ScheduleState,
persistor newagentmodel.SchedulePersistor,
originalState *schedule.ScheduleState, originalState *schedule.ScheduleState,
writePreview newagentmodel.WriteSchedulePreviewFunc, writePreview newagentmodel.WriteSchedulePreviewFunc,
emitter *newagentstream.ChunkEmitter, emitter *newagentstream.ChunkEmitter,
@@ -1595,6 +1592,7 @@ func executePendingTool(
// 5.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。 // 5.1 标记本轮执行过日程写工具graph 分支据此决定是否走 order_guard。
if registry.IsWriteTool(pending.ToolName) { if registry.IsWriteTool(pending.ToolName) {
flowState.HasScheduleWriteOps = true flowState.HasScheduleWriteOps = true
flowState.HasScheduleChanges = true
} }
// 5. 写工具实时预览confirm accept 后真实执行写工具时,立即刷新一次预览缓存。 // 5. 写工具实时预览confirm accept 后真实执行写工具时,立即刷新一次预览缓存。

View File

@@ -87,6 +87,11 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e
// 6. 把粗排结果写入 ScheduleState。 // 6. 把粗排结果写入 ScheduleState。
applyStats := applyRoughBuildPlacements(scheduleState, placements) applyStats := applyRoughBuildPlacements(scheduleState, placements)
// 6.1 标记本轮产生过日程变更,供 deliver 节点判断是否推送"排程完毕"卡片。
if applyStats.AppliedCount > 0 {
flowState.HasScheduleChanges = true
}
// 7. 先校验粗排后是否仍有真实 pending。 // 7. 先校验粗排后是否仍有真实 pending。
stillPending := countPendingTasks(scheduleState, taskClassIDs) stillPending := countPendingTasks(scheduleState, taskClassIDs)
log.Printf( log.Printf(

View File

@@ -324,6 +324,18 @@ func (e *ChunkEmitter) EmitInterruptMessage(ctx context.Context, blockID, stage,
) )
} }
// EmitScheduleCompleted 输出一次"排程完毕"卡片事件。
//
// 协议约束:
// 1. 只走 extra不附带 content/reasoning
// 2. 前端拿到 kind=schedule_completed 后自行拉取排程数据渲染卡片。
func (e *ChunkEmitter) EmitScheduleCompleted(blockID, stage string) error {
if e == nil || e.emit == nil {
return nil
}
return e.emitExtraOnly(NewScheduleCompletedExtra(blockID, stage))
}
// EmitFinish 统一输出 stop 结束块,并带上 finish extra。 // EmitFinish 统一输出 stop 结束块,并带上 finish extra。
func (e *ChunkEmitter) EmitFinish(blockID, stage string) error { func (e *ChunkEmitter) EmitFinish(blockID, stage string) error {
if e == nil || e.emit == nil { if e == nil || e.emit == nil {

View File

@@ -39,14 +39,15 @@ type OpenAIChunkDelta struct {
type StreamExtraKind string type StreamExtraKind string
const ( const (
StreamExtraKindReasoningText StreamExtraKind = "reasoning_text" StreamExtraKindReasoningText StreamExtraKind = "reasoning_text"
StreamExtraKindAssistantText StreamExtraKind = "assistant_text" StreamExtraKindAssistantText StreamExtraKind = "assistant_text"
StreamExtraKindStatus StreamExtraKind = "status" StreamExtraKindStatus StreamExtraKind = "status"
StreamExtraKindToolCall StreamExtraKind = "tool_call" StreamExtraKindToolCall StreamExtraKind = "tool_call"
StreamExtraKindToolResult StreamExtraKind = "tool_result" StreamExtraKindToolResult StreamExtraKind = "tool_result"
StreamExtraKindConfirm StreamExtraKind = "confirm_request" StreamExtraKindConfirm StreamExtraKind = "confirm_request"
StreamExtraKindInterrupt StreamExtraKind = "interrupt" StreamExtraKindInterrupt StreamExtraKind = "interrupt"
StreamExtraKindFinish StreamExtraKind = "finish" StreamExtraKindFinish StreamExtraKind = "finish"
StreamExtraKindScheduleCompleted StreamExtraKind = "schedule_completed"
) )
// StreamDisplayMode 表示前端更适合如何展示该结构化事件。 // StreamDisplayMode 表示前端更适合如何展示该结构化事件。
@@ -262,7 +263,22 @@ func NewInterruptExtra(blockID, stage, interactionID, interactionType, summary s
} }
} }
// NewFinishExtra 创建“收尾完成”事件的 extra。 // NewScheduleCompletedExtra 创建”排程完毕”卡片事件的 extra。
//
// 职责边界:
// 1. 仅作为前端渲染”排程完毕小卡片”的信号,不携带排程数据;
// 2. 前端收到此事件后,自行通过对话 ID 调用现有接口拉取排程详情;
// 3. 触发条件CommonState.HasScheduleChanges == true 且 IsCompleted()。
func NewScheduleCompletedExtra(blockID, stage string) *OpenAIChunkExtra {
return &OpenAIChunkExtra{
Kind: StreamExtraKindScheduleCompleted,
BlockID: blockID,
Stage: stage,
DisplayMode: StreamDisplayModeCard,
}
}
// NewFinishExtra 创建”收尾完成”事件的 extra。
func NewFinishExtra(blockID, stage string) *OpenAIChunkExtra { func NewFinishExtra(blockID, stage string) *OpenAIChunkExtra {
return &OpenAIChunkExtra{ return &OpenAIChunkExtra{
Kind: StreamExtraKindFinish, Kind: StreamExtraKindFinish,

View File

@@ -354,6 +354,31 @@ var ( //请求相关的响应
Info: "invalid memory content", Info: "invalid memory content",
} }
ScheduleStateSnapshotNotFound = Response{ //排程快照不存在或已过期
Status: "40058",
Info: "schedule state snapshot not found",
}
ScheduleStateInvalidCoordinates = Response{ //绝对时间坐标超出排程窗口范围
Status: "40059",
Info: "invalid week/day_of_week coordinates",
}
ScheduleStateTaskItemNotFound = Response{ //task_item_id 在快照中不存在
Status: "40060",
Info: "task_item_id not found in schedule state",
}
ScheduleStateEventNotFound = Response{ //embed_course_event_id 在快照课程中不存在
Status: "40061",
Info: "embed_course_event_id not found in schedule state events",
}
ScheduleStateDuplicateTaskItem = Response{ //请求中包含重复的 task_item_id
Status: "40062",
Info: "duplicate task_item_id in request",
}
RouteControlInternalError = Response{ //路由控制码内部错误 RouteControlInternalError = Response{ //路由控制码内部错误
Status: "50001", Status: "50001",
Info: "route control failed", Info: "route control failed",

View File

@@ -96,6 +96,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory) agentGroup.GET("/conversation-history", handlers.AgentHandler.GetConversationHistory)
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview) agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats) agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats)
agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState)
} }
memoryGroup := apiGroup.Group("/memory") memoryGroup := apiGroup.Group("/memory")
{ {

View File

@@ -51,15 +51,14 @@ type AgentService struct {
ResolvePlanningWindowFunc func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error) ResolvePlanningWindowFunc func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
// ── newAgent 依赖(由 cmd/start.go 通过 Set* 方法注入)── // ── newAgent 依赖(由 cmd/start.go 通过 Set* 方法注入)──
toolRegistry *newagenttools.ToolRegistry toolRegistry *newagenttools.ToolRegistry
scheduleProvider newagentmodel.ScheduleStateProvider scheduleProvider newagentmodel.ScheduleStateProvider
schedulePersistor newagentmodel.SchedulePersistor agentStateStore newagentmodel.AgentStateStore
agentStateStore newagentmodel.AgentStateStore compactionStore newagentmodel.CompactionStore
compactionStore newagentmodel.CompactionStore memoryReader MemoryReader
memoryReader MemoryReader memoryCfg memorymodel.Config
memoryCfg memorymodel.Config memoryObserver memoryobserve.Observer
memoryObserver memoryobserve.Observer memoryMetrics memoryobserve.MetricsRecorder
memoryMetrics memoryobserve.MetricsRecorder
} }
// NewAgentService 构造 AgentService。 // NewAgentService 构造 AgentService。

View File

@@ -192,7 +192,6 @@ func (s *AgentService) runNewAgentGraph(
StateStore: s.agentStateStore, StateStore: s.agentStateStore,
ToolRegistry: s.toolRegistry, ToolRegistry: s.toolRegistry,
ScheduleProvider: s.scheduleProvider, ScheduleProvider: s.scheduleProvider,
SchedulePersistor: s.schedulePersistor,
CompactionStore: s.compactionStore, CompactionStore: s.compactionStore,
RoughBuildFunc: s.makeRoughBuildFunc(), RoughBuildFunc: s.makeRoughBuildFunc(),
WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(), WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(),
@@ -660,11 +659,6 @@ func (s *AgentService) SetScheduleProvider(provider newagentmodel.ScheduleStateP
s.scheduleProvider = provider s.scheduleProvider = provider
} }
// schedulePersistor 由 cmd/start.go 注入
func (s *AgentService) SetSchedulePersistor(persistor newagentmodel.SchedulePersistor) {
s.schedulePersistor = persistor
}
// agentStateStore 由 cmd/start.go 注入 // agentStateStore 由 cmd/start.go 注入
func (s *AgentService) SetAgentStateStore(store newagentmodel.AgentStateStore) { func (s *AgentService) SetAgentStateStore(store newagentmodel.AgentStateStore) {
s.agentStateStore = store s.agentStateStore = store

View File

@@ -102,6 +102,7 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
Summary: strings.TrimSpace(preview.Summary), Summary: strings.TrimSpace(preview.Summary),
CandidatePlans: plans, CandidatePlans: plans,
HybridEntries: cloneHybridEntries(preview.HybridEntries), HybridEntries: cloneHybridEntries(preview.HybridEntries),
TaskClassIDs: preview.TaskClassIDs,
GeneratedAt: preview.GeneratedAt, GeneratedAt: preview.GeneratedAt,
}, nil }, nil
} }
@@ -214,6 +215,7 @@ func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnap
Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)),
CandidatePlans: plans, CandidatePlans: plans,
HybridEntries: cloneHybridEntries(snapshot.HybridEntries), HybridEntries: cloneHybridEntries(snapshot.HybridEntries),
TaskClassIDs: snapshot.TaskClassIDs,
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
} }
} }

View File

@@ -0,0 +1,62 @@
package agentsvc
import (
"context"
"errors"
"fmt"
"log"
"github.com/LoveLosita/smartflow/backend/model"
newagentconv "github.com/LoveLosita/smartflow/backend/newAgent/conv"
"github.com/LoveLosita/smartflow/backend/respond"
)
// SaveScheduleState 前端暂存日程调整到 Redis 快照。
//
// 职责边界:
// 1. 只负责更新 Redis 中的 ScheduleState 中 source=task_item 的任务;
// 2. 接受绝对时间格式(与 apply-batch 统一),由 conv 层转换为内部相对坐标;
// 3. source=event 的课程保持快照原值不变;
// 4. 不负责写 MySQL、不负责刷新预览缓存
// 5. 不负责触发 graph 执行(由 confirm_action=accept 驱动)。
func (s *AgentService) SaveScheduleState(
ctx context.Context,
userID int,
conversationID string,
items []model.SaveScheduleStatePlacedItem,
) error {
// 1. 加载快照。
if s.agentStateStore == nil {
return errors.New("agent state store 未初始化")
}
snapshot, ok, err := s.agentStateStore.Load(ctx, conversationID)
if err != nil {
return fmt.Errorf("加载快照失败: %w", err)
}
if !ok || snapshot == nil || snapshot.ScheduleState == nil {
return respond.ScheduleStateSnapshotNotFound
}
// 2. 校验归属。
if snapshot.RuntimeState != nil {
cs := snapshot.RuntimeState.EnsureCommonState()
if cs.UserID != 0 && cs.UserID != userID {
return fmt.Errorf("会话归属校验失败:快照 user_id=%d请求 user_id=%d", cs.UserID, userID)
}
}
// 3. 调用 conv 层将绝对时间放置项应用到 ScheduleState。
if err := newagentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil {
return err
}
// 4. 写回 Redis。
if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil {
return fmt.Errorf("保存快照失败: %w", err)
}
log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d",
conversationID, userID, len(items))
return nil
}

View File

@@ -860,9 +860,17 @@ func buildHybridEntriesFromSchedulesAndAllocated(
Type: "task", Type: "task",
Status: "suggested", Status: "suggested",
TaskItemID: item.ID, TaskItemID: item.ID,
TaskClassID: derefInt(item.CategoryID),
BlockForSuggested: true, BlockForSuggested: true,
}) })
} }
return entries return entries
} }
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}

View File

@@ -0,0 +1,653 @@
# 日程卡片前端集成文档
本文档描述前端实现"排程结果卡片 + 暂存/写库按钮"所需的全部接口、数据结构和交互流程。
---
## 一、整体交互流程
```
用户发消息 → SSE 流式返回 → 收到 schedule_completed 事件
调用 schedule-preview 接口拉取排程数据
渲染日程卡片(可拖拽调整位置)
┌─────────────────┴─────────────────┐
│ │
"暂存 state"按钮 "写库"按钮
POST /agent/schedule-state PUT /task-class/apply-batch-into-schedule
(暂存到 Redis 快照) (真正写入 MySQL 日程表)
```
---
## 二、SSE 事件格式
### 2.1 基础 SSE 壳
所有 SSE data 行都是 JSON格式遵循 OpenAI 兼容协议:
```json
{
"id": "request-id",
"object": "chat.completion.chunk",
"created": 1745036800,
"model": "worker",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "正文内容",
"reasoning_content": "思考内容"
},
"finish_reason": null
}
],
"extra": { ... }
}
```
- `choices` 可以为空数组(纯结构化事件时)
- `extra` 为可选字段,旧事件不含 extra
### 2.2 心跳保活
```
: ping
```
SSE 标准注释行,每 5 秒一次。前端 `JSON.parse` 失败后丢弃即可。
### 2.3 流结束标记
```
data: [DONE]
```
### 2.4 错误事件
```json
{
"error": {
"message": "错误描述",
"type": "server_error",
"code": "5xxxx"
}
}
```
---
## 三、`extra` 结构化事件类型
前端通过 `extra.kind` 判断事件类型。
### 3.1 事件类型枚举
| kind | 含义 | display_mode | 说明 |
|------|------|-------------|------|
| `reasoning_text` | 思考文字 | `append` | 逐块追加 |
| `assistant_text` | 回复正文 | `append` | 逐块追加 |
| `status` | 阶段状态 | `card` | 如"正在排程" |
| `tool_call` | 工具调用开始 | `card` | 如"正在查询任务" |
| `tool_result` | 工具调用结果 | `card` | 如"找到 3 个任务" |
| `confirm_request` | 待确认事件 | `card` | **需要用户确认** |
| `interrupt` | 中断/追问 | `card` | ask_user 追问 |
| `schedule_completed` | **排程完毕** | `card` | **前端拉取排程数据的信号** |
| `finish` | 流结束 | `replace` | 收尾 |
### 3.2 status 事件
```json
{
"extra": {
"kind": "status",
"block_id": "execute.status",
"stage": "execute",
"display_mode": "card",
"status": {
"code": "planning",
"summary": "正在智能排程..."
}
}
}
```
### 3.3 tool_call 事件(工具调用开始)
```json
{
"extra": {
"kind": "tool_call",
"block_id": "execute.tool.1",
"stage": "execute",
"display_mode": "card",
"tool": {
"name": "smart_planning",
"status": "start",
"summary": "正在为任务类智能排程",
"arguments_preview": "任务类: [高数作业, 英语阅读]"
}
}
}
```
### 3.4 tool_result 事件(工具调用结果)
```json
{
"extra": {
"kind": "tool_result",
"block_id": "execute.tool.1",
"stage": "execute",
"display_mode": "card",
"tool": {
"name": "smart_planning",
"status": "done",
"summary": "成功生成排程方案",
"arguments_preview": "已排 3 个任务"
}
}
}
```
- `tool.status` 取值:`start` | `done` | `blocked` | `failed`
### 3.5 confirm_request 事件(用户确认)
```json
{
"choices": [{
"index": 0,
"delta": { "role": "assistant", "content": "请确认是否应用排程结果...\n" }
}],
"extra": {
"kind": "confirm_request",
"block_id": "execute.confirm.1",
"stage": "execute",
"display_mode": "card",
"confirm": {
"interaction_id": "confirm_abc123",
"title": "确认应用排程结果",
"summary": "是否将 3 个任务安排到日程中?"
}
}
}
```
**前端需要:**
1. 保存 `interaction_id`
2. 展示确认卡片title + summary + 确认/拒绝按钮)
3. 用户点击后发送 resume 请求(见第五节)
### 3.6 schedule_completed 事件(排程完毕信号)
```json
{
"extra": {
"kind": "schedule_completed",
"block_id": "deliver.schedule",
"stage": "deliver",
"display_mode": "card"
}
}
```
**前端收到后:** 用当前 `conversation_id` 调用 `GET /agent/schedule-preview` 拉取排程数据。
### 3.7 finish 事件
```json
{
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "stop"
}],
"extra": {
"kind": "finish",
"block_id": "deliver.finish",
"stage": "deliver",
"display_mode": "replace"
}
}
```
---
## 四、排程预览接口
收到 `schedule_completed` 事件后调用此接口获取排程数据。
### GET `/api/v1/agent/schedule-preview`
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `conversation_id` | string | 是 | 会话 ID |
**响应:**
```json
{
"status": "10000",
"info": "success",
"data": {
"conversation_id": "1655dd9b-...",
"trace_id": "trace-...",
"summary": "已为你安排了 3 个任务",
"candidate_plans": [
{
"week": 1,
"events": [
{
"id": 10,
"order": 1,
"day_of_week": 1,
"name": "高等数学",
"start_time": "08:00",
"end_time": "09:30",
"location": "教学楼A",
"type": "course",
"span": 2,
"status": "normal",
"embedded_task_info": {
"id": 100,
"name": "数学作业",
"type": "task"
}
},
{
"id": 11,
"order": 2,
"day_of_week": 1,
"name": "编程练习",
"start_time": "10:00",
"end_time": "11:30",
"location": "",
"type": "task",
"span": 2,
"status": "normal",
"embedded_task_info": {}
}
]
},
{
"week": 2,
"events": [...]
}
],
"hybrid_entries": [
{
"week": 1,
"day_of_week": 1,
"section_from": 3,
"section_to": 4,
"name": "英语阅读",
"type": "task",
"status": "suggested",
"task_item_id": 101,
"task_class_id": 5,
"event_id": 0,
"can_be_embedded": false,
"block_for_suggested": true,
"context_tag": "Memory"
},
{
"week": 1,
"day_of_week": 2,
"section_from": 1,
"section_to": 2,
"name": "高等数学",
"type": "course",
"status": "existing",
"task_item_id": 0,
"task_class_id": 0,
"event_id": 10,
"can_be_embedded": true,
"block_for_suggested": false,
"context_tag": ""
}
],
"task_class_ids": [5, 6],
"generated_at": "2026-04-19T10:00:00Z"
}
}
```
### 数据结构说明
#### HybridScheduleEntry混合日程条目
前端渲染日程卡片的核心数据结构。课程和任务统一到同一个列表中。
| 字段 | 类型 | 说明 |
|------|------|------|
| `week` | int | 学期周数 |
| `day_of_week` | int | 星期几1=周一7=周日) |
| `section_from` | int | 起始节次1-based |
| `section_to` | int | 结束节次1-based |
| `name` | string | 名称 |
| `type` | string | `"course"`(课程)或 `"task"`(任务) |
| `status` | string | `"existing"`(已确定)或 `"suggested"`(建议) |
| `task_item_id` | int | 任务项 ID仅 type=task 且 status=suggested 时有值) |
| `task_class_id` | int | 任务类 ID仅 type=task 且 status=suggested 时有值,对应写库接口的 `task_class_id` |
| `event_id` | int | 日程事件 ID仅 existing 时有值) |
| `can_be_embedded` | bool | 课程是否允许嵌入任务 |
| `block_for_suggested` | bool | 是否阻塞建议任务占位 |
| `context_tag` | string | 认知类型标签:`"High-Logic"` / `"Memory"` / `"Review"` / `"General"` |
**渲染建议:**
- `status=existing` → 已有课程/日程,渲染为固定色块(不可拖拽)
- `status=suggested` → AI 建议的任务,渲染为可拖拽色块
- `can_be_embedded=true` 的课程 → 任务可嵌入到其时段内
#### UserWeekSchedule按周视图的已有日程
| 字段 | 类型 | 说明 |
|------|------|------|
| `week` | int | 周数 |
| `events` | WeeklyEventBrief[] | 该周事件列表 |
#### WeeklyEventBrief
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | ScheduleEvent.ID |
| `order` | int | 天内显示顺序 |
| `day_of_week` | int | 星期几 |
| `name` | string | 名称 |
| `start_time` | string | 开始时间(如 "08:00" |
| `end_time` | string | 结束时间 |
| `location` | string | 地点 |
| `type` | string | `"course"` / `"task"` |
| `span` | int | 跨越节数(渲染高度) |
| `status` | string | `"normal"` / `"interrupted"` |
| `embedded_task_info` | TaskBrief | 嵌入的任务信息(可选) |
#### TaskBrief
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 关联 ID |
| `name` | string | 任务名称 |
| `type` | string | `"task"` |
---
## 五、用户确认流程confirm / resume
### 5.1 流程说明
当后端需要用户确认时:
1. SSE 推送 `kind=confirm_request` 事件
2. 前端展示确认卡片
3. 用户点击"确认"或"拒绝"
4. 前端发送 resume 请求回同一聊天接口
### 5.2 请求格式
**POST `/api/v1/agent/chat`**(复用聊天入口)
```json
{
"conversation_id": "1655dd9b-2c4c-4b56-a712-f34c11b2634d",
"message": "",
"extra": {
"resume": {
"interaction_id": "confirm_abc123",
"type": "confirm",
"action": "approve"
}
}
}
```
### 5.3 resume 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `interaction_id` | string | 是 | 从 confirm_request 事件中获取 |
| `type` | string | 否 | 默认为 `"confirm"`,也可为 `"ask_user"``"connection_recover"` |
| `action` | string | 是 | 见下表 |
**confirm 类型的 action**
| action | 含义 |
|--------|------|
| `approve` | 用户同意,继续执行 |
| `reject` | 用户拒绝 |
| `cancel` | 用户取消 |
**ask_user 类型的 action**
| action | 含义 |
|--------|------|
| `reply` | 用户回答追问(回答内容放在顶层 `message` 字段) |
| `cancel` | 用户取消 |
---
## 六、"暂存 state"按钮
将用户在卡片上拖拽调整后的任务位置暂存到 Redis 快照。**不写 MySQL不触发 LLM。**
### POST `/api/v1/agent/schedule-state`
**请求体:**
```json
{
"conversation_id": "1655dd9b-2c4c-4b56-a712-f34c11b2634d",
"items": [
{
"task_item_id": 101,
"week": 1,
"day_of_week": 1,
"start_section": 3,
"end_section": 4
},
{
"task_item_id": 102,
"week": 2,
"day_of_week": 3,
"start_section": 5,
"end_section": 6,
"embed_course_event_id": 20
}
]
}
```
### SaveScheduleStatePlacedItem 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `task_item_id` | int | 是 | 任务项 ID来自 HybridScheduleEntry 的 `task_item_id` |
| `week` | int | 是 | 学期周数≥1 |
| `day_of_week` | int | 是 | 星期几1-7 |
| `start_section` | int | 是 | 起始节次≥1 |
| `end_section` | int | 是 | 结束节次≥1须 ≥ start_section |
| `embed_course_event_id` | int | 否 | 嵌入目标课程的 event_id来自 HybridScheduleEntry 的 `event_id` |
**安全保证:** 只修改 `type=task` 的建议任务,课程数据永远不变。不在 items 中的任务保持原样。
### 成功响应
```json
{
"status": "10000",
"info": "success",
"data": null
}
```
### 错误码
| 错误码 status | 含义 |
|--------------|------|
| `40004` | 缺少 conversation_id |
| `40005` | 请求体格式错误 |
| `40058` | 排程快照不存在或已过期(需重新对话) |
| `40059` | week/day_of_week 坐标超出排程窗口范围 |
| `40060` | task_item_id 在快照中不存在 |
| `40061` | embed_course_event_id 在快照课程中不存在 |
| `40062` | 请求中包含重复的 task_item_id |
---
## 七、"写库"按钮
将任务真正写入 MySQL 日程表。**需要按任务类task_class分组调用。**
### PUT `/api/v1/task-class/apply-batch-into-schedule`
**注意:** 该接口需要幂等性 Key`Idempotency-Key` header防止重复点击。
**请求体:**
```json
{
"task_class_id": 123,
"items": [
{
"task_item_id": 101,
"week": 1,
"day_of_week": 1,
"start_section": 3,
"end_section": 4
},
{
"task_item_id": 102,
"week": 2,
"day_of_week": 3,
"start_section": 5,
"end_section": 6,
"embed_course_event_id": 20
}
]
}
```
### 请求字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `task_class_id` | int | 是 | 任务类 ID来自 HybridScheduleEntry 关联的任务类) |
| `items` | SingleTaskClassItem[] | 是 | 放置项列表 |
### SingleTaskClassItem 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `task_item_id` | int | 是 | 任务项 ID |
| `week` | int | 是 | 学期周数≥1 |
| `day_of_week` | int | 是 | 星期几1-7 |
| `start_section` | int | 是 | 起始节次≥1 |
| `end_section` | int | 是 | 结束节次≥1须 ≥ start_section |
| `embed_course_event_id` | int | 否 | 嵌入目标课程的 ScheduleEvent.ID |
### 成功响应
```json
{
"status": "10000",
"info": "success"
}
```
### 错误码
| 错误码 status | 含义 |
|--------------|------|
| `40005` | 请求体格式错误 |
| `40037` | 缺少 Idempotency-Key header |
| `40038` | 请求正在处理中(幂等性防重) |
| `40026` | 日程冲突 |
| `40025` | 课程已被其他任务块嵌入 |
| `40034` | 任务项已安排 |
| `40048` | 任务项不属于该任务类 |
| `40049` | 任务项时间超出学期范围 |
---
## 八、两个按钮的 items 格式完全一致
**关键设计:** "暂存 state"和"写库"两个按钮的 `items` 数据格式完全相同。
```
前端只需维护一份 items 数组:
- 用户拖拽调整位置 → 更新 items
- 点击"暂存" → POST /agent/schedule-state { items }
- 点击"写库" → PUT /task-class/apply-batch-into-schedule { task_class_id, items }
```
**区别:**
- "暂存"接口需要 `conversation_id`"写库"接口需要 `task_class_id`
- `task_class_id` 来自 `HybridScheduleEntry.task_class_id`(每个 suggested 任务条目都有),也可从响应顶层 `task_class_ids` 数组获取
- 写库时需按 `task_class_id` 分组:相同 `task_class_id` 的 items 放在同一个请求中
- "暂存"写 Redis 快照(可恢复),"写库"写 MySQL 日程表(持久化)
- "写库"需要 `Idempotency-Key` header 防重
---
## 九、前端实现建议
### 9.1 日程卡片渲染
1.`hybrid_entries` 作为主数据源渲染周视图
2. `status=existing` 的条目渲染为**只读**色块(灰色/蓝色课程块)
3. `status=suggested` 的条目渲染为**可拖拽**色块(绿色/橙色任务块)
4. 拖拽时校验:目标位置是否与 `block_for_suggested=true` 的条目冲突
5. 嵌入:拖拽到 `can_be_embedded=true` 的课程块上时,设置 `embed_course_event_id`
### 9.2 items 数组维护
```typescript
interface PlacedItem {
task_item_id: number;
week: number;
day_of_week: number;
start_section: number;
end_section: number;
embed_course_event_id?: number;
}
// 从 hybrid_entries 中筛选 suggested 任务,构建 items
function buildItemsFromEntries(entries: HybridScheduleEntry[]): PlacedItem[] {
return entries
.filter(e => e.status === 'suggested' && e.task_item_id)
.map(e => ({
task_item_id: e.task_item_id,
week: e.week,
day_of_week: e.day_of_week,
start_section: e.section_from,
end_section: e.section_to,
embed_course_event_id: e.event_id || undefined,
}));
}
```
### 9.3 SSE 连接管理
- 使用 `fetch` + `ReadableStream`(非 `EventSource`,因为需要 POST body
- 心跳 `: ping` 行不是 `data:` 开头,`JSON.parse` 会失败,直接忽略
- 错误事件格式为 `{ "error": { ... } }`,注意与正常事件区分
- 流结束标记为 `data: [DONE]`
- `X-Conversation-ID` 响应头包含服务端分配的 conversation_id
### 9.4 完整交互时序
```
1. 用户发送消息 → POST /agent/chat
2. SSE 流返回 thinking + 工具事件 + 正文
3. 收到 extra.kind === "schedule_completed"
4. GET /agent/schedule-preview?conversation_id=xxx
5. 渲染日程卡片
6. 用户拖拽调整任务位置 → 更新本地 items 数组
7a. 点击"暂存" → POST /agent/schedule-state { conversation_id, items }
7b. 点击"写库" → PUT /task-class/apply-batch-into-schedule { task_class_id, items }
```

View File

@@ -0,0 +1,54 @@
import http from '@/api/http'
import type { ApiResponse } from '@/types/api'
import type { PlacedItem, SchedulePreviewData } from '@/types/dashboard'
import { extractErrorMessage } from '@/utils/http'
/**
* 获取排程预览数据
*/
export async function getSchedulePreview(conversationId: string): Promise<SchedulePreviewData> {
try {
const response = await http.get<ApiResponse<SchedulePreviewData>>('/agent/schedule-preview', {
params: { conversation_id: conversationId },
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '获取方案预览失败'))
}
}
/**
* 暂存排程状态到 Redis
*/
export async function saveScheduleState(conversationId: string, items: PlacedItem[]): Promise<void> {
try {
await http.post<ApiResponse<void>>('/agent/schedule-state', {
conversation_id: conversationId,
items,
})
} catch (error) {
throw new Error(extractErrorMessage(error, '暂存方案失败'))
}
}
/**
* 将排程结果正式应用到数据库
*/
export async function applyBatchIntoSchedule(
taskClassId: number,
items: PlacedItem[],
idempotencyKey: string
): Promise<void> {
try {
await http.put<ApiResponse<void>>('/task-class/apply-batch-into-schedule', {
task_class_id: taskClassId,
items,
}, {
headers: {
'Idempotency-Key': idempotencyKey
}
})
} catch (error) {
throw new Error(extractErrorMessage(error, '保存方案失败'))
}
}

View File

@@ -0,0 +1,682 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { HybridScheduleEntry, PlacedItem, SchedulePreviewData } from '@/types/dashboard'
import { saveScheduleState, applyBatchIntoSchedule } from '@/api/schedule_agent'
const props = defineProps<{
previewData: SchedulePreviewData
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
}>()
// 计算数据中的起止周次
const weekRange = computed(() => {
const weeks = props.previewData.hybrid_entries.map(e => e.week)
if (weeks.length === 0) return { min: 1, max: 20 }
return {
min: Math.min(...weeks),
max: Math.max(...weeks)
}
})
const currentWeek = ref(weekRange.value.min)
const isSaving = ref(false)
// 内部维护一份可变的建议任务列表,用于拖拽更新
const suggestedItems = ref<HybridScheduleEntry[]>(
JSON.parse(JSON.stringify(props.previewData.hybrid_entries))
)
const sectionSlots = [
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
{ order: 3, title: '5-6', timeRange: '14:00\n15:40' },
{ order: 4, title: '7-8', timeRange: '16:15\n17:55' },
{ order: 5, title: '9-10', timeRange: '19:00\n20:40' },
{ order: 6, title: '11-12', timeRange: '20:50\n22:30' },
]
function prevWeek() {
if (currentWeek.value > weekRange.value.min) currentWeek.value--
}
function nextWeek() {
if (currentWeek.value < weekRange.value.max) currentWeek.value++
}
// 转换当前状态为后端要求的 PlacedItem 数组
function buildPlacedItems(): PlacedItem[] {
return suggestedItems.value
.filter(e => e.type === 'task' && e.status === 'suggested')
.map(e => ({
task_item_id: e.task_item_id,
week: e.week,
day_of_week: e.day_of_week,
start_section: e.section_from,
end_section: e.section_to,
embed_course_event_id: e.event_id || undefined,
}))
}
/**
* 暂存至 State (Redis)
*/
async function handleSaveToState() {
isSaving.value = true
try {
const items = buildPlacedItems()
await saveScheduleState(props.previewData.conversation_id, items)
ElMessage.success('方案已成功暂存')
} catch (error: any) {
ElMessage.error(error.message || '暂存失败')
} finally {
isSaving.value = false
}
}
/**
* 正式保存到数据库 (MySQL)
*/
async function handleOfficialSave() {
await ElMessageBox.confirm(
'正式保存将把当前编排结果写入你的日程表。保存后本轮编排微调将终止,确认继续吗?',
'正式保存确认',
{
confirmButtonText: '确认保存',
cancelButtonText: '我再想想',
type: 'warning',
roundButton: true,
customClass: 'premium-msg-box',
}
)
isSaving.value = true
try {
const items = buildPlacedItems()
// 按 task_class_id 分组
const groups = new Map<number, PlacedItem[]>()
suggestedItems.value.forEach(e => {
if (e.type === 'task' && e.status === 'suggested' && e.task_class_id) {
if (!groups.has(e.task_class_id)) groups.set(e.task_class_id, [])
groups.get(e.task_class_id)!.push({
task_item_id: e.task_item_id,
week: e.week,
day_of_week: e.day_of_week,
start_section: e.section_from,
end_section: e.section_to,
embed_course_event_id: e.event_id || undefined,
})
}
})
const idempotencyKey = crypto.randomUUID()
const promises = Array.from(groups.entries()).map(([classId, groupItems]) =>
applyBatchIntoSchedule(classId, groupItems, idempotencyKey)
)
await Promise.all(promises)
ElMessage.success('日程已正式保存到数据库')
emit('saved')
emit('close')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
isSaving.value = false
}
}
// 拖拽逻辑
function onDragStart(event: DragEvent, item: HybridScheduleEntry) {
if (item.status === 'existing') return // 不允许拖拽已有日程
if (event.dataTransfer) {
event.dataTransfer.setData('application/json', JSON.stringify(item))
event.dataTransfer.effectAllowed = 'move'
}
}
function onDrop(event: DragEvent, day: number, order: number) {
const rawData = event.dataTransfer?.getData('application/json')
if (!rawData) return
const draggedItem = JSON.parse(rawData) as HybridScheduleEntry
const itemIndex = suggestedItems.value.findIndex(i => i.task_item_id === draggedItem.task_item_id)
if (itemIndex > -1) {
// 转发为块起始节次
const targetSectionFrom = (order - 1) * 2 + 1
const targetSectionTo = targetSectionFrom + 1
// 检查目标位置是否有冲突block_for_suggested === true 且不是自己)
const conflict = suggestedItems.value.find(i =>
i.week === currentWeek.value &&
i.day_of_week === day &&
getBlockIndex(i.section_from) === order &&
i.block_for_suggested &&
i.task_item_id !== draggedItem.task_item_id
)
if (conflict) {
if (conflict.can_be_embedded) {
// 允许嵌入
const item = suggestedItems.value[itemIndex]
item.week = currentWeek.value
item.day_of_week = day
item.section_from = targetSectionFrom
item.section_to = targetSectionTo
item.event_id = conflict.event_id // 设置嵌入目标 ID
} else {
ElMessage.warning('该时段已有课程,无法安插任务')
}
} else {
// 自由空位
const item = suggestedItems.value[itemIndex]
item.week = currentWeek.value
item.day_of_week = day
item.section_from = targetSectionFrom
item.section_to = targetSectionTo
item.event_id = 0 // 清除嵌入状态
}
}
}
// 将后端节次 (1, 3, 5...) 映射为前端 6 个双节块索引 (1, 2, 3...)
const getBlockIndex = (section: number) => Math.floor((section - 1) / 2) + 1
// 获取项的网格定位样式
function getItemStyle(item: HybridScheduleEntry) {
// 网格第 1 行是表头,所以 row 为 section + 1
const rowStart = item.section_from + 1
const rowEnd = item.section_to + 2
return {
gridColumn: item.day_of_week + 1,
gridRow: `${rowStart} / ${rowEnd}`,
zIndex: item.status === 'suggested' ? 2 : 1
}
}
const currentWeekEntries = computed(() =>
suggestedItems.value.filter(e => e.week === currentWeek.value)
)
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="previewData" class="schedule-modal-overlay" @click.self="emit('close')">
<div class="schedule-modal">
<header class="schedule-modal__header">
<h3>日程预览与精排 ( {{ currentWeek }} )</h3>
<div class="schedule-modal__header-actions">
<div class="week-switcher">
<button
class="week-switcher__btn"
@click="prevWeek"
:disabled="currentWeek <= weekRange.min"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span class="week-switcher__label"> {{ currentWeek }} </span>
<button
class="week-switcher__btn"
@click="nextWeek"
:disabled="currentWeek >= weekRange.max"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<button class="schedule-modal__close" @click="emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</header>
<div class="schedule-modal__body">
<div class="planning-board__grid">
<div class="planning-board__corner" />
<!-- 表头周一到周日 -->
<div
v-for="d in 7"
:key="`h-${d}`"
class="planning-board__day-head"
:style="{ gridColumn: d + 1, gridRow: 1 }"
>
<span>{{ ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][d-1] }}</span>
</div>
<!-- 时间侧栏与背景格采用跨行方式每个 Block 2 -->
<template v-for="slot in sectionSlots" :key="`r-${slot.order}`">
<div
class="planning-board__time-cell"
:style="{ gridColumn: 1, gridRow: `${(slot.order-1)*2 + 2} / span 2` }"
>
<strong>{{ slot.title }}</strong>
<small>{{ slot.timeRange }}</small>
</div>
<!-- 空白背景格 2 行以形成大块感 -->
<div
v-for="d in 7"
:key="`bg-${d}-${slot.order}`"
class="planning-board__cell planning-board__cell--empty"
:style="{ gridColumn: d + 1, gridRow: `${(slot.order-1)*2 + 2} / span 2` }"
@dragover.prevent
@drop="onDrop($event, d, slot.order)"
/>
</template>
<!-- 实际的内容项基于 12 行粒度精确展示 -->
<div
v-for="item in currentWeekEntries"
:key="item.task_item_id || `existing-${item.event_id}`"
class="planning-board__cell-main board-item-pop"
:class="`planning-board__cell-main--${item.type}`"
:style="getItemStyle(item)"
:draggable="item.status === 'suggested'"
@dragstart="onDragStart($event, item)"
>
<strong>{{ item.name }}</strong>
<span>{{ item.type === 'course' ? (item.context_tag || '教学楼 A') : '个人任务' }}</span>
</div>
</div>
</div>
<footer class="schedule-modal__footer">
<p class="schedule-modal__hint">提示拖动卡片可调整日程顺序</p>
<div class="schedule-modal__actions">
<button class="tool-btn tool-btn--ghost" @click="emit('close')">取消</button>
<button
class="tool-btn tool-btn--state"
:disabled="isSaving"
@click="handleSaveToState"
>
{{ isSaving ? '保存中...' : '暂存进state' }}
</button>
<button
class="tool-btn tool-btn--primary"
:disabled="isSaving"
@click="handleOfficialSave"
>
{{ isSaving ? '保存中...' : '正式保存日程' }}
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 弹窗核心样式 */
.schedule-modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.schedule-modal {
background: #ffffff;
width: min(1400px, 92%);
height: auto;
max-height: 95vh;
border-radius: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.schedule-modal__header {
padding: 20px 32px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.schedule-modal__header h3 {
margin: 0;
font-size: 18px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.schedule-modal__header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.week-switcher {
display: flex;
align-items: center;
gap: 12px;
background: #f8fafc;
padding: 4px;
border-radius: 12px;
border: 1px solid #f1f5f9;
}
.week-switcher__btn {
width: 32px;
height: 32px;
border: none;
background: #ffffff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.week-switcher__btn:hover:not(:disabled) {
background: #eff6ff;
color: #3b82f6;
transform: translateY(-1px);
}
.week-switcher__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.week-switcher__label {
font-size: 14px;
font-weight: 700;
color: #1e293b;
min-width: 60px;
text-align: center;
}
.schedule-modal__close {
background: #f1f5f9;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.schedule-modal__close:hover {
background: #e2e8f0;
color: #1e293b;
transform: rotate(90deg);
}
.schedule-modal__body {
padding: 0;
flex: 1;
overflow-y: auto;
}
.schedule-modal__footer {
padding: 20px 32px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.schedule-modal__hint {
margin: 0;
font-size: 13px;
color: #94a3b8;
font-style: italic;
}
.schedule-modal__actions {
display: flex;
gap: 12px;
}
/* 按钮通用样式 */
.tool-btn {
border: 1px solid transparent;
border-radius: 12px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.tool-btn--primary {
background: #0f172a;
color: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);
}
.tool-btn--primary:hover:not(:disabled) {
background: #1e293b;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
}
.tool-btn--state {
background: #eff6ff;
color: #2563eb;
border-color: #dbeafe;
}
.tool-btn--state:hover:not(:disabled) {
background: #dbeafe;
color: #1e40af;
border-color: #bfdbfe;
}
.tool-btn--ghost {
background: #ffffff;
color: #475569;
border-color: #e2e8f0;
}
.tool-btn--ghost:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #1e293b;
}
.tool-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 网格样式 */
.planning-board__grid {
--planning-grid-padding-x: 20px;
--planning-grid-padding-y: 16px;
--planning-grid-gap-x: 10px;
--planning-grid-gap-y: 10px;
--planning-time-column-width: 76px;
--planning-day-column-min: 96px;
--planning-cell-height: 82px;
display: grid;
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
grid-template-rows: auto repeat(12, calc((var(--planning-cell-height) - var(--planning-grid-gap-y)) / 2));
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 32px;
overflow: auto;
background: #ffffff;
}
.planning-board__corner {
min-height: 1px;
}
.planning-board__day-head {
display: grid;
justify-items: center;
gap: 4px;
color: #64748b;
padding-bottom: 12px;
}
.planning-board__day-head span {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.08em;
color: #1e293b;
}
.planning-board__time-cell {
display: grid;
align-content: center;
justify-items: end;
color: #94a3b8;
padding-right: 16px;
border-right: 1px solid #f1f5f9;
}
.planning-board__time-cell strong {
font-size: 15px;
color: #475569;
font-weight: 800;
}
.planning-board__time-cell small {
font-size: 11px;
color: #94a3b8;
white-space: pre-line;
text-align: right;
line-height: 1.35;
}
.planning-board__cell {
position: relative;
border-radius: 16px;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #f8fafc;
}
.planning-board__cell--empty {
background: #ffffff;
border: 1px dashed #e2e8f0;
}
.planning-board__cell:hover:not(.planning-board__cell--empty) {
transform: translateY(-4px);
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.planning-board__cell-main {
width: 100%;
height: 100%;
padding: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 6px;
border-radius: 16px;
cursor: grab;
transition: all 0.2s;
}
.planning-board__cell-main:active {
cursor: grabbing;
}
.planning-board__cell-main--course {
background: #e0f2fe;
color: #0369a1;
}
.planning-board__cell-main--task {
background: #dcfce7;
color: #15803d;
}
.planning-board__cell-main strong {
font-size: 13px;
font-weight: 700;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.planning-board__cell-main span {
font-size: 11px;
opacity: 0.8;
}
/* 进场动画 */
@keyframes board-item-spring {
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.board-item-pop {
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* 弹窗动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.4s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .schedule-modal {
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-leave-active .schedule-modal {
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
}
@keyframes modal-in {
from {
transform: scale(0.95) translateY(30px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
/**
* 排程结果展示卡片
* 显示在 AI 助手的消息流中,告知用户排程已就绪。
*/
defineProps<{
summary: string
}>()
const emit = defineEmits<{
(e: 'click'): void
}>()
</script>
<template>
<div class="schedule-result-card" @click="emit('click')">
<div class="schedule-result-card__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="schedule-result-card__content">
<h4 class="schedule-result-card__summary">{{ summary }}</h4>
<p class="schedule-result-card__detail">点击查看排程方案并进行微调</p>
</div>
<div class="schedule-result-card__arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
</div>
</template>
<style scoped>
.schedule-result-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
margin: 8px 0;
}
.schedule-result-card:hover {
transform: translateY(-2px);
border-color: #3b82f6;
box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.15);
}
.schedule-result-card__icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: #eff6ff;
color: #3b82f6;
border-radius: 12px;
flex-shrink: 0;
}
.schedule-result-card__content {
flex: 1;
min-width: 0;
}
.schedule-result-card__summary {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
}
.schedule-result-card__detail {
margin: 0;
font-size: 13px;
color: #64748b;
}
.schedule-result-card__arrow {
color: #cbd5e1;
transition: transform 0.3s;
}
.schedule-result-card:hover .schedule-result-card__arrow {
transform: translateX(4px);
color: #3b82f6;
}
</style>

View File

@@ -11,6 +11,7 @@ import {
getConversationMeta, getConversationMeta,
type ConversationHistoryMessage, type ConversationHistoryMessage,
} from '@/api/agent' } from '@/api/agent'
import { getSchedulePreview } from '@/api/schedule_agent'
import { refreshToken } from '@/api/auth' import { refreshToken } from '@/api/auth'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { import type {
@@ -21,7 +22,10 @@ import type {
ConversationListItem, ConversationListItem,
ConversationMeta, ConversationMeta,
ThinkingModeType, ThinkingModeType,
SchedulePreviewData,
} from '@/types/dashboard' } from '@/types/dashboard'
import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue'
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
import { formatConversationTime, formatMessageTime } from '@/utils/date' import { formatConversationTime, formatMessageTime } from '@/utils/date'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
@@ -139,11 +143,12 @@ interface DisplayMessage {
interface DisplayAssistantBlock { interface DisplayAssistantBlock {
id: string id: string
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card'
seq: number seq: number
text?: string text?: string
event?: ToolTraceEvent event?: ToolTraceEvent
statusEvent?: StatusTraceEvent statusEvent?: StatusTraceEvent
schedulePreview?: SchedulePreviewData
} }
interface AssistantContentBlock { interface AssistantContentBlock {
@@ -218,6 +223,9 @@ const conversationContextStatsMap = reactive<Record<string, ConversationContextS
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({}) const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({}) const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
const conversationListItemRevealMap = reactive<Record<string, boolean>>({}) const conversationListItemRevealMap = reactive<Record<string, boolean>>({})
const scheduleResultMap = reactive<Record<string, SchedulePreviewData>>({})
const isFineTuneModalVisible = ref(false)
const activeFineTuneData = ref<SchedulePreviewData | null>(null)
const quickActions = [ const quickActions = [
'帮我梳理今天最重要的三件事', '帮我梳理今天最重要的三件事',
@@ -464,6 +472,7 @@ function clearToolTraceState(messageId: string) {
delete assistantReasoningSeqMap[messageId] delete assistantReasoningSeqMap[messageId]
delete assistantContentBlocksMap[messageId] delete assistantContentBlocksMap[messageId]
delete assistantTimelineLastKindMap[messageId] delete assistantTimelineLastKindMap[messageId]
delete scheduleResultMap[messageId]
for (const key of Object.keys(toolTraceExpandedMap)) { for (const key of Object.keys(toolTraceExpandedMap)) {
if (key.startsWith(`${messageId}:tool:`)) { if (key.startsWith(`${messageId}:tool:`)) {
delete toolTraceExpandedMap[key] delete toolTraceExpandedMap[key]
@@ -1208,6 +1217,16 @@ function getDisplayAssistantBlocks(dm: DisplayMessage): DisplayAssistantBlock[]
} }
} }
const schedulePreview = scheduleResultMap[dm.id]
if (schedulePreview) {
blocks.push({
id: `${dm.id}:schedule-card`,
type: 'schedule_card',
seq: nextAssistantTimelineSeq(),
schedulePreview,
})
}
if (shouldShowDisplayReasoningBox(dm)) { if (shouldShowDisplayReasoningBox(dm)) {
const reasoningSeq = getDisplayReasoningSeq(dm) const reasoningSeq = getDisplayReasoningSeq(dm)
blocks.push({ blocks.push({
@@ -1699,6 +1718,30 @@ function isManualThinkingEnabled(mode: ThinkingModeType) {
return mode === 'true' return mode === 'true'
} }
function openFineTuneModal(data: SchedulePreviewData) {
activeFineTuneData.value = data
isFineTuneModalVisible.value = true
}
function closeFineTuneModal() {
isFineTuneModalVisible.value = false
}
function handleScheduleSaved() {
// 保存成功后可选的操作:重新刷新历史或状态
if (selectedConversationId.value) {
void loadConversationContextStats(selectedConversationId.value, true)
}
}
function closeConfirmOverlay() {
// 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。
// 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。
confirmOverlayState.visible = false
confirmOverlayState.manuallyClosed = true
confirmRejectDraft.value = ''
}
function resetConfirmOverlay() { function resetConfirmOverlay() {
// 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。 // 1. 会话切换/新建会话时直接重置确认覆盖层,避免把上一个会话的确认状态误带到当前会话。
// 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。 // 2. interactionId 同时清空,确保下一次收到相同 ID 的事件也能被视为新事件并重新拉起卡片。
@@ -1710,12 +1753,51 @@ function resetConfirmOverlay() {
confirmRejectDraft.value = '' confirmRejectDraft.value = ''
} }
function closeConfirmOverlay() { async function sendConfirmAction(action: 'approve' | 'reject' | 'cancel') {
// 1. “手动关闭”与“自动收起”要区分:手动关闭后,本次 interaction 的重复分片不应反复弹层。 const interactionId = confirmOverlayState.interactionId
// 2. 仅恢复对话框可见性,不改后端 pending 状态;真正的确认流转仍由用户点击确认/拒绝触发。 if (!interactionId) return
// 1. 立即关闭覆盖层,避免用户重复点击。
// 2. 构造 resume 特殊载荷,复用 sendMessageInternal 发送到聊天接口。
confirmOverlayState.visible = false confirmOverlayState.visible = false
confirmOverlayState.manuallyClosed = true await sendMessageInternal({
confirmRejectDraft.value = '' preset: '',
bypassConfirmOverlayCheck: true,
requestExtra: {
resume: {
interaction_id: interactionId,
type: 'confirm',
action
}
}
})
}
async function submitConfirmRejectMessage() {
const text = confirmRejectDraft.value.trim()
if (!text) return
const interactionId = confirmOverlayState.interactionId
if (!interactionId) return
confirmOverlayState.visible = false
await sendMessageInternal({
preset: text,
bypassConfirmOverlayCheck: true,
requestExtra: {
resume: {
interaction_id: interactionId,
type: 'ask_user',
action: 'reply'
}
}
})
}
function handleConfirmRejectInputEnter(event: KeyboardEvent) {
if (event.shiftKey) return
event.preventDefault()
void submitConfirmRejectMessage()
} }
function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) { function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
@@ -1887,6 +1969,19 @@ function handleStreamExtraEvent(extra: StreamExtraPayload | undefined, assistant
) )
} }
} }
if (extra.kind === 'schedule_completed') {
// 异步拉取详细排程方案
void (async () => {
try {
const preview = await getSchedulePreview(selectedConversationId.value)
scheduleResultMap[assistantMessage.id] = preview
scheduleScrollMessagesToBottom(true)
} catch (error: any) {
ElMessage.error(error.message || '获取排程方案失败')
}
})()
}
} }
function shouldSuppressReasoningDeltaByExtraKind(kind?: string) { function shouldSuppressReasoningDeltaByExtraKind(kind?: string) {
@@ -2197,47 +2292,6 @@ async function sendMessage(preset?: string) {
await sendMessageInternal({ preset }) await sendMessageInternal({ preset })
} }
async function sendConfirmAction(action: 'accept' | 'reject', rejectMessage?: string) {
// 1. confirm 是显式人工动作,先关闭覆盖层再发送,让对话框立即恢复可见。
// 2. “拒绝”动作允许带上用户自定义要求,后端可据此进行重规划。
// 3. 发送完成后清空输入草稿,避免旧要求残留到下一轮确认卡片。
const normalizedRejectMessage = `${rejectMessage || ''}`.trim()
const presetMessage =
action === 'accept'
? '我确认,继续执行。'
: normalizedRejectMessage || '先不执行,请重新规划。'
closeConfirmOverlay()
await sendMessageInternal({
preset: presetMessage,
requestExtra: { confirm_action: action },
resetPlanningSelectionOnSuccess: false,
bypassConfirmOverlayCheck: true,
})
confirmRejectDraft.value = ''
}
async function submitConfirmRejectMessage() {
const rejectMessage = confirmRejectDraft.value.trim()
if (!rejectMessage) {
ElMessage.warning('请输入你的调整要求后再发送。')
return
}
await sendConfirmAction('reject', rejectMessage)
}
function handleConfirmRejectInputEnter(event: KeyboardEvent) {
// 1. 中文输入法上屏期间按回车只用于选词,不应误触发发送。
// 2. 仅在“非组合输入 + 单独 Enter”时提交Shift+Enter 仍可换行。
if (event.isComposing) {
return
}
event.preventDefault()
void submitConfirmRejectMessage()
}
watch( watch(
() => selectedMessages.value.length, () => selectedMessages.value.length,
() => { () => {
@@ -2583,13 +2637,20 @@ onBeforeUnmount(() => {
<div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" /> <div class="chat-message__markdown chat-message__markdown--assistant" v-html="renderMessageMarkdown(block.text || '')" />
</div> </div>
<div v-else-if="block.type === 'content_indicator'" class="chat-message__streaming chat-message__streaming--plain"> <template v-else-if="block.type === 'schedule_card' && block.schedulePreview">
<ScheduleResultCard
:summary="block.schedulePreview.summary"
@click="openFineTuneModal(block.schedulePreview)"
/>
</template>
<div v-else-if="block.type === 'content_indicator'" class="assistant-timeline__answering-indicator">
<div class="typing-indicator"> <div class="typing-indicator">
<span /> <span />
<span /> <span />
<span /> <span />
</div> </div>
</div> </div>
</div> </div>
</TransitionGroup> </TransitionGroup>
@@ -2652,7 +2713,7 @@ onBeforeUnmount(() => {
type="button" type="button"
class="assistant-confirm-card__button assistant-confirm-card__button--primary" class="assistant-confirm-card__button assistant-confirm-card__button--primary"
:disabled="chatLoading" :disabled="chatLoading"
@click="sendConfirmAction('accept')" @click="sendConfirmAction('approve')"
> >
确认执行 确认执行
</button> </button>
@@ -2792,6 +2853,14 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
</aside> </aside>
<!-- 日程排程方案精排弹窗 -->
<ScheduleFineTuneModal
v-if="isFineTuneModalVisible && activeFineTuneData"
:preview-data="activeFineTuneData"
@close="closeFineTuneModal"
@saved="handleScheduleSaved"
/>
</template> </template>
<style scoped> <style scoped>
@@ -4566,5 +4635,143 @@ onBeforeUnmount(() => {
} }
</style> </style>
<style> <style>
/* --- AI 助手确认卡片 & 弹窗高级样式 --- */
:global(.premium-msg-box) {
--el-messagebox-width: 420px;
border-radius: 24px !important;
padding: 24px !important;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25) !important;
backdrop-filter: blur(16px) !important;
background: rgba(255, 255, 255, 0.9) !important;
}
:global(.premium-msg-box .el-message-box__header) {
padding-bottom: 8px !important;
}
:global(.premium-msg-box .el-message-box__title) {
font-size: 18px !important;
font-weight: 800 !important;
color: #0f172a !important;
}
:global(.premium-msg-box .el-message-box__message) {
color: #64748b !important;
line-height: 1.6 !important;
}
.assistant-confirm-composer {
padding: 16px;
background: #ffffff;
}
.assistant-confirm-card {
background: #f8fafc;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 24px;
padding: 24px;
box-shadow: 0 10px 15px -3px rgba(15, 23, 42, 0.05);
}
.assistant-confirm-card__eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: #3b82f6;
margin-bottom: 8px;
letter-spacing: 0.05em;
}
.assistant-confirm-card__title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 800;
color: #0f172a;
}
.assistant-confirm-card__summary {
margin-bottom: 20px;
font-size: 15px;
color: #1e293b;
line-height: 1.5;
}
.assistant-confirm-card__hint {
font-size: 13px;
color: #94a3b8;
margin-bottom: 24px;
padding: 12px;
background: rgba(255, 255, 255, 0.5);
border-radius: 12px;
}
.assistant-confirm-card__actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.assistant-confirm-card__button {
height: 48px;
border-radius: 16px;
font-weight: 700;
cursor: pointer;
border: none;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.assistant-confirm-card__button--primary {
background: #3b82f6;
color: #ffffff;
}
.assistant-confirm-card__button--primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.assistant-confirm-card__button--ghost {
background: #ffffff;
color: #475569;
border: 1px solid #e2e8f0;
}
.assistant-confirm-card__button--plain {
background: transparent;
color: #94a3b8;
font-size: 14px;
}
.assistant-confirm-card__reject-box {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.assistant-confirm-card__reject-label {
font-size: 13px;
font-weight: 600;
color: #64748b;
}
.assistant-confirm-card__reject-input {
width: 100%;
padding: 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
resize: none;
font-family: inherit;
font-size: 14px;
}
.assistant-confirm-card__reject-input:focus {
outline: none;
border-color: #3b82f6;
}
</style> </style>

View File

@@ -115,3 +115,43 @@ export interface ChatStreamRequest {
thinking?: ThinkingModeType thinking?: ThinkingModeType
extra?: ChatRequestExtra extra?: ChatRequestExtra
} }
export interface HybridScheduleEntry {
week: number
day_of_week: number
section_from: number
section_to: number
name: string
type: 'course' | 'task'
status: 'existing' | 'suggested'
task_item_id: number
task_class_id: number
event_id: number
can_be_embedded: boolean
block_for_suggested: boolean
context_tag: string
}
export interface ScheduleCandidatePlan {
week: number
events: TodayEvent[]
}
export interface SchedulePreviewData {
conversation_id: string
trace_id: string
summary: string
candidate_plans: ScheduleCandidatePlan[]
hybrid_entries: HybridScheduleEntry[]
task_class_ids: number[]
generated_at: string
}
export interface PlacedItem {
task_item_id: number
week: number
day_of_week: number
start_section: number
end_section: number
embed_course_event_id?: number
}

View File

@@ -178,7 +178,7 @@ function syncDashboardMainScale() {
const gridGap = 10 const gridGap = 10
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return } if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return }
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.98) const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.96)
dashboardMainScale.value = Number(nextScale.toFixed(4)) dashboardMainScale.value = Number(nextScale.toFixed(4))
}) })
} }
@@ -343,9 +343,9 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
.dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } .dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.dashboard-import { .dashboard-import {
border-radius: 24px; border-radius: 20px;
padding: 32px; padding: 24px 32px;
min-height: 220px; min-height: 180px;
background: #ffffff; background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05); border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02); box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
@@ -358,8 +358,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
.dashboard-import__content { position: relative; z-index: 1; max-width: 460px; } .dashboard-import__content { position: relative; z-index: 1; max-width: 460px; }
.dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; } .dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; }
.dashboard-import h2 { margin: 0; font-size: 32px; color: #0f172a; font-weight: 800; } .dashboard-import h2 { margin: 0; font-size: 24px; color: #0f172a; font-weight: 800; }
.dashboard-import p { margin: 14px 0 24px; color: #64748b; font-size: 14px; } .dashboard-import p { margin: 8px 0 16px; color: #64748b; font-size: 13px; line-height: 1.5; }
.dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; } .dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; }
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; } .dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
interface BaselineLine { interface BaselineLine {
id: string id: string
@@ -12,7 +13,7 @@ type ToolLineState = 'called' | 'create' | 'blocked'
interface EnhancedLine { interface EnhancedLine {
id: string id: string
atMs: number atMs: number
type: 'text' | 'tool' type: 'text' | 'tool' | 'schedule_card'
text?: string text?: string
summary?: string summary?: string
detail?: string detail?: string
@@ -76,6 +77,13 @@ const enhancedScript: EnhancedLine[] = [
detail: '你还没授权“允许打乱顺序”,所以这一步先不执行。', detail: '你还没授权“允许打乱顺序”,所以这一步先不执行。',
}, },
{ id: 'enh-8', atMs: 2460, type: 'text', text: '如果你还要我打乱同类任务顺序,需要你先明确授权。' }, { id: 'enh-8', atMs: 2460, type: 'text', text: '如果你还要我打乱同类任务顺序,需要你先明确授权。' },
{
id: 'enh-9',
atMs: 2800,
type: 'schedule_card',
summary: '日程表编排已就绪',
detail: '已为你避开晚上课程,并插入了“周日报告”提醒。点击查看并微调。',
},
] ]
const baselineLines = ref<BaselineLine[]>([]) const baselineLines = ref<BaselineLine[]>([])
@@ -83,13 +91,114 @@ const enhancedLines = ref<EnhancedLine[]>([])
const isReplaying = ref(false) const isReplaying = ref(false)
const replayRound = ref(0) const replayRound = ref(0)
const expandedToolLineMap = reactive<Record<string, boolean>>({}) const expandedToolLineMap = reactive<Record<string, boolean>>({})
const isScheduleModalVisible = ref(false)
// 模拟日程数据
interface MockScheduleItem {
id: number
name: string
day: number // 1-7
order: number // 1-5
type: 'course' | 'task'
}
const sectionSlots = [
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
{ order: 3, title: '5-6', timeRange: '14:00\n15:40' },
{ order: 4, title: '7-8', timeRange: '16:15\n17:55' },
{ order: 5, title: '9-10', timeRange: '19:00\n20:40' },
{ order: 6, title: '11-12', timeRange: '20:50\n22:30' },
]
const mockSchedule = ref<MockScheduleItem[]>([
{ id: 1, name: '软件架构设计 (课程)', day: 1, order: 1, type: 'course' },
{ id: 2, name: '英语听力练习', day: 1, order: 3, type: 'task' },
{ id: 3, name: '算法分析 (课程)', day: 2, order: 2, type: 'course' },
{ id: 4, name: '周日报告提交', day: 7, order: 5, type: 'task' },
])
const isSaving = ref(false)
const currentWeek = ref(1)
function openScheduleModal() {
isScheduleModalVisible.value = true
}
function closeScheduleModal() {
isScheduleModalVisible.value = false
}
function prevWeek() {
if (currentWeek.value > 1) {
currentWeek.value--
}
}
function nextWeek() {
currentWeek.value++
}
function handleSaveToState() {
isSaving.value = true
setTimeout(() => {
isSaving.value = false
ElMessage({
message: '日程已成功暂存至运行时 State',
type: 'success',
plain: true
})
}, 600)
}
function handleOfficialSave() {
ElMessageBox.confirm(
'正式保存日程将把当前编排结果写入数据库。注意:保存后本轮编排微调将会终止,无法撤回。确认继续吗?',
'正式保存确认',
{
confirmButtonText: '确认保存',
cancelButtonText: '我再想想',
type: 'warning',
roundButton: true,
customClass: 'premium-msg-box',
}
).then(() => {
isSaving.value = true
setTimeout(() => {
isSaving.value = false
closeScheduleModal()
ElMessage.success('日程已正式持久化到数据库')
}, 1200)
}).catch(() => {
// 用户取消,无需操作
})
}
// 拖拽逻辑
function onDragStart(event: DragEvent, item: MockScheduleItem) {
if (event.dataTransfer) {
event.dataTransfer.setData('text/plain', item.id.toString())
event.dataTransfer.effectAllowed = 'move'
}
}
function onDrop(event: DragEvent, day: number, order: number) {
if (event.dataTransfer) {
const id = parseInt(event.dataTransfer.getData('text/plain'))
const item = mockSchedule.value.find(i => i.id === id)
if (item) {
item.day = day
item.order = order
}
}
}
const timerHandles = new Set<number>() const timerHandles = new Set<number>()
function clearReplayTimers() { function clearReplayTimers() {
for (const handle of timerHandles) { timerHandles.forEach((handle) => {
window.clearTimeout(handle) window.clearTimeout(handle)
} })
timerHandles.clear() timerHandles.clear()
} }
@@ -224,7 +333,7 @@ onBeforeUnmount(() => {
<p v-if="line.type === 'text'" class="proto-line">{{ line.text }}</p> <p v-if="line.type === 'text'" class="proto-line">{{ line.text }}</p>
<div <div
v-else v-else-if="line.type === 'tool'"
class="proto-tool" class="proto-tool"
:class="{ :class="{
'proto-tool--called': line.state === 'called', 'proto-tool--called': line.state === 'called',
@@ -252,6 +361,31 @@ onBeforeUnmount(() => {
{{ line.detail }} {{ line.detail }}
</p> </p>
</div> </div>
<!-- 日程小卡片 -->
<div
v-else-if="line.type === 'schedule_card'"
class="proto-schedule-card"
@click="openScheduleModal"
>
<div class="proto-schedule-card__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="proto-schedule-card__content">
<h4 class="proto-schedule-card__summary">{{ line.summary }}</h4>
<p class="proto-schedule-card__detail">{{ line.detail }}</p>
</div>
<div class="proto-schedule-card__arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
</div>
</template> </template>
<p v-if="enhancedLines.length <= 0" class="proto-line proto-line--placeholder">正在生成回复...</p> <p v-if="enhancedLines.length <= 0" class="proto-line proto-line--placeholder">正在生成回复...</p>
@@ -260,6 +394,105 @@ onBeforeUnmount(() => {
</div> </div>
</article> </article>
</section> </section>
<!-- 日程编排弹窗 -->
<Teleport to="body">
<Transition name="modal">
<div v-if="isScheduleModalVisible" class="schedule-modal-overlay" @click.self="closeScheduleModal">
<div class="schedule-modal">
<header class="schedule-modal__header">
<h3>日程预览与精排 ( {{ currentWeek }} )</h3>
<div class="schedule-modal__header-actions">
<div class="week-switcher">
<button class="week-switcher__btn" @click="prevWeek" :disabled="currentWeek <= 1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span class="week-switcher__label"> {{ currentWeek }} </span>
<button class="week-switcher__btn" @click="nextWeek" :disabled="currentWeek >= 20">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<button class="schedule-modal__close" @click="closeScheduleModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</header>
<div class="schedule-modal__body">
<div class="planning-board__grid">
<!-- 避让左上角 -->
<div class="planning-board__corner" />
<!-- 表头周一到周日 -->
<div v-for="d in 7" :key="`h-${d}`" class="planning-board__day-head">
<span>{{ ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][d-1] }}</span>
<small>{{ d + 18 }}</small>
</div>
<!-- 表身6 个节次每节次两行 -->
<template v-for="slot in sectionSlots" :key="`r-${slot.order}`">
<div class="planning-board__time-cell">
<strong>{{ slot.title }}</strong>
<small>{{ slot.timeRange }}</small>
</div>
<div
v-for="d in 7"
:key="`c-${d}-${slot.order}`"
class="planning-board__cell"
:class="{
'planning-board__cell--empty': !mockSchedule.some(i => i.day === d && i.order === slot.order),
'board-item-pop': mockSchedule.some(i => i.day === d && i.order === slot.order)
}"
@dragover.prevent
@drop="onDrop($event, d, slot.order)"
>
<div
v-for="item in mockSchedule.filter(i => i.day === d && i.order === slot.order)"
:key="item.id"
class="planning-board__cell-main"
:class="`planning-board__cell-main--${item.type}`"
draggable="true"
@dragstart="onDragStart($event, item)"
>
<strong>{{ item.name }}</strong>
<span>{{ item.type === 'course' ? '教学楼 A' : '个人任务' }}</span>
</div>
</div>
</template>
</div>
</div>
<footer class="schedule-modal__footer">
<p class="schedule-modal__hint">提示拖动卡片可调整日程顺序</p>
<div class="schedule-modal__actions">
<button class="tool-compare-proto__btn tool-compare-proto__btn--ghost" @click="closeScheduleModal">取消</button>
<button
class="tool-compare-proto__btn tool-compare-proto__btn--state"
:disabled="isSaving"
@click="handleSaveToState"
>
暂存进state
</button>
<button
class="tool-compare-proto__btn tool-compare-proto__btn--primary"
:disabled="isSaving"
@click="handleOfficialSave"
>
{{ isSaving ? '保存中...' : '正式保存日程' }}
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
</main> </main>
</template> </template>
@@ -345,6 +578,18 @@ onBeforeUnmount(() => {
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2); box-shadow: 0 6px 16px rgba(15, 23, 42, 0.2);
} }
.tool-compare-proto__btn--state {
background: #eff6ff;
color: #2563eb;
border-color: #dbeafe;
}
.tool-compare-proto__btn--state:hover {
background: #dbeafe;
color: #1e40af;
border-color: #bfdbfe;
}
.tool-compare-proto__btn--ghost { .tool-compare-proto__btn--ghost {
background: #ffffff; background: #ffffff;
color: #475569; color: #475569;
@@ -621,4 +866,449 @@ onBeforeUnmount(() => {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* 新增:日程小卡片样式 */
.proto-schedule-card {
margin-top: 10px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
animation: line-fade-in 0.5s ease-out forwards;
animation-delay: 0.1s;
opacity: 0;
}
.proto-schedule-card:hover {
transform: translateY(-4px) scale(1.02);
border-color: #3b82f6;
box-shadow: 0 12px 24px -8px rgba(59, 130, 246, 0.15);
}
.proto-schedule-card__icon {
width: 44px;
height: 44px;
background: #eff6ff;
color: #3b82f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.proto-schedule-card__content {
flex: 1;
}
.proto-schedule-card__summary {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
}
.proto-schedule-card__detail {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.4;
}
.proto-schedule-card__arrow {
color: #94a3b8;
transition: transform 0.2s;
}
.proto-schedule-card:hover .proto-schedule-card__arrow {
color: #3b82f6;
transform: translateX(4px);
}
/* 弹窗核心样式 */
.schedule-modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(8px);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.schedule-modal {
background: #ffffff;
width: min(1400px, 92%);
height: auto;
max-height: 95vh;
border-radius: 20px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.schedule-modal__header {
padding: 20px 32px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.schedule-modal__header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.week-switcher {
display: flex;
align-items: center;
gap: 12px;
background: #f8fafc;
padding: 4px;
border-radius: 12px;
border: 1px solid #f1f5f9;
}
.week-switcher__btn {
width: 32px;
height: 32px;
border: none;
background: #ffffff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.week-switcher__btn:hover:not(:disabled) {
background: #eff6ff;
color: #3b82f6;
transform: translateY(-1px);
}
.week-switcher__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.week-switcher__label {
font-size: 14px;
font-weight: 700;
color: #1e293b;
min-width: 60px;
text-align: center;
}
.schedule-modal__header h3 {
margin: 0;
font-size: 18px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.schedule-modal__close {
background: #f1f5f9;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.schedule-modal__close:hover {
background: #e2e8f0;
color: #1e293b;
transform: rotate(90deg);
}
.schedule-modal__body {
padding: 0; /* 这里的 padding 让 grid 自己处理,保持 full-bleed 效果 */
flex: 1;
overflow-y: auto;
}
.schedule-modal__footer {
padding: 20px 32px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.schedule-modal__hint {
margin: 0;
font-size: 13px;
color: #94a3b8;
font-style: italic;
}
.schedule-modal__actions {
display: flex;
gap: 12px;
}
/* 基准样式:同步自 WeekPlanningBoard.vue */
.planning-board__grid {
--planning-grid-padding-x: 20px;
--planning-grid-padding-y: 16px;
--planning-grid-gap-x: 10px;
--planning-grid-gap-y: 10px;
--planning-time-column-width: 76px;
--planning-day-column-min: 96px;
--planning-cell-height: 82px;
display: grid;
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 32px;
overflow: auto;
background: #ffffff;
}
.planning-board__corner {
min-height: 1px;
}
.planning-board__day-head {
display: grid;
justify-items: center;
gap: 4px;
color: #64748b;
padding-bottom: 12px;
}
.planning-board__day-head span {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.08em;
color: #1e293b;
}
.planning-board__day-head small {
font-size: 12px;
color: #94a3b8;
}
.planning-board__time-cell {
min-height: var(--planning-cell-height);
display: grid;
align-content: center;
justify-items: end;
color: #94a3b8;
padding-right: 16px;
border-right: 1px solid #f1f5f9;
}
.planning-board__time-cell strong {
font-size: 15px;
color: #475569;
font-weight: 800;
}
.planning-board__time-cell small {
font-size: 11px;
color: #94a3b8;
white-space: pre-line;
text-align: right;
line-height: 1.35;
}
.planning-board__cell {
position: relative;
min-height: var(--planning-cell-height);
border-radius: 16px;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #f8fafc;
}
.planning-board__cell--empty {
background: #ffffff;
border: 1px dashed #e2e8f0;
}
.planning-board__cell:hover:not(.planning-board__cell--empty) {
transform: translateY(-4px);
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.planning-board__cell-main {
width: 100%;
height: 100%;
padding: 12px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
gap: 6px;
border-radius: 16px;
cursor: grab;
}
.planning-board__cell-main:active {
cursor: grabbing;
}
.planning-board__cell-main strong {
font-size: 13px;
font-weight: 700;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.planning-board__cell-main span {
font-size: 11px;
opacity: 0.8;
}
/* 颜色系统 */
.planning-board__cell-main--course {
background: #e0f2fe;
color: #0369a1;
}
.planning-board__cell-main--task {
background: #dcfce7;
color: #15803d;
}
/* 进场动画 */
@keyframes board-item-spring {
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.board-item-pop {
animation: board-item-spring 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* 弹窗动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.4s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .schedule-modal {
animation: modal-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-leave-active .schedule-modal {
animation: modal-in 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse;
}
@keyframes modal-in {
from {
transform: scale(0.95) translateY(30px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
</style>
<!-- 全局样式用于拦截挂载在 body 上的弹窗组件 -->
<style>
.premium-msg-box {
--el-messagebox-width: 420px;
border-radius: 24px !important;
border: 1px solid rgba(255, 255, 255, 0.6) !important;
padding: 12px !important;
background: rgba(255, 255, 255, 0.85) !important;
backdrop-filter: blur(25px) saturate(180%) !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.premium-msg-box .el-message-box__header {
padding: 24px 28px 10px !important;
}
.premium-msg-box .el-message-box__title {
font-size: 20px !important;
font-weight: 900 !important;
color: #0f172a !important;
letter-spacing: -0.02em !important;
}
.premium-msg-box .el-message-box__content {
padding: 10px 28px 24px !important;
color: #64748b !important;
font-size: 14px !important;
line-height: 1.7 !important;
}
.premium-msg-box .el-message-box__btns {
padding: 16px 24px 24px !important;
}
.premium-msg-box .el-button {
height: 44px !important;
padding: 0 24px !important;
border-radius: 14px !important;
font-weight: 700 !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
font-size: 14px !important;
}
.premium-msg-box .el-button--primary {
background: #0f172a !important;
border: none !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2) !important;
}
.premium-msg-box .el-button--primary:hover {
background: #1e293b !important;
transform: translateY(-2px) !important;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.25) !important;
}
.premium-msg-box .el-button--default:not(.el-button--primary) {
background: #f1f5f9 !important;
border: none !important;
color: #475569 !important;
}
.premium-msg-box .el-button--default:not(.el-button--primary):hover {
background: #e2e8f0 !important;
color: #1e293b !important;
}
</style> </style>