diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 6400a43..c9aba16 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -152,8 +152,6 @@ memory: preferenceLimit: 5 # fact 类型最大注入条数。 factLimit: 5 - # todo_hint 类型最大注入条数。 - todoHintLimit: 3 inject: # 注入渲染模式: # flat 为旧扁平列表;typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。 diff --git a/backend/conv/task-class.go b/backend/conv/task-class.go index 63476cd..557ed73 100644 --- a/backend/conv/task-class.go +++ b/backend/conv/task-class.go @@ -128,6 +128,7 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model. req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items)) for _, item := range taskClass.Items { itemReq := model.UserAddTaskClassItemRequest{ + ID: item.ID, // 填充数据库主键 ID,前端拖拽编排依赖此字段 Order: safeInt(item.Order), Content: safeStr(item.Content), EmbeddedTime: item.EmbeddedTime, // 结构体指针直接复用 diff --git a/backend/memory/model/config.go b/backend/memory/model/config.go index 810b616..6a8ceb7 100644 --- a/backend/memory/model/config.go +++ b/backend/memory/model/config.go @@ -22,8 +22,6 @@ const ( DefaultReadPreferenceLimit = 5 // DefaultReadFactLimit 是 fact 默认预算上限。 DefaultReadFactLimit = 5 - // DefaultReadTodoHintLimit 是 todo_hint 默认预算上限。 - DefaultReadTodoHintLimit = 3 ) // Config 是记忆模块配置对象(Day1 首版)。 @@ -39,7 +37,6 @@ type Config struct { ReadConstraintLimit int ReadPreferenceLimit int ReadFactLimit int - ReadTodoHintLimit int InjectRenderMode string ExtractPrompt string @@ -112,11 +109,6 @@ func (c Config) EffectiveReadFactLimit() int { return normalizePositiveLimit(c.ReadFactLimit, DefaultReadFactLimit) } -// EffectiveReadTodoHintLimit 返回 todo_hint 生效预算。 -func (c Config) EffectiveReadTodoHintLimit() int { - return normalizePositiveLimit(c.ReadTodoHintLimit, DefaultReadTodoHintLimit) -} - // EffectiveReadMode 返回生效读取模式。 func (c Config) EffectiveReadMode() string { return NormalizeReadMode(c.ReadMode) @@ -127,12 +119,11 @@ func (c Config) EffectiveInjectRenderMode() string { return NormalizeInjectRenderMode(c.InjectRenderMode) } -// TotalReadBudget 返回四类记忆的总预算上限。 +// TotalReadBudget 返回三类记忆的总预算上限。 func (c Config) TotalReadBudget() int { return c.EffectiveReadConstraintLimit() + c.EffectiveReadPreferenceLimit() + - c.EffectiveReadFactLimit() + - c.EffectiveReadTodoHintLimit() + c.EffectiveReadFactLimit() } func normalizePositiveLimit(value int, defaultValue int) int { diff --git a/backend/memory/model/status.go b/backend/memory/model/status.go index 642322c..7a0e4b3 100644 --- a/backend/memory/model/status.go +++ b/backend/memory/model/status.go @@ -9,8 +9,6 @@ const ( MemoryTypeConstraint = "constraint" // MemoryTypeFact 表示一般事实类记忆。 MemoryTypeFact = "fact" - // MemoryTypeTodoHint 表示近期待办线索类记忆。 - MemoryTypeTodoHint = "todo_hint" ) const ( @@ -28,7 +26,6 @@ var validMemoryTypes = map[string]struct{}{ MemoryTypePreference: {}, MemoryTypeConstraint: {}, MemoryTypeFact: {}, - MemoryTypeTodoHint: {}, } var validDecisionActions = map[string]struct{}{ diff --git a/backend/memory/orchestrator/llm_write_orchestrator.go b/backend/memory/orchestrator/llm_write_orchestrator.go index 648df72..ca15fa1 100644 --- a/backend/memory/orchestrator/llm_write_orchestrator.go +++ b/backend/memory/orchestrator/llm_write_orchestrator.go @@ -133,7 +133,7 @@ func buildMemoryExtractSystemPrompt(override string) string { “message_intent”: “chitchat|task_request|knowledge_qa|preference|personal_fact|standing_instruction”, “facts”: [ { - “memory_type”: “preference|constraint|fact|todo_hint”, + “memory_type”: “preference|constraint|fact”, “title”: “短标题”, “content”: “完整事实内容”, “confidence”: 0.0, @@ -303,8 +303,6 @@ func defaultImportanceByType(memoryType string) float64 { return 0.85 case memorymodel.MemoryTypeConstraint: return 0.95 - case memorymodel.MemoryTypeTodoHint: - return 0.8 default: return 0.6 } diff --git a/backend/memory/service/config_loader.go b/backend/memory/service/config_loader.go index 7301c2c..26f217d 100644 --- a/backend/memory/service/config_loader.go +++ b/backend/memory/service/config_loader.go @@ -31,7 +31,6 @@ func LoadConfigFromViper() memorymodel.Config { ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"), ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"), ReadFactLimit: viper.GetInt("memory.read.factLimit"), - ReadTodoHintLimit: viper.GetInt("memory.read.todoHintLimit"), // 决策层配置:默认关闭,灰度开启后才会生效。 DecisionEnabled: viper.GetBool("memory.decision.enabled"), @@ -64,7 +63,6 @@ func LoadConfigFromViper() memorymodel.Config { cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit() cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit() cfg.ReadFactLimit = cfg.EffectiveReadFactLimit() - cfg.ReadTodoHintLimit = cfg.EffectiveReadTodoHintLimit() cfg.ReadMode = cfg.EffectiveReadMode() cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode() diff --git a/backend/memory/service/read_service.go b/backend/memory/service/read_service.go index 16d358f..4302057 100644 --- a/backend/memory/service/read_service.go +++ b/backend/memory/service/read_service.go @@ -224,7 +224,6 @@ func normalizeRetrieveMemoryTypes(raw []string) []string { return []string{ memorymodel.MemoryTypeConstraint, memorymodel.MemoryTypePreference, - memorymodel.MemoryTypeTodoHint, memorymodel.MemoryTypeFact, } } @@ -297,8 +296,6 @@ func scoreRetrievedItem(item model.MemoryItem, now time.Time) float64 { score += 0.12 case memorymodel.MemoryTypePreference: score += 0.08 - case memorymodel.MemoryTypeTodoHint: - score += 0.05 } return score } diff --git a/backend/memory/service/retrieve_merge.go b/backend/memory/service/retrieve_merge.go index c7c50e9..8bb471f 100644 --- a/backend/memory/service/retrieve_merge.go +++ b/backend/memory/service/retrieve_merge.go @@ -267,7 +267,6 @@ func applyTypeBudget(items []memorymodel.ItemDTO, cfg memorymodel.Config, caller memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(), memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(), memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(), - memorymodel.MemoryTypeTodoHint: cfg.EffectiveReadTodoHintLimit(), } usedByType := make(map[string]int, len(budgetByType)) result := make([]memorymodel.ItemDTO, 0, minInt(len(items), hardCap)) @@ -306,8 +305,6 @@ func renderMemoryTypeLabelForDedup(memoryType string) string { return "偏好" case memorymodel.MemoryTypeConstraint: return "约束" - case memorymodel.MemoryTypeTodoHint: - return "待办线索" case memorymodel.MemoryTypeFact: return "事实" default: diff --git a/backend/memory/service/retrieve_rank.go b/backend/memory/service/retrieve_rank.go index d1131e4..e727b65 100644 --- a/backend/memory/service/retrieve_rank.go +++ b/backend/memory/service/retrieve_rank.go @@ -47,8 +47,6 @@ func scoreRankedItem(item memorymodel.ItemDTO, now time.Time) float64 { score += 0.15 case memorymodel.MemoryTypePreference: score += 0.10 - case memorymodel.MemoryTypeTodoHint: - score += 0.05 } return score } diff --git a/backend/memory/utils/normalize_facts.go b/backend/memory/utils/normalize_facts.go index 3dc9b01..9a1999b 100644 --- a/backend/memory/utils/normalize_facts.go +++ b/backend/memory/utils/normalize_facts.go @@ -119,8 +119,6 @@ func defaultImportanceByType(memoryType string) float64 { return 0.85 case memorymodel.MemoryTypeConstraint: return 0.95 - case memorymodel.MemoryTypeTodoHint: - return 0.8 default: return 0.6 } diff --git a/backend/memory/worker/runner.go b/backend/memory/worker/runner.go index 743c8ec..650ea0a 100644 --- a/backend/memory/worker/runner.go +++ b/backend/memory/worker/runner.go @@ -326,9 +326,6 @@ func (r *Runner) syncVectorDeletes(ctx context.Context, memoryIDs []int64) { func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time { switch memoryType { - case memorymodel.MemoryTypeTodoHint: - t := base.Add(30 * 24 * time.Hour) - return &t case memorymodel.MemoryTypeFact: t := base.Add(180 * 24 * time.Hour) return &t diff --git a/backend/model/memory.go b/backend/model/memory.go index e33d7da..9e84c11 100644 --- a/backend/model/memory.go +++ b/backend/model/memory.go @@ -47,7 +47,7 @@ type MemoryItem struct { AssistantID *string `gorm:"column:assistant_id;type:varchar(64);index:idx_memory_items_user_asst_run_status,priority:2;comment:助手ID"` RunID *string `gorm:"column:run_id;type:varchar(64);index:idx_memory_items_user_asst_run_status,priority:3;comment:运行ID"` - MemoryType string `gorm:"column:memory_type;type:varchar(32);not null;index:idx_memory_items_user_status_type,priority:3;index:idx_memory_items_user_type_hash,priority:2;comment:preference/constraint/fact/todo_hint"` + MemoryType string `gorm:"column:memory_type;type:varchar(32);not null;index:idx_memory_items_user_status_type,priority:3;index:idx_memory_items_user_type_hash,priority:2;comment:preference/constraint/fact"` Title string `gorm:"column:title;type:varchar(128);not null;comment:记忆标题"` Content string `gorm:"column:content;type:text;not null;comment:记忆内容"` diff --git a/backend/model/task-class.go b/backend/model/task-class.go index ba01fc2..48a4d6b 100644 --- a/backend/model/task-class.go +++ b/backend/model/task-class.go @@ -92,6 +92,7 @@ type UserAddTaskClassConfig struct { // UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分 type UserAddTaskClassItemRequest struct { + ID int `json:"id,omitempty"` // 任务块的数据库主键 ID(查询时返回,创建时可省略) Order int `json:"order" binding:"required,min=1"` Content string `json:"content" binding:"required"` EmbeddedTime *TargetTime `json:"embedded_time"` // 例: 2025-12-22 1-2节; nil 表示未安排 diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index 78d3c9b..c7241fd 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -112,6 +112,9 @@ type CommonState struct { // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 // 调用目的:graph 分支函数据此判断是否需要走 order_guard,非日程操作跳过守卫。 HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` + // UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。 + // 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。 + UsedQuickNote bool `json:"used_quick_note,omitempty"` // HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。 // 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。 HasScheduleChanges bool `json:"has_schedule_changes,omitempty"` @@ -226,6 +229,7 @@ func (s *CommonState) ResetForNextRun() { s.AllowReorder = false s.HasScheduleWriteOps = false s.HasScheduleChanges = false + s.UsedQuickNote = false s.SuggestedOrderBaseline = nil s.ClearTerminalOutcome() } diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index d5130d1..14ae02c 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -395,6 +395,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error { // 3. 后端强制清空兜底,即使 LLM 误填了 speak 也不会推流到前端。 if decision.ToolCall != nil && strings.EqualFold(decision.ToolCall.Name, "quick_note_create") { decision.Speak = "" + flowState.UsedQuickNote = true } // 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。 diff --git a/backend/service/agentsvc/agent_memory_render.go b/backend/service/agentsvc/agent_memory_render.go index ba11b93..ab7b0d2 100644 --- a/backend/service/agentsvc/agent_memory_render.go +++ b/backend/service/agentsvc/agent_memory_render.go @@ -67,13 +67,11 @@ func RenderTypedMemoryContent(items []memorymodel.ItemDTO) string { memorymodel.MemoryTypeConstraint, memorymodel.MemoryTypePreference, memorymodel.MemoryTypeFact, - memorymodel.MemoryTypeTodoHint, } sectionTitle := map[string]string{ memorymodel.MemoryTypeConstraint: "必守约束", memorymodel.MemoryTypePreference: "用户偏好", memorymodel.MemoryTypeFact: "当前话题相关事实", - memorymodel.MemoryTypeTodoHint: "近期待办", } grouped := make(map[string][]string, len(orderedTypes)) @@ -149,8 +147,6 @@ func localizeMemoryType(memoryType string) string { return "偏好" case memorymodel.MemoryTypeConstraint: return "约束" - case memorymodel.MemoryTypeTodoHint: - return "待办线索" case memorymodel.MemoryTypeFact: return "事实" default: diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index a00281e..f65482e 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -108,7 +108,7 @@ func (s *AgentService) runNewAgentGraph( // 5.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。 // 5.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索; // 5.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆; - // 5.1.3 检索失败只降级为”本轮不注入记忆”,不阻断主链路。 + // 5.1.3 检索失败只降级为"本轮不注入记忆",不阻断主链路。 memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage) // 5.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。 @@ -250,6 +250,19 @@ func (s *AgentService) runNewAgentGraph( eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID) } + // 11.6. graph 完成后条件触发记忆抽取。 + // 说明: + // 1. 只有本轮未使用 quick_note_create 时才触发记忆抽取; + // 2. 避免随口记创建的 Task 与记忆系统产生语义冲突。 + if finalState != nil { + cs := finalState.EnsureRuntimeState().EnsureCommonState() + if cs == nil || !cs.UsedQuickNote { + if memErr := eventsvc.PublishMemoryExtractFromGraph(requestCtx, s.eventPublisher, userID, chatID, userMessage); memErr != nil { + log.Printf("[WARN] graph 完成后发布记忆抽取事件失败 trace=%s chat=%s err=%v", traceID, chatID, memErr) + } + } + } + // 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func), // 保证只有任务真正完成时才写,中断路径不写中间态。 @@ -310,7 +323,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone { terminalBefore := cs.TerminalStatus() roundBefore := cs.RoundUsed - // 1. 仅“正常完成(completed)”写 loop 收口 marker: + // 1. 仅"正常完成(completed)"写 loop 收口 marker: // 1.1 下一轮执行时,prompt 会把上一轮 loop 从 msg2 归档到 msg1; // 1.2 异常中断(aborted/exhausted)不写 marker,保留 msg2 便于后续续跑。 if terminalBefore == newagentmodel.FlowTerminalStatusCompleted { @@ -331,7 +344,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri originalScheduleState := snapshot.OriginalScheduleState if snapshot.ScheduleState != nil && originalScheduleState == nil { // 1. 兼容老快照:历史会话可能只存了 ScheduleState,没有 original 副本。 - // 2. 这里补一份克隆,保证后续节点拿到的仍是“恢复态 + 原始态”成对数据。 + // 2. 这里补一份克隆,保证后续节点拿到的仍是"恢复态 + 原始态"成对数据。 // 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。 originalScheduleState = snapshot.ScheduleState.Clone() } @@ -340,7 +353,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri return newRT() } -// appendExecuteLoopClosedMarker 在 ConversationContext 写入“上一轮 loop 正常收口”标记。 +// appendExecuteLoopClosedMarker 在 ConversationContext 写入"上一轮 loop 正常收口"标记。 // // 职责边界: // 1. 只追加轻量 marker 供 prompt 分层,不做历史摘要或裁剪; diff --git a/backend/service/events/chat_history_persist.go b/backend/service/events/chat_history_persist.go index ce53811..5f2ab5c 100644 --- a/backend/service/events/chat_history_persist.go +++ b/backend/service/events/chat_history_persist.go @@ -13,7 +13,7 @@ import ( ) const ( - // EventTypeChatHistoryPersistRequested 是“聊天消息持久化请求”的业务事件类型。 + // EventTypeChatHistoryPersistRequested 是"聊天消息持久化请求"的业务事件类型。 // // 命名策略: // 1. 只描述业务语义,不包含 outbox/kafka 等实现词; @@ -22,12 +22,12 @@ const ( EventTypeChatHistoryPersistRequested = "chat.history.persist.requested" ) -// RegisterChatHistoryPersistHandler 注册“聊天消息持久化”消费者处理器。 +// RegisterChatHistoryPersistHandler 注册"聊天消息持久化"消费者处理器。 // // 职责边界: // 1. 只负责聊天事件,不处理其他业务事件; // 2. 只负责注册,不负责总线启停; -// 3. 通过 outbox 通用事务入口把“业务写入 + consumed 推进”合并为一个事务; +// 3. 通过 outbox 通用事务入口把"业务写入 + consumed 推进"合并为一个事务; // 4. 当前版本仅注册新路由键(chat.history.persist.requested),不再注册旧兼容键。 func RegisterChatHistoryPersistHandler( bus *outboxinfra.EventBus, @@ -44,7 +44,6 @@ func RegisterChatHistoryPersistHandler( if repoManager == nil { return errors.New("repo manager is nil") } - kafkaCfg := kafkabus.LoadConfig() // 2. 定义统一处理器: // 2.1 解析 payload; @@ -58,12 +57,12 @@ func RegisterChatHistoryPersistHandler( return nil } - // 2.2 使用 outbox 通用消费事务,保证“业务写入 + consumed 状态推进”原子一致。 + // 2.2 使用 outbox 通用消费事务,保证"业务写入 + consumed 状态推进"原子一致。 return outboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, func(tx *gorm.DB) error { // 2.2.1 基于同一个 tx 构造 RepoManager,复用你现有跨包事务模型。 txM := repoManager.WithTx(tx) // 2.2.2 在同事务内写入聊天历史与会话计数。 - if err := txM.Agent.SaveChatHistoryInTx( + return txM.Agent.SaveChatHistoryInTx( ctx, payload.UserID, payload.ConversationID, @@ -72,19 +71,6 @@ func RegisterChatHistoryPersistHandler( payload.ReasoningContent, payload.ReasoningDurationSeconds, payload.TokensConsumed, - ); err != nil { - return err - } - - // 2.2.3 Day1 追加“记忆抽取请求”事件入队: - // 1) 仅对 user 消息投递,避免把助手回复重复喂给抽取链路; - // 2) 与聊天落库放在同一事务,保证“消息存在 -> 事件一定可追踪”; - // 3) 若入队失败,整体回滚并触发 outbox 重试,不留半成功状态。 - return EnqueueMemoryExtractRequestedInTx( - ctx, - outboxRepo.WithTx(tx), - kafkaCfg, - payload, ) }) } @@ -97,7 +83,7 @@ func RegisterChatHistoryPersistHandler( return nil } -// PublishChatHistoryPersistRequested 发布“聊天消息持久化请求”事件。 +// PublishChatHistoryPersistRequested 发布"聊天消息持久化请求"事件。 // // 设计目的: // 1. 让业务层只传 DTO,不重复拼事件元数据; diff --git a/backend/service/events/memory_extract_requested.go b/backend/service/events/memory_extract_requested.go index cfc5d51..7568117 100644 --- a/backend/service/events/memory_extract_requested.go +++ b/backend/service/events/memory_extract_requested.go @@ -125,6 +125,51 @@ func EnqueueMemoryExtractRequestedInTx( return err } +// PublishMemoryExtractFromGraph 在 graph 完成后直接发布记忆抽取事件。 +// +// 设计目的: +// 1. 绕过 chat-persist 链路,由 agent service 在 graph 完成后按需调用; +// 2. 内部完成 source text 截断、幂等 key 生成、memory 开关检查; +// 3. 发布失败只记日志,不阻断主链路。 +func PublishMemoryExtractFromGraph( + ctx context.Context, + publisher outboxinfra.EventPublisher, + userID int, + conversationID string, + sourceText string, +) error { + if !isMemoryWriteEnabled() { + return nil + } + if publisher == nil { + return errors.New("event publisher is nil") + } + + sourceText = strings.TrimSpace(sourceText) + if sourceText == "" || userID <= 0 || strings.TrimSpace(conversationID) == "" { + return nil + } + + truncated := truncateByRune(sourceText, maxMemorySourceTextLength) + now := time.Now() + payload := model.MemoryExtractRequestedPayload{ + UserID: userID, + ConversationID: strings.TrimSpace(conversationID), + SourceRole: "user", + SourceText: truncated, + OccurredAt: now, + IdempotencyKey: buildMemoryExtractIdempotencyKey(userID, conversationID, truncated), + } + + return publisher.Publish(ctx, outboxinfra.PublishRequest{ + EventType: EventTypeMemoryExtractRequested, + EventVersion: outboxinfra.DefaultEventVersion, + MessageKey: payload.ConversationID, + AggregateID: payload.ConversationID, + Payload: payload, + }) +} + func buildMemoryExtractPayloadFromChat(chatPayload model.ChatHistoryPersistPayload) (model.MemoryExtractRequestedPayload, bool) { role := strings.ToLower(strings.TrimSpace(chatPayload.Role)) if role != "user" { diff --git a/frontend/src/components/schedule/TaskClassSidebar.vue b/frontend/src/components/schedule/TaskClassSidebar.vue index be1302e..e4cdc3e 100644 --- a/frontend/src/components/schedule/TaskClassSidebar.vue +++ b/frontend/src/components/schedule/TaskClassSidebar.vue @@ -11,6 +11,7 @@ const props = defineProps<{ expandedTaskClassDetail: TaskClassDetail | null selectedTaskClassIds: number[] taskClassMultiSelectMode: boolean + manualEditMode: boolean }>() const emit = defineEmits<{ @@ -35,14 +36,45 @@ function isSelected(taskClassId: number) { } function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_time']) { - if (!value?.date) { + if (!value && !(value as any)?._preview_week) { return '未安排' } + const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + + const weekNum = (value as any)?._preview_week + const dayNum = (value as any)?._day_of_week + + if (weekNum && dayNum) { + return `第${weekNum}周 ${weekDays[dayNum - 1]} ${value?.section_from || 0}-${value?.section_to || 0}节` + } + + if (!value?.date) return '未安排' + const date = new Date(value.date) + // getDay() 返回 0 (周日) 到 6 (周六)。 转换成我们的 1-7。 + const rawDay = date.getDay() + const displayDay = rawDay === 0 ? 6 : rawDay - 1 // 对应 weekDays 索引 + const month = `${date.getMonth() + 1}`.padStart(2, '0') const day = `${date.getDate()}`.padStart(2, '0') - return `${month}.${day} ${value.section_from}-${value.section_to}节` + return `${month}.${day} ${weekDays[displayDay]} ${value.section_from}-${value.section_to}节` +} + +function handleDragStart(item: TaskClassDetail['items'][number], dragEvent: DragEvent) { + if (!props.manualEditMode) return + + dragEvent.dataTransfer?.setData( + 'application/task-item', + JSON.stringify({ + id: item.id, + content: item.content, + taskClassId: props.expandedTaskClassId, + }), + ) + if (dragEvent.dataTransfer) { + dragEvent.dataTransfer.effectAllowed = 'move' + } } function syncViewportHeight() { @@ -181,6 +213,9 @@ watch( v-for="item in expandedTaskClassDetail.items" :key="item.order" class="task-class-card__detail-item" + :class="{ 'task-class-card__detail-item--draggable': manualEditMode }" + :draggable="manualEditMode" + @dragstart="handleDragStart(item, $event)" > {{ item.order }} {{ item.content }} @@ -471,6 +506,17 @@ watch( align-items: center; } +.task-class-card__detail-item--draggable { + cursor: grab; + transition: all 0.2s; +} + +.task-class-card__detail-item--draggable:hover { + border-color: #3b82f6; + background: #f1f5f9; + transform: translateX(4px); +} + .task-class-card__detail-order { color: #17253d; font-weight: 700; diff --git a/frontend/src/components/schedule/WeekPlanningBoard.vue b/frontend/src/components/schedule/WeekPlanningBoard.vue index c8d53c3..990b7b8 100644 --- a/frontend/src/components/schedule/WeekPlanningBoard.vue +++ b/frontend/src/components/schedule/WeekPlanningBoard.vue @@ -30,15 +30,20 @@ const props = defineProps<{ scheduleSelectionMode: boolean selectedScheduleEventIds: number[] previewDragEnabled: boolean + manualEditMode: boolean }>() const emit = defineEmits<{ toggleScheduleEvent: [eventId: number] movePreviewEvent: [payload: PreviewMovePayload] + dropTaskItem: [payload: { id: number; content: string; taskClassId: number; week: number; dayOfWeek: number; order: number }] + removeEvent: [payload: { id: number; type: string; status?: string; week: number; dayOfWeek: number; order: number }] }>() const draggingCellKey = ref(null) const dragOverCellKey = ref(null) +const isDraggingOverDeleteZone = ref(false) +const isExternalDragging = ref(false) const sectionSlots: SectionSlot[] = [ { order: 1, title: '1-2', timeRange: '08:00\n09:40' }, @@ -68,11 +73,12 @@ function isSelected(eventId: number) { } function hasEmbeddedTask(event?: ScheduleWeekEvent) { + const taskId = Number(event?.embedded_task_info?.id) return Boolean( event && event.type === 'course' && - event.embedded_task_info && - event.embedded_task_info.id > 0, + !isNaN(taskId) && + taskId > 0 ) } @@ -130,7 +136,7 @@ function resolveEmbeddedTaskName(event?: ScheduleWeekEvent) { // 2. 只有 preview 模式下的 suggested 条目才允许拖拽,正式课表与普通课程保持只读。 function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) { return Boolean( - props.previewDragEnabled && + (props.previewDragEnabled || props.manualEditMode) && !props.scheduleSelectionMode && event && event.status === 'suggested', @@ -147,7 +153,15 @@ function isEmbeddedSuggestedPreviewEvent(event?: ScheduleWeekEvent) { } function isWholeCellDraggable(event?: ScheduleWeekEvent) { - return Boolean(isSuggestedPreviewEvent(event) && !isEmbeddedSuggestedPreviewEvent(event)) + if (props.scheduleSelectionMode || !event) return false + + // 1. 建议块可拖拽 + if (event.status === 'suggested' && event.type !== 'course') return true + + // 2. 已安排的任务块,仅在手动编辑模式下可拖拽(用于删除/移动) + if (props.manualEditMode && event.type === 'task') return true + + return false } // canDropPreviewEvent 负责判断当前格子是否允许作为“拖拽目标”。 @@ -157,7 +171,11 @@ function isWholeCellDraggable(event?: ScheduleWeekEvent) { // 2. 课程格允许接收 suggested 任务,父组件会把它转换成“嵌入课程”的预览结构。 // 3. suggested 格本身也允许作为目标,用于交换两个建议任务的位置。 function canDropPreviewEvent(event?: ScheduleWeekEvent) { - if (!props.previewDragEnabled || props.scheduleSelectionMode) { + if (!props.manualEditMode && !props.previewDragEnabled) { + return false + } + + if (props.scheduleSelectionMode) { return false } @@ -178,18 +196,19 @@ function buildCellKey(dayOfWeek: number, order: number) { function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: DragEvent) { const event = resolveEvent(dayOfWeek, order) - if (!isSuggestedPreviewEvent(event) || !props.weekData) { + if (!isWholeCellDraggable(event) && !isEmbeddedSuggestedPreviewEvent(event)) { dragEvent.preventDefault() return } draggingCellKey.value = buildCellKey(dayOfWeek, order) dragOverCellKey.value = null + isExternalDragging.value = false dragEvent.dataTransfer?.setData( 'application/json', JSON.stringify({ - week: props.weekData.week, + week: props.weekData?.week ?? 0, sourceDayOfWeek: dayOfWeek, sourceOrder: order, }), @@ -200,10 +219,6 @@ function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: Dra } function handlePreviewDragOver(dayOfWeek: number, order: number, dragEvent: DragEvent) { - if (!draggingCellKey.value) { - return - } - const cellKey = buildCellKey(dayOfWeek, order) if (cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) { return @@ -211,17 +226,46 @@ function handlePreviewDragOver(dayOfWeek: number, order: number, dragEvent: Drag dragEvent.preventDefault() dragOverCellKey.value = cellKey + isDraggingOverDeleteZone.value = false if (dragEvent.dataTransfer) { dragEvent.dataTransfer.dropEffect = 'move' } } +function handleExternalDragOver(dragEvent: DragEvent) { + if (dragEvent.dataTransfer?.types.includes('application/task-item')) { + dragEvent.preventDefault() + isExternalDragging.value = true + } +} + function handlePreviewDrop(dayOfWeek: number, order: number, dragEvent: DragEvent) { - if (!draggingCellKey.value) { + const cellKey = buildCellKey(dayOfWeek, order) + + // 1. 处理从侧边栏拖入的任务块 + const taskItemData = dragEvent.dataTransfer?.getData('application/task-item') + if (taskItemData) { + try { + const payload = JSON.parse(taskItemData) + // 强制转换 ID 为数字,确保后续匹配逻辑一致 + if (payload.id) payload.id = Number(payload.id) + + dragEvent.preventDefault() + emit('dropTaskItem', { + ...payload, + week: props.weekData?.week ?? 0, + dayOfWeek, + order, + }) + } finally { + draggingCellKey.value = null + dragOverCellKey.value = null + isExternalDragging.value = false + } return } - const cellKey = buildCellKey(dayOfWeek, order) + // 2. 处理内部拖拽移动 const payloadText = dragEvent.dataTransfer?.getData('application/json') if (!payloadText || cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) { draggingCellKey.value = null @@ -253,9 +297,45 @@ function handlePreviewDrop(dayOfWeek: number, order: number, dragEvent: DragEven } } +function handleDragOverDeleteZone(dragEvent: DragEvent) { + if (draggingCellKey.value) { + dragEvent.preventDefault() + isDraggingOverDeleteZone.value = true + dragOverCellKey.value = null + } +} + +function handleDropOnDeleteZone(dragEvent: DragEvent) { + if (!draggingCellKey.value) return + + const payloadText = dragEvent.dataTransfer?.getData('application/json') + if (!payloadText) return + + try { + const payload = JSON.parse(payloadText) + const event = resolveEvent(payload.sourceDayOfWeek, payload.sourceOrder) + if (event) { + dragEvent.preventDefault() + emit('removeEvent', { + id: event.id, + type: event.type, + status: event.status, + week: payload.week, + dayOfWeek: payload.sourceDayOfWeek, + order: payload.sourceOrder, + }) + } + } finally { + draggingCellKey.value = null + isDraggingOverDeleteZone.value = false + } +} + function handlePreviewDragEnd() { draggingCellKey.value = null dragOverCellKey.value = null + isDraggingOverDeleteZone.value = false + isExternalDragging.value = false } @@ -265,7 +345,7 @@ function handlePreviewDragEnd() { {{ weekLabel }} -
+
@@ -290,6 +370,7 @@ function handlePreviewDragEnd() { 'planning-board__cell--selectable': scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty', 'planning-board__cell--selected': resolveEvent(header.dayOfWeek, slot.order) && isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id), 'planning-board__cell--draggable': isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order)), + 'planning-board__cell--suggested': isSuggestedPreviewEvent(resolveEvent(header.dayOfWeek, slot.order)), 'planning-board__cell--dragging': draggingCellKey === buildCellKey(header.dayOfWeek, slot.order), 'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order), }, @@ -302,10 +383,13 @@ function handlePreviewDragEnd() { @dragend="handlePreviewDragEnd" >
+
+ {{ resolveCellTitle(resolveEvent(header.dayOfWeek, slot.order)) }} + {{ resolveCellMeta(resolveEvent(header.dayOfWeek, slot.order)) }} +
+
+ + + +
+ + + + + + 在此处松开以解除安排 +
+
@@ -465,81 +576,89 @@ function handlePreviewDragEnd() { } .planning-board__cell--course { - background: #e0f2fe; + background: #f0f7ff; } .planning-board__cell--course-embedded { - background: #b9e6fe; + background: #f0f7ff; align-items: stretch; padding: 8px; } +.planning-board__cell--suggested { + outline: 2px dashed #3b82f6; + outline-offset: -2px; + background: #ffffff !important; + box-shadow: inset 0 0 0 100px #eff6ffaa; +} + +.planning-board__cell--course-embedded.planning-board__cell--suggested { + outline-color: #0284c7; + background: #f0f9ff !important; +} + .planning-board__cell--course .planning-board__cell-main strong, .planning-board__cell--course .planning-board__cell-main span { - color: #0284c7; + color: #0369a1; } .planning-board__embedded-shell { - display: grid; - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; + display: flex; + flex-direction: column; + gap: 4px; width: 100%; height: 100%; min-height: 0; - text-align: center; - overflow: hidden; -} - -.planning-board__embedded-course, -.planning-board__embedded-task { - display: flex; - align-items: center; - justify-content: center; - min-width: 0; - min-height: 0; - overflow: hidden; } .planning-board__embedded-course { - padding: 6px 4px; + padding: 2px 4px; + font-size: 13px; color: #0369a1; -} - -.planning-board__embedded-course strong, -.planning-board__embedded-task strong { - min-width: 0; + font-weight: 800; + white-space: nowrap; overflow: hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - white-space: normal; - overflow-wrap: anywhere; + text-overflow: ellipsis; text-align: center; } -.planning-board__embedded-course strong { - width: 100%; - font-size: 13px; - line-height: 1.28; - font-weight: 800; - -webkit-line-clamp: 2; -} - .planning-board__embedded-task { - padding: 6px 8px; - border-radius: 10px; + flex: 1; background: #ffffff; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + border: 1px solid rgba(15, 23, 42, 0.04); + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + min-height: 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } -.planning-board__embedded-task strong { - color: #0369a1; - font-size: 11px; - line-height: 1.24; - font-weight: 800; - -webkit-line-clamp: 2; +.planning-board__embedded-task:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12); + border-color: #3b82f6; } .planning-board__embedded-task-dragger { + font-size: 12px; + color: #334155; + font-weight: 700; + text-align: center; + padding: 2px 4px; + cursor: grab; width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.planning-board__embedded-task-dragger--active { + color: #3b82f6; } .planning-board__embedded-task-dragger--active { @@ -646,6 +765,60 @@ function handlePreviewDragEnd() { box-shadow: inset 0 0 0 3px #ffffff; } +.planning-board__checkbox--hidden { + display: none !important; +} + +/* 悬浮删除区样式 */ +.planning-board__delete-zone { + position: absolute; + left: 50%; + bottom: 80px; + transform: translateX(-50%); + z-index: 100; + width: 280px; + height: 64px; + border-radius: 32px; + background: rgba(239, 68, 68, 0.9); + backdrop-filter: blur(8px); + border: 2px dashed rgba(255, 255, 255, 0.4); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + box-shadow: 0 12px 32px rgba(239, 68, 68, 0.3); + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.planning-board__delete-zone--active { + background: #ef4444; + transform: translateX(-50%) scale(1.1); + box-shadow: 0 16px 48px rgba(239, 68, 68, 0.45); + border-style: solid; +} + +.delete-zone-icon { + animation: delete-icon-shake 1.5s infinite; +} + +@keyframes delete-icon-shake { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-10deg); } + 75% { transform: rotate(10deg); } +} + +.delete-zone-enter-active, +.delete-zone-leave-active { + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.delete-zone-enter-from, +.delete-zone-leave-to { + opacity: 0; + transform: translateX(-50%) translateY(40px) scale(0.8); +} + @keyframes board-item-spring { 0% { opacity: 0; transform: scale(0.6) translateY(20px); } 60% { opacity: 1; transform: scale(1.05) translateY(-2px); } diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 394c05d..f4ef3a4 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -218,6 +218,10 @@ async function handleLogout() { finally { logoutLoading.value = false; await router.push('/auth') } } +function handleCourseImportEntry() { + void router.push('/schedule') +} + function syncDashboardMainScale() { const main = dashboardMainRef.value const inner = dashboardMainInnerRef.value diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 8f5008d..6ea76b5 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -1,6 +1,6 @@