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:
Losita
2026-04-23 23:07:04 +08:00
parent 53e2602df4
commit ba8e8e2a82
23 changed files with 640 additions and 154 deletions

View File

@@ -152,8 +152,6 @@ memory:
preferenceLimit: 5
# fact 类型最大注入条数。
factLimit: 5
# todo_hint 类型最大注入条数。
todoHintLimit: 3
inject:
# 注入渲染模式:
# flat 为旧扁平列表typed_v2 为按类型分段,便于模型区分“硬约束”和“参考事实”。

View File

@@ -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, // 结构体指针直接复用

View File

@@ -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 {

View File

@@ -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{}{

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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:记忆内容"`

View File

@@ -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 表示未安排

View File

@@ -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()
}

View File

@@ -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 重试。

View File

@@ -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:

View File

@@ -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 分层,不做历史摘要或裁剪;

View File

@@ -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不重复拼事件元数据

View File

@@ -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" {

View File

@@ -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)"
>
<span class="task-class-card__detail-order">{{ item.order }}</span>
<span class="task-class-card__detail-text">{{ item.content }}</span>
@@ -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;

View File

@@ -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<string | null>(null)
const dragOverCellKey = ref<string | null>(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
}
</script>
@@ -265,7 +345,7 @@ function handlePreviewDragEnd() {
<strong>{{ weekLabel }}</strong>
</header>
<div class="planning-board__grid">
<div class="planning-board__grid" @dragover="handleExternalDragOver">
<div class="planning-board__corner" />
<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--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"
>
<button
v-if="scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
v-if="(scheduleSelectionMode || manualEditMode) && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
type="button"
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)"
/>
@@ -333,6 +417,14 @@ function handlePreviewDragEnd() {
</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
v-else
class="planning-board__cell-main"
@@ -344,6 +436,25 @@ function handlePreviewDragEnd() {
</article>
</template>
</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>
</template>
@@ -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); }

View File

@@ -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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
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 {
@@ -97,13 +97,19 @@ const SCHEDULE_SECTION_TIME_MAP: Record<number, [string, string]> = {
// 1. 这里只负责触发浏览器原生确认弹框,不负责展示自定义 UI。
// 2. 只有存在未应用的智能编排结果时才拦截,避免影响正常刷新体验。
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
}
// 2. 如果存在待应用的智能预览,拦截刷新。
if (schedulePreviewRuntimeState.weeks?.length) {
event.preventDefault()
event.returnValue = ''
}
}
if (typeof window !== 'undefined') {
const schedulePageWindow = window as SchedulePageWindow
@@ -130,8 +136,11 @@ const expandedTaskClassId = ref<number | null>(null)
const expandedTaskClassDetail = ref<TaskClassDetail | null>(null)
const taskClassMultiSelectMode = ref(false)
const selectedTaskClassIds = ref<number[]>([])
const scheduleSelectionMode = ref(false)
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 previewWeeks = ref<ScheduleWeekData[] | null>(schedulePreviewRuntimeState.weeks)
@@ -156,6 +165,51 @@ const effectiveSelectedTaskClassIds = computed(() => {
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 map = new Map<number, ScheduleWeekData>()
@@ -176,7 +230,11 @@ const liveWeekLookup = computed(() => {
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(() => {
if (!previewWeeks.value?.length && !liveWeeks.value.length) {
@@ -232,8 +290,7 @@ const showDeleteModeButton = computed(() =>
)
const showApplyButton = computed(() =>
!scheduleSelectionMode.value &&
hasPendingPreview.value,
!scheduleSelectionMode.value && (hasPendingPreview.value || pendingDeleteIds.value.length > 0),
)
const canGoPreviousWeek = computed(() =>
@@ -343,6 +400,8 @@ function setPreviewState(weeks: ScheduleWeekData[] | null, taskClassIds: number[
// 2. 调用方负责在“应用成功 / 删除后重算 / 用户主动替换预览”等时机决定是否清空。
function clearPreviewState() {
setPreviewState(null, [])
pendingDeleteIds.value = []
hasManualChanges.value = false
}
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
@@ -468,7 +527,7 @@ function buildPreviewEventWithSuggested(
id: suggestedItem.id,
order: slot.order,
day_of_week: slot.dayOfWeek,
name: suggestedItem.name,
name: suggestedItem.name || '未命名任务',
start_time: startTime,
end_time: endTime,
location: '',
@@ -556,6 +615,119 @@ function handleMovePreviewEvent(payload: PreviewMovePayload) {
replacePreviewEventAtSlot(nextWeeks, sourceSlot, nextSourceEvent)
replacePreviewEventAtSlot(nextWeeks, targetSlot, nextTargetEvent)
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() {
@@ -690,6 +862,22 @@ async function handleSmartPlanning() {
return
}
if (hasManualChanges.value) {
try {
await ElMessageBox.confirm(
'自动编排将覆盖您当前的手动调整内容,是否继续?',
'提示',
{
confirmButtonText: '确定覆盖',
cancelButtonText: '取消',
type: 'warning',
},
)
} catch {
return
}
}
smartPlanningLoading.value = true
try {
const plannedWeeks = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
@@ -832,24 +1020,35 @@ async function buildApplyGroupsFromPreview(
}
async function handleApplyPreview() {
if (!previewWeeks.value?.length || previewTaskClassIds.value.length === 0) {
ElMessage.info('当前没有可正式应用的预览结果')
const hasAdditions = previewWeeks.value?.some(w => w.events.some(e => e.status === 'suggested'))
const hasDeletions = pendingDeleteIds.value.length > 0
if (!hasAdditions && !hasDeletions) {
ElMessage.info('当前没有可提交的变更')
return
}
applyingLoading.value = true
try {
const groupedItems = await buildApplyGroupsFromPreview(previewWeeks.value, previewTaskClassIds.value)
if (!groupedItems.size) {
ElMessage.info('当前预览没有可应用的建议排程')
return
}
// 1. 处理新增项
if (hasAdditions) {
const groupedItems = await buildApplyGroupsFromPreview(previewWeeks.value!, previewTaskClassIds.value)
for (const [taskClassId, items] of groupedItems) {
await applyBatchIntoSchedule(taskClassId, items)
}
}
ElMessage.success(previewTaskClassIds.value.length > 1 ? '已正式应用批量粗排结果' : '已正式应用到日程')
// 2. 处理删除项
if (hasDeletions) {
await deleteScheduleEntries(pendingDeleteIds.value.map(id => ({
id,
delete_course: false,
delete_embedded_task: false,
})))
}
ElMessage.success('日程安排已保存')
manualEditMode.value = false
clearPreviewState()
await loadWeekData(currentWeek.value ?? undefined, { force: true })
await loadTaskClasses()
@@ -857,12 +1056,38 @@ async function handleApplyPreview() {
await loadTaskClassDetail(expandedTaskClassId.value)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '正式应用失败')
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
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]) {
createDialogLoading.value = true
try {
@@ -951,9 +1176,10 @@ onMounted(async () => {
:loading="taskClassLoading"
:detail-loading="taskClassDetailLoading"
:expanded-task-class-id="expandedTaskClassId"
:expanded-task-class-detail="expandedTaskClassDetail"
:expanded-task-class-detail="augmentedTaskClassDetail"
:selected-task-class-ids="effectiveSelectedTaskClassIds"
:task-class-multi-select-mode="taskClassMultiSelectMode"
:manual-edit-mode="manualEditMode"
@activate="handleActivateTaskClass"
@toggle-multi-mode="handleToggleTaskClassMultiMode"
@create="createDialogVisible = true"
@@ -964,16 +1190,25 @@ onMounted(async () => {
<div class="schedule-board__toolbar">
<div class="schedule-board__toolbar-left">
<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"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="toggleScheduleSelectionMode"
>
多选
多选解除
</button>
<button
v-else-if="scheduleSelectionMode"
v-else-if="scheduleSelectionMode && !manualEditMode"
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
@click="toggleScheduleSelectionMode"
@@ -1019,11 +1254,22 @@ onMounted(async () => {
:schedule-selection-mode="scheduleSelectionMode"
:selected-schedule-event-ids="selectedScheduleEventIds"
:preview-drag-enabled="hasPendingPreview"
:manual-edit-mode="manualEditMode"
@toggle-schedule-event="handleToggleScheduleEvent"
@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
v-if="showApplyButton"
type="button"
@@ -1031,7 +1277,7 @@ onMounted(async () => {
:disabled="applyingLoading"
@click="handleApplyPreview"
>
{{ applyingLoading ? '应用中…' : '正式应用日程' }}
{{ applyingLoading ? '保存中…' : '保存日程' }}
</button>
<button
@@ -1195,10 +1441,11 @@ onMounted(async () => {
color: #475569;
}
.schedule-board__toolbar-button--ghost:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #0f172a;
.schedule-board__toolbar-button--ghost:hover,
.schedule-board__toolbar-button--active {
border-color: #3b82f6;
background: #eff6ff;
color: #3b82f6;
}
.schedule-board__footer {