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:
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 嵌入与阻塞语义。
|
// 嵌入与阻塞语义。
|
||||||
|
|||||||
90
backend/newAgent/conv/schedule_state_apply.go
Normal file
90
backend/newAgent/conv/schedule_state_apply.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 注入,排程预览写入入口
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 后真实执行写工具时,立即刷新一次预览缓存。
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
backend/service/agentsvc/agent_schedule_state.go
Normal file
62
backend/service/agentsvc/agent_schedule_state.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
653
docs/frontend-schedule-integration.md
Normal file
653
docs/frontend-schedule-integration.md
Normal 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 }
|
||||||
|
```
|
||||||
54
frontend/src/api/schedule_agent.ts
Normal file
54
frontend/src/api/schedule_agent.ts
Normal 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, '保存方案失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
682
frontend/src/components/assistant/ScheduleFineTuneModal.vue
Normal file
682
frontend/src/components/assistant/ScheduleFineTuneModal.vue
Normal 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>
|
||||||
97
frontend/src/components/assistant/ScheduleResultCard.vue
Normal file
97
frontend/src/components/assistant/ScheduleResultCard.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user