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

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