Version: 0.9.39.dev.260423
后端: 1. 记忆系统移除 todo_hint 类型——随口记已由 Task 系统承接,todo_hint 语义重叠且无完成追踪 - 全链路清理:常量、校验、默认重要度、30 天 TTL、读取预算、LLM 抽取提示词枚举 - 总预算从四类收缩为三类(preference / constraint / fact) 2. 记忆抽取触发点从 chat-persist 移至 graph-completion——避免随口记消息被误提取为 constraint/preference - chat-persist consumer 不再自动入队 memory.extract.requested,仅负责聊天历史落库 - graph 完成后新增条件发布:检测 UsedQuickNote 标记,调用过 quick_note_create 则跳过记忆抽取 - ResetForNextRun 重置 UsedQuickNote,防止跨轮残留导致后续正常消息记忆抽取被误跳过 3. 任务类查询接口返回 items 补充数据库主键 ID(前端拖拽编排依赖此字段) 前端: 4. 排程视图新增手动编排模式——侧边栏任务块拖拽入周课表 + 悬浮删除热区 + 建议块虚线标识 - TaskClassSidebar 拖拽发起 + 预览态嵌入时间格式化(含周次/星期) - WeekPlanningBoard 外部拖入 / 内部移动 / 悬浮删除区交互 - ScheduleView 手动编排状态机(进入/退出/取消/覆盖确认)+ apply 时同步处理新增与删除
This commit is contained in:
@@ -152,8 +152,6 @@ memory:
|
|||||||
preferenceLimit: 5
|
preferenceLimit: 5
|
||||||
# fact 类型最大注入条数。
|
# fact 类型最大注入条数。
|
||||||
factLimit: 5
|
factLimit: 5
|
||||||
# todo_hint 类型最大注入条数。
|
|
||||||
todoHintLimit: 3
|
|
||||||
inject:
|
inject:
|
||||||
# 注入渲染模式:
|
# 注入渲染模式:
|
||||||
# flat 为旧扁平列表;typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。
|
# flat 为旧扁平列表;typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
|
|||||||
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))
|
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))
|
||||||
for _, item := range taskClass.Items {
|
for _, item := range taskClass.Items {
|
||||||
itemReq := model.UserAddTaskClassItemRequest{
|
itemReq := model.UserAddTaskClassItemRequest{
|
||||||
|
ID: item.ID, // 填充数据库主键 ID,前端拖拽编排依赖此字段
|
||||||
Order: safeInt(item.Order),
|
Order: safeInt(item.Order),
|
||||||
Content: safeStr(item.Content),
|
Content: safeStr(item.Content),
|
||||||
EmbeddedTime: item.EmbeddedTime, // 结构体指针直接复用
|
EmbeddedTime: item.EmbeddedTime, // 结构体指针直接复用
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ const (
|
|||||||
DefaultReadPreferenceLimit = 5
|
DefaultReadPreferenceLimit = 5
|
||||||
// DefaultReadFactLimit 是 fact 默认预算上限。
|
// DefaultReadFactLimit 是 fact 默认预算上限。
|
||||||
DefaultReadFactLimit = 5
|
DefaultReadFactLimit = 5
|
||||||
// DefaultReadTodoHintLimit 是 todo_hint 默认预算上限。
|
|
||||||
DefaultReadTodoHintLimit = 3
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 是记忆模块配置对象(Day1 首版)。
|
// Config 是记忆模块配置对象(Day1 首版)。
|
||||||
@@ -39,7 +37,6 @@ type Config struct {
|
|||||||
ReadConstraintLimit int
|
ReadConstraintLimit int
|
||||||
ReadPreferenceLimit int
|
ReadPreferenceLimit int
|
||||||
ReadFactLimit int
|
ReadFactLimit int
|
||||||
ReadTodoHintLimit int
|
|
||||||
InjectRenderMode string
|
InjectRenderMode string
|
||||||
|
|
||||||
ExtractPrompt string
|
ExtractPrompt string
|
||||||
@@ -112,11 +109,6 @@ func (c Config) EffectiveReadFactLimit() int {
|
|||||||
return normalizePositiveLimit(c.ReadFactLimit, DefaultReadFactLimit)
|
return normalizePositiveLimit(c.ReadFactLimit, DefaultReadFactLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveReadTodoHintLimit 返回 todo_hint 生效预算。
|
|
||||||
func (c Config) EffectiveReadTodoHintLimit() int {
|
|
||||||
return normalizePositiveLimit(c.ReadTodoHintLimit, DefaultReadTodoHintLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EffectiveReadMode 返回生效读取模式。
|
// EffectiveReadMode 返回生效读取模式。
|
||||||
func (c Config) EffectiveReadMode() string {
|
func (c Config) EffectiveReadMode() string {
|
||||||
return NormalizeReadMode(c.ReadMode)
|
return NormalizeReadMode(c.ReadMode)
|
||||||
@@ -127,12 +119,11 @@ func (c Config) EffectiveInjectRenderMode() string {
|
|||||||
return NormalizeInjectRenderMode(c.InjectRenderMode)
|
return NormalizeInjectRenderMode(c.InjectRenderMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TotalReadBudget 返回四类记忆的总预算上限。
|
// TotalReadBudget 返回三类记忆的总预算上限。
|
||||||
func (c Config) TotalReadBudget() int {
|
func (c Config) TotalReadBudget() int {
|
||||||
return c.EffectiveReadConstraintLimit() +
|
return c.EffectiveReadConstraintLimit() +
|
||||||
c.EffectiveReadPreferenceLimit() +
|
c.EffectiveReadPreferenceLimit() +
|
||||||
c.EffectiveReadFactLimit() +
|
c.EffectiveReadFactLimit()
|
||||||
c.EffectiveReadTodoHintLimit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePositiveLimit(value int, defaultValue int) int {
|
func normalizePositiveLimit(value int, defaultValue int) int {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ const (
|
|||||||
MemoryTypeConstraint = "constraint"
|
MemoryTypeConstraint = "constraint"
|
||||||
// MemoryTypeFact 表示一般事实类记忆。
|
// MemoryTypeFact 表示一般事实类记忆。
|
||||||
MemoryTypeFact = "fact"
|
MemoryTypeFact = "fact"
|
||||||
// MemoryTypeTodoHint 表示近期待办线索类记忆。
|
|
||||||
MemoryTypeTodoHint = "todo_hint"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,7 +26,6 @@ var validMemoryTypes = map[string]struct{}{
|
|||||||
MemoryTypePreference: {},
|
MemoryTypePreference: {},
|
||||||
MemoryTypeConstraint: {},
|
MemoryTypeConstraint: {},
|
||||||
MemoryTypeFact: {},
|
MemoryTypeFact: {},
|
||||||
MemoryTypeTodoHint: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validDecisionActions = map[string]struct{}{
|
var validDecisionActions = map[string]struct{}{
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func buildMemoryExtractSystemPrompt(override string) string {
|
|||||||
“message_intent”: “chitchat|task_request|knowledge_qa|preference|personal_fact|standing_instruction”,
|
“message_intent”: “chitchat|task_request|knowledge_qa|preference|personal_fact|standing_instruction”,
|
||||||
“facts”: [
|
“facts”: [
|
||||||
{
|
{
|
||||||
“memory_type”: “preference|constraint|fact|todo_hint”,
|
“memory_type”: “preference|constraint|fact”,
|
||||||
“title”: “短标题”,
|
“title”: “短标题”,
|
||||||
“content”: “完整事实内容”,
|
“content”: “完整事实内容”,
|
||||||
“confidence”: 0.0,
|
“confidence”: 0.0,
|
||||||
@@ -303,8 +303,6 @@ func defaultImportanceByType(memoryType string) float64 {
|
|||||||
return 0.85
|
return 0.85
|
||||||
case memorymodel.MemoryTypeConstraint:
|
case memorymodel.MemoryTypeConstraint:
|
||||||
return 0.95
|
return 0.95
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
return 0.8
|
|
||||||
default:
|
default:
|
||||||
return 0.6
|
return 0.6
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func LoadConfigFromViper() memorymodel.Config {
|
|||||||
ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"),
|
ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"),
|
||||||
ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"),
|
ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"),
|
||||||
ReadFactLimit: viper.GetInt("memory.read.factLimit"),
|
ReadFactLimit: viper.GetInt("memory.read.factLimit"),
|
||||||
ReadTodoHintLimit: viper.GetInt("memory.read.todoHintLimit"),
|
|
||||||
|
|
||||||
// 决策层配置:默认关闭,灰度开启后才会生效。
|
// 决策层配置:默认关闭,灰度开启后才会生效。
|
||||||
DecisionEnabled: viper.GetBool("memory.decision.enabled"),
|
DecisionEnabled: viper.GetBool("memory.decision.enabled"),
|
||||||
@@ -64,7 +63,6 @@ func LoadConfigFromViper() memorymodel.Config {
|
|||||||
cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit()
|
cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit()
|
||||||
cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit()
|
cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit()
|
||||||
cfg.ReadFactLimit = cfg.EffectiveReadFactLimit()
|
cfg.ReadFactLimit = cfg.EffectiveReadFactLimit()
|
||||||
cfg.ReadTodoHintLimit = cfg.EffectiveReadTodoHintLimit()
|
|
||||||
cfg.ReadMode = cfg.EffectiveReadMode()
|
cfg.ReadMode = cfg.EffectiveReadMode()
|
||||||
cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode()
|
cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode()
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ func normalizeRetrieveMemoryTypes(raw []string) []string {
|
|||||||
return []string{
|
return []string{
|
||||||
memorymodel.MemoryTypeConstraint,
|
memorymodel.MemoryTypeConstraint,
|
||||||
memorymodel.MemoryTypePreference,
|
memorymodel.MemoryTypePreference,
|
||||||
memorymodel.MemoryTypeTodoHint,
|
|
||||||
memorymodel.MemoryTypeFact,
|
memorymodel.MemoryTypeFact,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,8 +296,6 @@ func scoreRetrievedItem(item model.MemoryItem, now time.Time) float64 {
|
|||||||
score += 0.12
|
score += 0.12
|
||||||
case memorymodel.MemoryTypePreference:
|
case memorymodel.MemoryTypePreference:
|
||||||
score += 0.08
|
score += 0.08
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
score += 0.05
|
|
||||||
}
|
}
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,6 @@ func applyTypeBudget(items []memorymodel.ItemDTO, cfg memorymodel.Config, caller
|
|||||||
memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(),
|
memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(),
|
||||||
memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(),
|
memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(),
|
||||||
memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(),
|
memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(),
|
||||||
memorymodel.MemoryTypeTodoHint: cfg.EffectiveReadTodoHintLimit(),
|
|
||||||
}
|
}
|
||||||
usedByType := make(map[string]int, len(budgetByType))
|
usedByType := make(map[string]int, len(budgetByType))
|
||||||
result := make([]memorymodel.ItemDTO, 0, minInt(len(items), hardCap))
|
result := make([]memorymodel.ItemDTO, 0, minInt(len(items), hardCap))
|
||||||
@@ -306,8 +305,6 @@ func renderMemoryTypeLabelForDedup(memoryType string) string {
|
|||||||
return "偏好"
|
return "偏好"
|
||||||
case memorymodel.MemoryTypeConstraint:
|
case memorymodel.MemoryTypeConstraint:
|
||||||
return "约束"
|
return "约束"
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
return "待办线索"
|
|
||||||
case memorymodel.MemoryTypeFact:
|
case memorymodel.MemoryTypeFact:
|
||||||
return "事实"
|
return "事实"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ func scoreRankedItem(item memorymodel.ItemDTO, now time.Time) float64 {
|
|||||||
score += 0.15
|
score += 0.15
|
||||||
case memorymodel.MemoryTypePreference:
|
case memorymodel.MemoryTypePreference:
|
||||||
score += 0.10
|
score += 0.10
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
score += 0.05
|
|
||||||
}
|
}
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ func defaultImportanceByType(memoryType string) float64 {
|
|||||||
return 0.85
|
return 0.85
|
||||||
case memorymodel.MemoryTypeConstraint:
|
case memorymodel.MemoryTypeConstraint:
|
||||||
return 0.95
|
return 0.95
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
return 0.8
|
|
||||||
default:
|
default:
|
||||||
return 0.6
|
return 0.6
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,9 +326,6 @@ func (r *Runner) syncVectorDeletes(ctx context.Context, memoryIDs []int64) {
|
|||||||
|
|
||||||
func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time {
|
func resolveMemoryTTLAt(base time.Time, memoryType string) *time.Time {
|
||||||
switch memoryType {
|
switch memoryType {
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
t := base.Add(30 * 24 * time.Hour)
|
|
||||||
return &t
|
|
||||||
case memorymodel.MemoryTypeFact:
|
case memorymodel.MemoryTypeFact:
|
||||||
t := base.Add(180 * 24 * time.Hour)
|
t := base.Add(180 * 24 * time.Hour)
|
||||||
return &t
|
return &t
|
||||||
|
|||||||
@@ -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"`
|
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"`
|
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:记忆标题"`
|
Title string `gorm:"column:title;type:varchar(128);not null;comment:记忆标题"`
|
||||||
Content string `gorm:"column:content;type:text;not null;comment:记忆内容"`
|
Content string `gorm:"column:content;type:text;not null;comment:记忆内容"`
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ type UserAddTaskClassConfig struct {
|
|||||||
|
|
||||||
// UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分
|
// UserAddTaskClassItemRequest 用于处理用户添加任务类别时的任务块部分
|
||||||
type UserAddTaskClassItemRequest struct {
|
type UserAddTaskClassItemRequest struct {
|
||||||
|
ID int `json:"id,omitempty"` // 任务块的数据库主键 ID(查询时返回,创建时可省略)
|
||||||
Order int `json:"order" binding:"required,min=1"`
|
Order int `json:"order" binding:"required,min=1"`
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
EmbeddedTime *TargetTime `json:"embedded_time"` // 例: 2025-12-22 1-2节; nil 表示未安排
|
EmbeddedTime *TargetTime `json:"embedded_time"` // 例: 2025-12-22 1-2节; 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"`
|
||||||
|
// UsedQuickNote 标记本轮是否调用过 quick_note_create 工具。
|
||||||
|
// 调用目的:graph 完成后据此决定是否跳过记忆抽取,避免随口记内容被错误归类。
|
||||||
|
UsedQuickNote bool `json:"used_quick_note,omitempty"`
|
||||||
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
|
// HasScheduleChanges 标记本轮流程是否产生过日程变更(粗排或写工具)。
|
||||||
// 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。
|
// 调用目的:deliver 节点据此判断是否向前端推送"排程完毕"卡片。
|
||||||
HasScheduleChanges bool `json:"has_schedule_changes,omitempty"`
|
HasScheduleChanges bool `json:"has_schedule_changes,omitempty"`
|
||||||
@@ -226,6 +229,7 @@ func (s *CommonState) ResetForNextRun() {
|
|||||||
s.AllowReorder = false
|
s.AllowReorder = false
|
||||||
s.HasScheduleWriteOps = false
|
s.HasScheduleWriteOps = false
|
||||||
s.HasScheduleChanges = false
|
s.HasScheduleChanges = false
|
||||||
|
s.UsedQuickNote = false
|
||||||
s.SuggestedOrderBaseline = nil
|
s.SuggestedOrderBaseline = nil
|
||||||
s.ClearTerminalOutcome()
|
s.ClearTerminalOutcome()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ func RunExecuteNode(ctx context.Context, input ExecuteNodeInput) error {
|
|||||||
// 3. 后端强制清空兜底,即使 LLM 误填了 speak 也不会推流到前端。
|
// 3. 后端强制清空兜底,即使 LLM 误填了 speak 也不会推流到前端。
|
||||||
if decision.ToolCall != nil && strings.EqualFold(decision.ToolCall.Name, "quick_note_create") {
|
if decision.ToolCall != nil && strings.EqualFold(decision.ToolCall.Name, "quick_note_create") {
|
||||||
decision.Speak = ""
|
decision.Speak = ""
|
||||||
|
flowState.UsedQuickNote = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。
|
// 自省校验:next_plan / done 必须附带 goal_check,否则不推进,追加修正让 LLM 重试。
|
||||||
|
|||||||
@@ -67,13 +67,11 @@ func RenderTypedMemoryContent(items []memorymodel.ItemDTO) string {
|
|||||||
memorymodel.MemoryTypeConstraint,
|
memorymodel.MemoryTypeConstraint,
|
||||||
memorymodel.MemoryTypePreference,
|
memorymodel.MemoryTypePreference,
|
||||||
memorymodel.MemoryTypeFact,
|
memorymodel.MemoryTypeFact,
|
||||||
memorymodel.MemoryTypeTodoHint,
|
|
||||||
}
|
}
|
||||||
sectionTitle := map[string]string{
|
sectionTitle := map[string]string{
|
||||||
memorymodel.MemoryTypeConstraint: "必守约束",
|
memorymodel.MemoryTypeConstraint: "必守约束",
|
||||||
memorymodel.MemoryTypePreference: "用户偏好",
|
memorymodel.MemoryTypePreference: "用户偏好",
|
||||||
memorymodel.MemoryTypeFact: "当前话题相关事实",
|
memorymodel.MemoryTypeFact: "当前话题相关事实",
|
||||||
memorymodel.MemoryTypeTodoHint: "近期待办",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped := make(map[string][]string, len(orderedTypes))
|
grouped := make(map[string][]string, len(orderedTypes))
|
||||||
@@ -149,8 +147,6 @@ func localizeMemoryType(memoryType string) string {
|
|||||||
return "偏好"
|
return "偏好"
|
||||||
case memorymodel.MemoryTypeConstraint:
|
case memorymodel.MemoryTypeConstraint:
|
||||||
return "约束"
|
return "约束"
|
||||||
case memorymodel.MemoryTypeTodoHint:
|
|
||||||
return "待办线索"
|
|
||||||
case memorymodel.MemoryTypeFact:
|
case memorymodel.MemoryTypeFact:
|
||||||
return "事实"
|
return "事实"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
// 5.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。
|
// 5.1. 在 graph 执行前统一补充与当前输入相关的记忆上下文(预取管线模式)。
|
||||||
// 5.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索;
|
// 5.1.1 先读 Redis 预取缓存注入到 ConversationContext,再启动后台 goroutine 做完整检索;
|
||||||
// 5.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆;
|
// 5.1.2 返回的 channel 传入 Deps,供 Execute/Plan 节点在启动前消费最新记忆;
|
||||||
// 5.1.3 检索失败只降级为”本轮不注入记忆”,不阻断主链路。
|
// 5.1.3 检索失败只降级为"本轮不注入记忆",不阻断主链路。
|
||||||
memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage)
|
memoryFuture := s.injectMemoryContext(requestCtx, conversationContext, userID, chatID, userMessage)
|
||||||
|
|
||||||
// 5.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。
|
// 5.5 将前端传入的 thinkingMode 写入 CommonState,供 ChatNode 及下游节点读取。
|
||||||
@@ -250,6 +250,19 @@ func (s *AgentService) runNewAgentGraph(
|
|||||||
eventsvc.PublishAgentStateSnapshot(requestCtx, s.eventPublisher, snapshot, chatID, userID)
|
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),
|
// 排程预览缓存由 Deliver 节点负责写入(通过注入的 WriteSchedulePreview func),
|
||||||
// 保证只有任务真正完成时才写,中断路径不写中间态。
|
// 保证只有任务真正完成时才写,中断路径不写中间态。
|
||||||
|
|
||||||
@@ -310,7 +323,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
|||||||
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
|
if !snapshot.RuntimeState.HasPendingInteraction() && cs.Phase == newagentmodel.PhaseDone {
|
||||||
terminalBefore := cs.TerminalStatus()
|
terminalBefore := cs.TerminalStatus()
|
||||||
roundBefore := cs.RoundUsed
|
roundBefore := cs.RoundUsed
|
||||||
// 1. 仅“正常完成(completed)”写 loop 收口 marker:
|
// 1. 仅"正常完成(completed)"写 loop 收口 marker:
|
||||||
// 1.1 下一轮执行时,prompt 会把上一轮 loop 从 msg2 归档到 msg1;
|
// 1.1 下一轮执行时,prompt 会把上一轮 loop 从 msg2 归档到 msg1;
|
||||||
// 1.2 异常中断(aborted/exhausted)不写 marker,保留 msg2 便于后续续跑。
|
// 1.2 异常中断(aborted/exhausted)不写 marker,保留 msg2 便于后续续跑。
|
||||||
if terminalBefore == newagentmodel.FlowTerminalStatusCompleted {
|
if terminalBefore == newagentmodel.FlowTerminalStatusCompleted {
|
||||||
@@ -331,7 +344,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
|||||||
originalScheduleState := snapshot.OriginalScheduleState
|
originalScheduleState := snapshot.OriginalScheduleState
|
||||||
if snapshot.ScheduleState != nil && originalScheduleState == nil {
|
if snapshot.ScheduleState != nil && originalScheduleState == nil {
|
||||||
// 1. 兼容老快照:历史会话可能只存了 ScheduleState,没有 original 副本。
|
// 1. 兼容老快照:历史会话可能只存了 ScheduleState,没有 original 副本。
|
||||||
// 2. 这里补一份克隆,保证后续节点拿到的仍是“恢复态 + 原始态”成对数据。
|
// 2. 这里补一份克隆,保证后续节点拿到的仍是"恢复态 + 原始态"成对数据。
|
||||||
// 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。
|
// 3. 即便当前阶段不落库,这里也保留一致性,避免下一轮再出现语义漂移。
|
||||||
originalScheduleState = snapshot.ScheduleState.Clone()
|
originalScheduleState = snapshot.ScheduleState.Clone()
|
||||||
}
|
}
|
||||||
@@ -340,7 +353,7 @@ func (s *AgentService) loadOrCreateRuntimeState(ctx context.Context, chatID stri
|
|||||||
return newRT()
|
return newRT()
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendExecuteLoopClosedMarker 在 ConversationContext 写入“上一轮 loop 正常收口”标记。
|
// appendExecuteLoopClosedMarker 在 ConversationContext 写入"上一轮 loop 正常收口"标记。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只追加轻量 marker 供 prompt 分层,不做历史摘要或裁剪;
|
// 1. 只追加轻量 marker 供 prompt 分层,不做历史摘要或裁剪;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// EventTypeChatHistoryPersistRequested 是“聊天消息持久化请求”的业务事件类型。
|
// EventTypeChatHistoryPersistRequested 是"聊天消息持久化请求"的业务事件类型。
|
||||||
//
|
//
|
||||||
// 命名策略:
|
// 命名策略:
|
||||||
// 1. 只描述业务语义,不包含 outbox/kafka 等实现词;
|
// 1. 只描述业务语义,不包含 outbox/kafka 等实现词;
|
||||||
@@ -22,12 +22,12 @@ const (
|
|||||||
EventTypeChatHistoryPersistRequested = "chat.history.persist.requested"
|
EventTypeChatHistoryPersistRequested = "chat.history.persist.requested"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterChatHistoryPersistHandler 注册“聊天消息持久化”消费者处理器。
|
// RegisterChatHistoryPersistHandler 注册"聊天消息持久化"消费者处理器。
|
||||||
//
|
//
|
||||||
// 职责边界:
|
// 职责边界:
|
||||||
// 1. 只负责聊天事件,不处理其他业务事件;
|
// 1. 只负责聊天事件,不处理其他业务事件;
|
||||||
// 2. 只负责注册,不负责总线启停;
|
// 2. 只负责注册,不负责总线启停;
|
||||||
// 3. 通过 outbox 通用事务入口把“业务写入 + consumed 推进”合并为一个事务;
|
// 3. 通过 outbox 通用事务入口把"业务写入 + consumed 推进"合并为一个事务;
|
||||||
// 4. 当前版本仅注册新路由键(chat.history.persist.requested),不再注册旧兼容键。
|
// 4. 当前版本仅注册新路由键(chat.history.persist.requested),不再注册旧兼容键。
|
||||||
func RegisterChatHistoryPersistHandler(
|
func RegisterChatHistoryPersistHandler(
|
||||||
bus *outboxinfra.EventBus,
|
bus *outboxinfra.EventBus,
|
||||||
@@ -44,7 +44,6 @@ func RegisterChatHistoryPersistHandler(
|
|||||||
if repoManager == nil {
|
if repoManager == nil {
|
||||||
return errors.New("repo manager is nil")
|
return errors.New("repo manager is nil")
|
||||||
}
|
}
|
||||||
kafkaCfg := kafkabus.LoadConfig()
|
|
||||||
|
|
||||||
// 2. 定义统一处理器:
|
// 2. 定义统一处理器:
|
||||||
// 2.1 解析 payload;
|
// 2.1 解析 payload;
|
||||||
@@ -58,12 +57,12 @@ func RegisterChatHistoryPersistHandler(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.2 使用 outbox 通用消费事务,保证“业务写入 + consumed 状态推进”原子一致。
|
// 2.2 使用 outbox 通用消费事务,保证"业务写入 + consumed 状态推进"原子一致。
|
||||||
return outboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, func(tx *gorm.DB) error {
|
return outboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, func(tx *gorm.DB) error {
|
||||||
// 2.2.1 基于同一个 tx 构造 RepoManager,复用你现有跨包事务模型。
|
// 2.2.1 基于同一个 tx 构造 RepoManager,复用你现有跨包事务模型。
|
||||||
txM := repoManager.WithTx(tx)
|
txM := repoManager.WithTx(tx)
|
||||||
// 2.2.2 在同事务内写入聊天历史与会话计数。
|
// 2.2.2 在同事务内写入聊天历史与会话计数。
|
||||||
if err := txM.Agent.SaveChatHistoryInTx(
|
return txM.Agent.SaveChatHistoryInTx(
|
||||||
ctx,
|
ctx,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.ConversationID,
|
payload.ConversationID,
|
||||||
@@ -72,19 +71,6 @@ func RegisterChatHistoryPersistHandler(
|
|||||||
payload.ReasoningContent,
|
payload.ReasoningContent,
|
||||||
payload.ReasoningDurationSeconds,
|
payload.ReasoningDurationSeconds,
|
||||||
payload.TokensConsumed,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishChatHistoryPersistRequested 发布“聊天消息持久化请求”事件。
|
// PublishChatHistoryPersistRequested 发布"聊天消息持久化请求"事件。
|
||||||
//
|
//
|
||||||
// 设计目的:
|
// 设计目的:
|
||||||
// 1. 让业务层只传 DTO,不重复拼事件元数据;
|
// 1. 让业务层只传 DTO,不重复拼事件元数据;
|
||||||
|
|||||||
@@ -125,6 +125,51 @@ func EnqueueMemoryExtractRequestedInTx(
|
|||||||
return err
|
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) {
|
func buildMemoryExtractPayloadFromChat(chatPayload model.ChatHistoryPersistPayload) (model.MemoryExtractRequestedPayload, bool) {
|
||||||
role := strings.ToLower(strings.TrimSpace(chatPayload.Role))
|
role := strings.ToLower(strings.TrimSpace(chatPayload.Role))
|
||||||
if role != "user" {
|
if role != "user" {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
expandedTaskClassDetail: TaskClassDetail | null
|
expandedTaskClassDetail: TaskClassDetail | null
|
||||||
selectedTaskClassIds: number[]
|
selectedTaskClassIds: number[]
|
||||||
taskClassMultiSelectMode: boolean
|
taskClassMultiSelectMode: boolean
|
||||||
|
manualEditMode: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -35,14 +36,45 @@ function isSelected(taskClassId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_time']) {
|
function formatEmbeddedTime(value: TaskClassDetail['items'][number]['embedded_time']) {
|
||||||
if (!value?.date) {
|
if (!value && !(value as any)?._preview_week) {
|
||||||
return '未安排'
|
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)
|
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 month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
const day = `${date.getDate()}`.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() {
|
function syncViewportHeight() {
|
||||||
@@ -181,6 +213,9 @@ watch(
|
|||||||
v-for="item in expandedTaskClassDetail.items"
|
v-for="item in expandedTaskClassDetail.items"
|
||||||
:key="item.order"
|
:key="item.order"
|
||||||
class="task-class-card__detail-item"
|
class="task-class-card__detail-item"
|
||||||
|
:class="{ 'task-class-card__detail-item--draggable': manualEditMode }"
|
||||||
|
:draggable="manualEditMode"
|
||||||
|
@dragstart="handleDragStart(item, $event)"
|
||||||
>
|
>
|
||||||
<span class="task-class-card__detail-order">{{ item.order }}</span>
|
<span class="task-class-card__detail-order">{{ item.order }}</span>
|
||||||
<span class="task-class-card__detail-text">{{ item.content }}</span>
|
<span class="task-class-card__detail-text">{{ item.content }}</span>
|
||||||
@@ -471,6 +506,17 @@ watch(
|
|||||||
align-items: center;
|
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 {
|
.task-class-card__detail-order {
|
||||||
color: #17253d;
|
color: #17253d;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ const props = defineProps<{
|
|||||||
scheduleSelectionMode: boolean
|
scheduleSelectionMode: boolean
|
||||||
selectedScheduleEventIds: number[]
|
selectedScheduleEventIds: number[]
|
||||||
previewDragEnabled: boolean
|
previewDragEnabled: boolean
|
||||||
|
manualEditMode: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggleScheduleEvent: [eventId: number]
|
toggleScheduleEvent: [eventId: number]
|
||||||
movePreviewEvent: [payload: PreviewMovePayload]
|
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<string | null>(null)
|
const draggingCellKey = ref<string | null>(null)
|
||||||
const dragOverCellKey = ref<string | null>(null)
|
const dragOverCellKey = ref<string | null>(null)
|
||||||
|
const isDraggingOverDeleteZone = ref(false)
|
||||||
|
const isExternalDragging = ref(false)
|
||||||
|
|
||||||
const sectionSlots: SectionSlot[] = [
|
const sectionSlots: SectionSlot[] = [
|
||||||
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
|
||||||
@@ -68,11 +73,12 @@ function isSelected(eventId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasEmbeddedTask(event?: ScheduleWeekEvent) {
|
function hasEmbeddedTask(event?: ScheduleWeekEvent) {
|
||||||
|
const taskId = Number(event?.embedded_task_info?.id)
|
||||||
return Boolean(
|
return Boolean(
|
||||||
event &&
|
event &&
|
||||||
event.type === 'course' &&
|
event.type === 'course' &&
|
||||||
event.embedded_task_info &&
|
!isNaN(taskId) &&
|
||||||
event.embedded_task_info.id > 0,
|
taskId > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +136,7 @@ function resolveEmbeddedTaskName(event?: ScheduleWeekEvent) {
|
|||||||
// 2. 只有 preview 模式下的 suggested 条目才允许拖拽,正式课表与普通课程保持只读。
|
// 2. 只有 preview 模式下的 suggested 条目才允许拖拽,正式课表与普通课程保持只读。
|
||||||
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
|
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
props.previewDragEnabled &&
|
(props.previewDragEnabled || props.manualEditMode) &&
|
||||||
!props.scheduleSelectionMode &&
|
!props.scheduleSelectionMode &&
|
||||||
event &&
|
event &&
|
||||||
event.status === 'suggested',
|
event.status === 'suggested',
|
||||||
@@ -147,7 +153,15 @@ function isEmbeddedSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWholeCellDraggable(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 负责判断当前格子是否允许作为“拖拽目标”。
|
// canDropPreviewEvent 负责判断当前格子是否允许作为“拖拽目标”。
|
||||||
@@ -157,7 +171,11 @@ function isWholeCellDraggable(event?: ScheduleWeekEvent) {
|
|||||||
// 2. 课程格允许接收 suggested 任务,父组件会把它转换成“嵌入课程”的预览结构。
|
// 2. 课程格允许接收 suggested 任务,父组件会把它转换成“嵌入课程”的预览结构。
|
||||||
// 3. suggested 格本身也允许作为目标,用于交换两个建议任务的位置。
|
// 3. suggested 格本身也允许作为目标,用于交换两个建议任务的位置。
|
||||||
function canDropPreviewEvent(event?: ScheduleWeekEvent) {
|
function canDropPreviewEvent(event?: ScheduleWeekEvent) {
|
||||||
if (!props.previewDragEnabled || props.scheduleSelectionMode) {
|
if (!props.manualEditMode && !props.previewDragEnabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.scheduleSelectionMode) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,18 +196,19 @@ function buildCellKey(dayOfWeek: number, order: number) {
|
|||||||
|
|
||||||
function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: DragEvent) {
|
function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: DragEvent) {
|
||||||
const event = resolveEvent(dayOfWeek, order)
|
const event = resolveEvent(dayOfWeek, order)
|
||||||
if (!isSuggestedPreviewEvent(event) || !props.weekData) {
|
if (!isWholeCellDraggable(event) && !isEmbeddedSuggestedPreviewEvent(event)) {
|
||||||
dragEvent.preventDefault()
|
dragEvent.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
draggingCellKey.value = buildCellKey(dayOfWeek, order)
|
draggingCellKey.value = buildCellKey(dayOfWeek, order)
|
||||||
dragOverCellKey.value = null
|
dragOverCellKey.value = null
|
||||||
|
isExternalDragging.value = false
|
||||||
|
|
||||||
dragEvent.dataTransfer?.setData(
|
dragEvent.dataTransfer?.setData(
|
||||||
'application/json',
|
'application/json',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
week: props.weekData.week,
|
week: props.weekData?.week ?? 0,
|
||||||
sourceDayOfWeek: dayOfWeek,
|
sourceDayOfWeek: dayOfWeek,
|
||||||
sourceOrder: order,
|
sourceOrder: order,
|
||||||
}),
|
}),
|
||||||
@@ -200,10 +219,6 @@ function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: Dra
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePreviewDragOver(dayOfWeek: number, order: number, dragEvent: DragEvent) {
|
function handlePreviewDragOver(dayOfWeek: number, order: number, dragEvent: DragEvent) {
|
||||||
if (!draggingCellKey.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellKey = buildCellKey(dayOfWeek, order)
|
const cellKey = buildCellKey(dayOfWeek, order)
|
||||||
if (cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) {
|
if (cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) {
|
||||||
return
|
return
|
||||||
@@ -211,17 +226,46 @@ function handlePreviewDragOver(dayOfWeek: number, order: number, dragEvent: Drag
|
|||||||
|
|
||||||
dragEvent.preventDefault()
|
dragEvent.preventDefault()
|
||||||
dragOverCellKey.value = cellKey
|
dragOverCellKey.value = cellKey
|
||||||
|
isDraggingOverDeleteZone.value = false
|
||||||
if (dragEvent.dataTransfer) {
|
if (dragEvent.dataTransfer) {
|
||||||
dragEvent.dataTransfer.dropEffect = 'move'
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellKey = buildCellKey(dayOfWeek, order)
|
// 2. 处理内部拖拽移动
|
||||||
const payloadText = dragEvent.dataTransfer?.getData('application/json')
|
const payloadText = dragEvent.dataTransfer?.getData('application/json')
|
||||||
if (!payloadText || cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) {
|
if (!payloadText || cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) {
|
||||||
draggingCellKey.value = null
|
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() {
|
function handlePreviewDragEnd() {
|
||||||
draggingCellKey.value = null
|
draggingCellKey.value = null
|
||||||
dragOverCellKey.value = null
|
dragOverCellKey.value = null
|
||||||
|
isDraggingOverDeleteZone.value = false
|
||||||
|
isExternalDragging.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -265,7 +345,7 @@ function handlePreviewDragEnd() {
|
|||||||
<strong>{{ weekLabel }}</strong>
|
<strong>{{ weekLabel }}</strong>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="planning-board__grid">
|
<div class="planning-board__grid" @dragover="handleExternalDragOver">
|
||||||
<div class="planning-board__corner" />
|
<div class="planning-board__corner" />
|
||||||
|
|
||||||
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
|
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
|
||||||
@@ -290,6 +370,7 @@ function handlePreviewDragEnd() {
|
|||||||
'planning-board__cell--selectable': scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty',
|
'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--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--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--dragging': draggingCellKey === buildCellKey(header.dayOfWeek, slot.order),
|
||||||
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
|
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
|
||||||
},
|
},
|
||||||
@@ -302,10 +383,13 @@ function handlePreviewDragEnd() {
|
|||||||
@dragend="handlePreviewDragEnd"
|
@dragend="handlePreviewDragEnd"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
|
v-if="(scheduleSelectionMode || manualEditMode) && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
|
||||||
type="button"
|
type="button"
|
||||||
class="planning-board__checkbox"
|
class="planning-board__checkbox"
|
||||||
:class="{ 'planning-board__checkbox--active': isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id) }"
|
:class="{
|
||||||
|
'planning-board__checkbox--active': isSelected(resolveEvent(header.dayOfWeek, slot.order)!.id),
|
||||||
|
'planning-board__checkbox--hidden': manualEditMode && resolveEvent(header.dayOfWeek, slot.order)?.status !== 'suggested'
|
||||||
|
}"
|
||||||
@click="emit('toggleScheduleEvent', resolveEvent(header.dayOfWeek, slot.order)!.id)"
|
@click="emit('toggleScheduleEvent', resolveEvent(header.dayOfWeek, slot.order)!.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -333,6 +417,14 @@ function handlePreviewDragEnd() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="resolveEvent(header.dayOfWeek, slot.order)?.type === 'task' || resolveEvent(header.dayOfWeek, slot.order)?.status === 'suggested'"
|
||||||
|
class="planning-board__cell-main"
|
||||||
|
>
|
||||||
|
<strong>{{ resolveCellTitle(resolveEvent(header.dayOfWeek, slot.order)) }}</strong>
|
||||||
|
<span>{{ resolveCellMeta(resolveEvent(header.dayOfWeek, slot.order)) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="planning-board__cell-main"
|
class="planning-board__cell-main"
|
||||||
@@ -344,6 +436,25 @@ function handlePreviewDragEnd() {
|
|||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 悬浮删除热区 -->
|
||||||
|
<transition name="delete-zone">
|
||||||
|
<div
|
||||||
|
v-if="draggingCellKey && manualEditMode"
|
||||||
|
class="planning-board__delete-zone"
|
||||||
|
:class="{ 'planning-board__delete-zone--active': isDraggingOverDeleteZone }"
|
||||||
|
@dragover="handleDragOverDeleteZone"
|
||||||
|
@dragleave="isDraggingOverDeleteZone = false"
|
||||||
|
@drop="handleDropOnDeleteZone"
|
||||||
|
>
|
||||||
|
<span class="delete-zone-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 7L18.1327 19.1425C18.0579 20.1891 17.187 21 16.1378 21H7.86224C6.81296 21 5.94208 20.1891 5.86732 19.1425L5 7M10 11V17M14 11V17M15 7V4C15 3.44772 14.5523 3 14 3H10C9.44772 3 9 3.44772 9 4V7M4 7H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<strong>在此处松开以解除安排</strong>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -465,81 +576,89 @@ function handlePreviewDragEnd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.planning-board__cell--course {
|
.planning-board__cell--course {
|
||||||
background: #e0f2fe;
|
background: #f0f7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-board__cell--course-embedded {
|
.planning-board__cell--course-embedded {
|
||||||
background: #b9e6fe;
|
background: #f0f7ff;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 8px;
|
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 strong,
|
||||||
.planning-board__cell--course .planning-board__cell-main span {
|
.planning-board__cell--course .planning-board__cell-main span {
|
||||||
color: #0284c7;
|
color: #0369a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-board__embedded-shell {
|
.planning-board__embedded-shell {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
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 {
|
.planning-board__embedded-course {
|
||||||
padding: 6px 4px;
|
padding: 2px 4px;
|
||||||
|
font-size: 13px;
|
||||||
color: #0369a1;
|
color: #0369a1;
|
||||||
}
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
.planning-board__embedded-course strong,
|
|
||||||
.planning-board__embedded-task strong {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
text-overflow: ellipsis;
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-align: center;
|
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 {
|
.planning-board__embedded-task {
|
||||||
padding: 6px 8px;
|
flex: 1;
|
||||||
border-radius: 10px;
|
|
||||||
background: #ffffff;
|
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 {
|
.planning-board__embedded-task:hover {
|
||||||
color: #0369a1;
|
transform: translateY(-1px);
|
||||||
font-size: 11px;
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
|
||||||
line-height: 1.24;
|
border-color: #3b82f6;
|
||||||
font-weight: 800;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-board__embedded-task-dragger {
|
.planning-board__embedded-task-dragger {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: grab;
|
||||||
width: 100%;
|
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 {
|
.planning-board__embedded-task-dragger--active {
|
||||||
@@ -646,6 +765,60 @@ function handlePreviewDragEnd() {
|
|||||||
box-shadow: inset 0 0 0 3px #ffffff;
|
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 {
|
@keyframes board-item-spring {
|
||||||
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
|
0% { opacity: 0; transform: scale(0.6) translateY(20px); }
|
||||||
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
|
60% { opacity: 1; transform: scale(1.05) translateY(-2px); }
|
||||||
|
|||||||
@@ -218,6 +218,10 @@ async function handleLogout() {
|
|||||||
finally { logoutLoading.value = false; await router.push('/auth') }
|
finally { logoutLoading.value = false; await router.push('/auth') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCourseImportEntry() {
|
||||||
|
void router.push('/schedule')
|
||||||
|
}
|
||||||
|
|
||||||
function syncDashboardMainScale() {
|
function syncDashboardMainScale() {
|
||||||
const main = dashboardMainRef.value
|
const main = dashboardMainRef.value
|
||||||
const inner = dashboardMainInnerRef.value
|
const inner = dashboardMainInnerRef.value
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -97,12 +97,18 @@ const SCHEDULE_SECTION_TIME_MAP: Record<number, [string, string]> = {
|
|||||||
// 1. 这里只负责触发浏览器原生确认弹框,不负责展示自定义 UI。
|
// 1. 这里只负责触发浏览器原生确认弹框,不负责展示自定义 UI。
|
||||||
// 2. 只有存在未应用的智能编排结果时才拦截,避免影响正常刷新体验。
|
// 2. 只有存在未应用的智能编排结果时才拦截,避免影响正常刷新体验。
|
||||||
function handleSchedulePreviewBeforeUnload(event: BeforeUnloadEvent) {
|
function handleSchedulePreviewBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
if (!schedulePreviewRuntimeState.weeks?.length) {
|
// 1. 如果处于手动编辑模式且有变更,拦截刷新。
|
||||||
|
if (manualEditMode.value && (Boolean(previewWeeks.value?.length) || pendingDeleteIds.value.length > 0)) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
// 2. 如果存在待应用的智能预览,拦截刷新。
|
||||||
event.returnValue = ''
|
if (schedulePreviewRuntimeState.weeks?.length) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -130,8 +136,11 @@ const expandedTaskClassId = ref<number | null>(null)
|
|||||||
const expandedTaskClassDetail = ref<TaskClassDetail | null>(null)
|
const expandedTaskClassDetail = ref<TaskClassDetail | null>(null)
|
||||||
const taskClassMultiSelectMode = ref(false)
|
const taskClassMultiSelectMode = ref(false)
|
||||||
const selectedTaskClassIds = ref<number[]>([])
|
const selectedTaskClassIds = ref<number[]>([])
|
||||||
const scheduleSelectionMode = ref(false)
|
|
||||||
const selectedScheduleEventIds = ref<number[]>([])
|
const selectedScheduleEventIds = ref<number[]>([])
|
||||||
|
const scheduleSelectionMode = ref(false)
|
||||||
|
const manualEditMode = ref(false)
|
||||||
|
const pendingDeleteIds = ref<number[]>([])
|
||||||
|
const hasManualChanges = ref(false)
|
||||||
|
|
||||||
const liveWeeks = ref<ScheduleWeekData[]>([])
|
const liveWeeks = ref<ScheduleWeekData[]>([])
|
||||||
const previewWeeks = ref<ScheduleWeekData[] | null>(schedulePreviewRuntimeState.weeks)
|
const previewWeeks = ref<ScheduleWeekData[] | null>(schedulePreviewRuntimeState.weeks)
|
||||||
@@ -156,6 +165,51 @@ const effectiveSelectedTaskClassIds = computed(() => {
|
|||||||
return expandedTaskClassId.value ? [expandedTaskClassId.value] : []
|
return expandedTaskClassId.value ? [expandedTaskClassId.value] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const augmentedTaskClassDetail = computed<TaskClassDetail | null>(() => {
|
||||||
|
if (!expandedTaskClassDetail.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = { ...expandedTaskClassDetail.value }
|
||||||
|
detail.items = detail.items.map((item) => {
|
||||||
|
// 0. 只要有 ID,就尝试进行匹配(注意类型一致性)
|
||||||
|
const targetId = Number(item.id)
|
||||||
|
if (isNaN(targetId) || targetId <= 0) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先查找当前预览(previewWeeks)中是否有该任务块。
|
||||||
|
for (const weekData of previewWeeks.value ?? []) {
|
||||||
|
const event = weekData.events.find((e) =>
|
||||||
|
(e.status === 'suggested') &&
|
||||||
|
((e.type === 'course' && Number(e.embedded_task_info?.id) === targetId) || (e.type !== 'course' && Number(e.id) === targetId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
embedded_time: {
|
||||||
|
date: '', // 预览态下日期由预览周次决定,侧边栏格式化函数会处理
|
||||||
|
section_from: (event.order - 1) * 2 + 1,
|
||||||
|
section_to: (event.order - 1) * 2 + 2,
|
||||||
|
_preview_week: weekData.week, // 标记这是预览周
|
||||||
|
_day_of_week: event.day_of_week, // 注入周几信息
|
||||||
|
} as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果该任务块被标记为待删除,则强制显示为“未安排”。
|
||||||
|
if (typeof item.id === 'number' && pendingDeleteIds.value.includes(item.id)) {
|
||||||
|
return { ...item, embedded_time: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
return detail
|
||||||
|
})
|
||||||
|
|
||||||
const previewWeekLookup = computed(() => {
|
const previewWeekLookup = computed(() => {
|
||||||
const map = new Map<number, ScheduleWeekData>()
|
const map = new Map<number, ScheduleWeekData>()
|
||||||
|
|
||||||
@@ -176,7 +230,11 @@ const liveWeekLookup = computed(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasPendingPreview = computed(() => Boolean(previewWeeks.value?.length))
|
const hasPendingPreview = computed(() => Boolean(previewWeeks.value?.length) || manualEditMode.value)
|
||||||
|
|
||||||
|
const isEditUnsaved = computed(() =>
|
||||||
|
manualEditMode.value && (Boolean(previewWeeks.value?.length) || pendingDeleteIds.value.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
const resolvedCurrentWeekData = computed(() => {
|
const resolvedCurrentWeekData = computed(() => {
|
||||||
if (!previewWeeks.value?.length && !liveWeeks.value.length) {
|
if (!previewWeeks.value?.length && !liveWeeks.value.length) {
|
||||||
@@ -232,8 +290,7 @@ const showDeleteModeButton = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showApplyButton = computed(() =>
|
const showApplyButton = computed(() =>
|
||||||
!scheduleSelectionMode.value &&
|
!scheduleSelectionMode.value && (hasPendingPreview.value || pendingDeleteIds.value.length > 0),
|
||||||
hasPendingPreview.value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const canGoPreviousWeek = computed(() =>
|
const canGoPreviousWeek = computed(() =>
|
||||||
@@ -343,6 +400,8 @@ function setPreviewState(weeks: ScheduleWeekData[] | null, taskClassIds: number[
|
|||||||
// 2. 调用方负责在“应用成功 / 删除后重算 / 用户主动替换预览”等时机决定是否清空。
|
// 2. 调用方负责在“应用成功 / 删除后重算 / 用户主动替换预览”等时机决定是否清空。
|
||||||
function clearPreviewState() {
|
function clearPreviewState() {
|
||||||
setPreviewState(null, [])
|
setPreviewState(null, [])
|
||||||
|
pendingDeleteIds.value = []
|
||||||
|
hasManualChanges.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
|
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
|
||||||
@@ -468,7 +527,7 @@ function buildPreviewEventWithSuggested(
|
|||||||
id: suggestedItem.id,
|
id: suggestedItem.id,
|
||||||
order: slot.order,
|
order: slot.order,
|
||||||
day_of_week: slot.dayOfWeek,
|
day_of_week: slot.dayOfWeek,
|
||||||
name: suggestedItem.name,
|
name: suggestedItem.name || '未命名任务',
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
location: '',
|
location: '',
|
||||||
@@ -556,6 +615,119 @@ function handleMovePreviewEvent(payload: PreviewMovePayload) {
|
|||||||
replacePreviewEventAtSlot(nextWeeks, sourceSlot, nextSourceEvent)
|
replacePreviewEventAtSlot(nextWeeks, sourceSlot, nextSourceEvent)
|
||||||
replacePreviewEventAtSlot(nextWeeks, targetSlot, nextTargetEvent)
|
replacePreviewEventAtSlot(nextWeeks, targetSlot, nextTargetEvent)
|
||||||
setPreviewState(nextWeeks, previewTaskClassIds.value)
|
setPreviewState(nextWeeks, previewTaskClassIds.value)
|
||||||
|
hasManualChanges.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDropTaskItem 负责处理从侧边栏拖入新任务块到格子的逻辑。
|
||||||
|
function handleDropTaskItem(payload: {
|
||||||
|
id: number | string
|
||||||
|
content: string
|
||||||
|
taskClassId: number
|
||||||
|
week: number
|
||||||
|
dayOfWeek: number
|
||||||
|
order: number
|
||||||
|
}) {
|
||||||
|
if (!manualEditMode.value) return
|
||||||
|
|
||||||
|
// 0. 强制规整 ID 为数值类型,防止后续匹配失效
|
||||||
|
const targetId = Number(payload.id)
|
||||||
|
if (isNaN(targetId) || targetId === 0) return
|
||||||
|
|
||||||
|
const targetSlot: SchedulePreviewSlotRef = {
|
||||||
|
week: payload.week,
|
||||||
|
dayOfWeek: payload.dayOfWeek,
|
||||||
|
order: payload.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 初始化预览数据(如果当前没有预览态,则基于 live 数据克隆)。
|
||||||
|
const nextWeeks = previewWeeks.value?.length
|
||||||
|
? clonePreviewWeeks(previewWeeks.value)
|
||||||
|
: clonePreviewWeeks(liveWeeks.value)
|
||||||
|
|
||||||
|
// 1.5 查重:严禁同一个任务块被重复安排
|
||||||
|
const isDuplicate = nextWeeks.some(w => w.events.some(e =>
|
||||||
|
(e.status === 'suggested') && (Number(e.id || e.embedded_task_info?.id) === targetId)
|
||||||
|
))
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
ElMessage.warning('该任务块已在当前计划中安排,不可重复添加')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { weekIndex, eventIndex } = findPreviewEventIndex(nextWeeks, targetSlot)
|
||||||
|
if (weekIndex < 0) return
|
||||||
|
|
||||||
|
const targetEvent = nextWeeks[weekIndex]!.events[eventIndex]
|
||||||
|
// 仅保护非编辑态下的课程,编辑态允许覆盖
|
||||||
|
if (targetEvent?.type === 'course' && !manualEditMode.value) {
|
||||||
|
ElMessage.warning('该位置无法放置任务块')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 构造建议项,确保所有字段就绪。
|
||||||
|
const suggestedItem: SuggestedPreviewItem = {
|
||||||
|
id: targetId,
|
||||||
|
name: payload.content || '未命名任务',
|
||||||
|
type: 'task',
|
||||||
|
span: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 应用建议。
|
||||||
|
// 重新获取当前格子引用,确保操作的是克隆后的最新数据结构
|
||||||
|
const finalTargetEvent = nextWeeks[weekIndex]!.events[eventIndex]
|
||||||
|
const nextTargetEvent = buildPreviewEventWithSuggested(finalTargetEvent, targetSlot, suggestedItem)
|
||||||
|
replacePreviewEventAtSlot(nextWeeks, targetSlot, nextTargetEvent)
|
||||||
|
|
||||||
|
// 4. 更新全局状态
|
||||||
|
const nextTaskClassIds = Array.from(new Set([...previewTaskClassIds.value, Number(payload.taskClassId)]))
|
||||||
|
setPreviewState(nextWeeks, nextTaskClassIds)
|
||||||
|
|
||||||
|
// 5. 将该任务从待删除列表中移除(如果存在)
|
||||||
|
pendingDeleteIds.value = pendingDeleteIds.value.filter(id => Number(id) !== targetId)
|
||||||
|
hasManualChanges.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveEvent 从预览中移除某个已排/建议的任务块。
|
||||||
|
function handleRemoveEvent(payload: {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
status?: string
|
||||||
|
week: number
|
||||||
|
dayOfWeek: number
|
||||||
|
order: number
|
||||||
|
}) {
|
||||||
|
if (!manualEditMode.value) return
|
||||||
|
|
||||||
|
const slot: SchedulePreviewSlotRef = {
|
||||||
|
week: payload.week,
|
||||||
|
dayOfWeek: payload.dayOfWeek,
|
||||||
|
order: payload.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWeeks = previewWeeks.value?.length
|
||||||
|
? clonePreviewWeeks(previewWeeks.value)
|
||||||
|
: clonePreviewWeeks(liveWeeks.value)
|
||||||
|
|
||||||
|
const { weekIndex, eventIndex } = findPreviewEventIndex(nextWeeks, slot)
|
||||||
|
if (weekIndex < 0 || eventIndex < 0) return
|
||||||
|
|
||||||
|
const targetEvent = nextWeeks[weekIndex]!.events[eventIndex]
|
||||||
|
|
||||||
|
// 1. 如果是建议块(刚生成的),直接还原格子。
|
||||||
|
if (targetEvent.status === 'suggested') {
|
||||||
|
const nextEvent = buildPreviewEventWithSuggested(targetEvent, slot, null)
|
||||||
|
replacePreviewEventAtSlot(nextWeeks, slot, nextEvent)
|
||||||
|
setPreviewState(nextWeeks, previewTaskClassIds.value)
|
||||||
|
}
|
||||||
|
// 2. 如果是正式块(原有的),加入待删除列表,并在预览中移除。
|
||||||
|
else if (targetEvent.type === 'task') {
|
||||||
|
pendingDeleteIds.value = Array.from(new Set([...pendingDeleteIds.value, targetEvent.id]))
|
||||||
|
const nextEvent = buildEmptyPreviewEvent(slot)
|
||||||
|
replacePreviewEventAtSlot(nextWeeks, slot, nextEvent)
|
||||||
|
setPreviewState(nextWeeks, previewTaskClassIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasManualChanges.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTaskClasses() {
|
async function loadTaskClasses() {
|
||||||
@@ -690,6 +862,22 @@ async function handleSmartPlanning() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasManualChanges.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'自动编排将覆盖您当前的手动调整内容,是否继续?',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定覆盖',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
smartPlanningLoading.value = true
|
smartPlanningLoading.value = true
|
||||||
try {
|
try {
|
||||||
const plannedWeeks = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
|
const plannedWeeks = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
|
||||||
@@ -832,24 +1020,35 @@ async function buildApplyGroupsFromPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleApplyPreview() {
|
async function handleApplyPreview() {
|
||||||
if (!previewWeeks.value?.length || previewTaskClassIds.value.length === 0) {
|
const hasAdditions = previewWeeks.value?.some(w => w.events.some(e => e.status === 'suggested'))
|
||||||
ElMessage.info('当前没有可正式应用的预览结果')
|
const hasDeletions = pendingDeleteIds.value.length > 0
|
||||||
|
|
||||||
|
if (!hasAdditions && !hasDeletions) {
|
||||||
|
ElMessage.info('当前没有可提交的变更')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyingLoading.value = true
|
applyingLoading.value = true
|
||||||
try {
|
try {
|
||||||
const groupedItems = await buildApplyGroupsFromPreview(previewWeeks.value, previewTaskClassIds.value)
|
// 1. 处理新增项
|
||||||
if (!groupedItems.size) {
|
if (hasAdditions) {
|
||||||
ElMessage.info('当前预览没有可应用的建议排程')
|
const groupedItems = await buildApplyGroupsFromPreview(previewWeeks.value!, previewTaskClassIds.value)
|
||||||
return
|
for (const [taskClassId, items] of groupedItems) {
|
||||||
|
await applyBatchIntoSchedule(taskClassId, items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [taskClassId, items] of groupedItems) {
|
// 2. 处理删除项
|
||||||
await applyBatchIntoSchedule(taskClassId, items)
|
if (hasDeletions) {
|
||||||
|
await deleteScheduleEntries(pendingDeleteIds.value.map(id => ({
|
||||||
|
id,
|
||||||
|
delete_course: false,
|
||||||
|
delete_embedded_task: false,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
ElMessage.success(previewTaskClassIds.value.length > 1 ? '已正式应用批量粗排结果' : '已正式应用到日程')
|
ElMessage.success('日程安排已保存')
|
||||||
|
manualEditMode.value = false
|
||||||
clearPreviewState()
|
clearPreviewState()
|
||||||
await loadWeekData(currentWeek.value ?? undefined, { force: true })
|
await loadWeekData(currentWeek.value ?? undefined, { force: true })
|
||||||
await loadTaskClasses()
|
await loadTaskClasses()
|
||||||
@@ -857,12 +1056,38 @@ async function handleApplyPreview() {
|
|||||||
await loadTaskClassDetail(expandedTaskClassId.value)
|
await loadTaskClassDetail(expandedTaskClassId.value)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '正式应用失败')
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
applyingLoading.value = false
|
applyingLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCancelEdit() {
|
||||||
|
clearPreviewState()
|
||||||
|
manualEditMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleManualEditMode() {
|
||||||
|
if (manualEditMode.value && isEditUnsaved.value) {
|
||||||
|
ElMessageBox.confirm('当前有未保存的修改,退出将丢失这些调整,确定继续吗?', '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
}).then(() => {
|
||||||
|
handleCancelEdit()
|
||||||
|
}).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualEditMode.value = !manualEditMode.value
|
||||||
|
if (manualEditMode.value) {
|
||||||
|
// 进入编辑态时,如果没有预览数据,先克隆一份当前的正式数据,方便增量修改
|
||||||
|
if (!previewWeeks.value) {
|
||||||
|
setPreviewState(clonePreviewWeeks(liveWeeks.value), [])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleCancelEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateTaskClass(payload: Parameters<typeof createTaskClass>[0]) {
|
async function handleCreateTaskClass(payload: Parameters<typeof createTaskClass>[0]) {
|
||||||
createDialogLoading.value = true
|
createDialogLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -951,9 +1176,10 @@ onMounted(async () => {
|
|||||||
:loading="taskClassLoading"
|
:loading="taskClassLoading"
|
||||||
:detail-loading="taskClassDetailLoading"
|
:detail-loading="taskClassDetailLoading"
|
||||||
:expanded-task-class-id="expandedTaskClassId"
|
:expanded-task-class-id="expandedTaskClassId"
|
||||||
:expanded-task-class-detail="expandedTaskClassDetail"
|
:expanded-task-class-detail="augmentedTaskClassDetail"
|
||||||
:selected-task-class-ids="effectiveSelectedTaskClassIds"
|
:selected-task-class-ids="effectiveSelectedTaskClassIds"
|
||||||
:task-class-multi-select-mode="taskClassMultiSelectMode"
|
:task-class-multi-select-mode="taskClassMultiSelectMode"
|
||||||
|
:manual-edit-mode="manualEditMode"
|
||||||
@activate="handleActivateTaskClass"
|
@activate="handleActivateTaskClass"
|
||||||
@toggle-multi-mode="handleToggleTaskClassMultiMode"
|
@toggle-multi-mode="handleToggleTaskClassMultiMode"
|
||||||
@create="createDialogVisible = true"
|
@create="createDialogVisible = true"
|
||||||
@@ -964,16 +1190,25 @@ onMounted(async () => {
|
|||||||
<div class="schedule-board__toolbar">
|
<div class="schedule-board__toolbar">
|
||||||
<div class="schedule-board__toolbar-left">
|
<div class="schedule-board__toolbar-left">
|
||||||
<button
|
<button
|
||||||
v-if="showDeleteModeButton"
|
type="button"
|
||||||
|
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
||||||
|
:class="{ 'schedule-board__toolbar-button--active': manualEditMode }"
|
||||||
|
@click="toggleManualEditMode"
|
||||||
|
>
|
||||||
|
{{ manualEditMode ? '退出编排' : '自定义编排' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showDeleteModeButton && !manualEditMode"
|
||||||
type="button"
|
type="button"
|
||||||
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
||||||
@click="toggleScheduleSelectionMode"
|
@click="toggleScheduleSelectionMode"
|
||||||
>
|
>
|
||||||
多选
|
多选解除
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-else-if="scheduleSelectionMode"
|
v-else-if="scheduleSelectionMode && !manualEditMode"
|
||||||
type="button"
|
type="button"
|
||||||
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
|
||||||
@click="toggleScheduleSelectionMode"
|
@click="toggleScheduleSelectionMode"
|
||||||
@@ -1019,11 +1254,22 @@ onMounted(async () => {
|
|||||||
:schedule-selection-mode="scheduleSelectionMode"
|
:schedule-selection-mode="scheduleSelectionMode"
|
||||||
:selected-schedule-event-ids="selectedScheduleEventIds"
|
:selected-schedule-event-ids="selectedScheduleEventIds"
|
||||||
:preview-drag-enabled="hasPendingPreview"
|
:preview-drag-enabled="hasPendingPreview"
|
||||||
|
:manual-edit-mode="manualEditMode"
|
||||||
@toggle-schedule-event="handleToggleScheduleEvent"
|
@toggle-schedule-event="handleToggleScheduleEvent"
|
||||||
@move-preview-event="handleMovePreviewEvent"
|
@move-preview-event="handleMovePreviewEvent"
|
||||||
|
@drop-task-item="handleDropTaskItem"
|
||||||
|
@remove-event="handleRemoveEvent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="showApplyButton || scheduleSelectionMode" class="schedule-board__footer">
|
<div v-if="showApplyButton || scheduleSelectionMode || manualEditMode" class="schedule-board__footer">
|
||||||
|
<button
|
||||||
|
v-if="manualEditMode"
|
||||||
|
type="button"
|
||||||
|
class="schedule-board__footer-button schedule-board__footer-button--ghost"
|
||||||
|
@click="handleCancelEdit"
|
||||||
|
>
|
||||||
|
取消修改
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showApplyButton"
|
v-if="showApplyButton"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1031,7 +1277,7 @@ onMounted(async () => {
|
|||||||
:disabled="applyingLoading"
|
:disabled="applyingLoading"
|
||||||
@click="handleApplyPreview"
|
@click="handleApplyPreview"
|
||||||
>
|
>
|
||||||
{{ applyingLoading ? '应用中…' : '正式应用日程' }}
|
{{ applyingLoading ? '保存中…' : '保存日程' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1195,10 +1441,11 @@ onMounted(async () => {
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-board__toolbar-button--ghost:hover {
|
.schedule-board__toolbar-button--ghost:hover,
|
||||||
border-color: #cbd5e1;
|
.schedule-board__toolbar-button--active {
|
||||||
background: #f8fafc;
|
border-color: #3b82f6;
|
||||||
color: #0f172a;
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-board__footer {
|
.schedule-board__footer {
|
||||||
|
|||||||
Reference in New Issue
Block a user