Version: 0.8.3.dev.260328

后端:
1.彻底删除原agent文件夹,并将现agent2文件夹全量重命名为agent(包括全部涉及到的文件以及文档、注释),迁移工作完美结束
2.修复了重试消息的相关逻辑问题

前端:
1.改善了一些交互体验,修复了一些bug,现在只剩少的功能了,现存的bug基本都修复完毕

全仓库:
1.更新了决策记录和README文档
This commit is contained in:
Losita
2026-03-28 18:00:31 +08:00
parent 5fc9548420
commit 468367d617
108 changed files with 1910 additions and 17173 deletions

View File

@@ -11,14 +11,31 @@ import type {
import { extractErrorMessage } from '@/utils/http'
import { createIdempotencyKey } from '@/utils/idempotency'
type WeekScheduleResponseData = ScheduleWeekData | ScheduleWeekData[] | null | undefined
function normalizeWeekScheduleData(data: WeekScheduleResponseData): ScheduleWeekData[] {
if (Array.isArray(data)) {
return data
}
if (data && typeof data === 'object') {
return [data]
}
return []
}
export async function getWeekSchedule(week?: number) {
try {
const response = await http.get<ApiResponse<ScheduleWeekData[]>>('/schedule/week', {
params: typeof week === 'number' ? { week } : undefined,
const response = await http.get<ApiResponse<WeekScheduleResponseData>>('/schedule/week', {
params: {
week: typeof week === 'number' ? week : 0,
},
})
return response.data.data ?? []
return normalizeWeekScheduleData(response.data.data)
} catch (error) {
throw new Error(extractErrorMessage(error, '周日程加载失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u5468\u603b\u65e5\u7a0b\u52a0\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -27,7 +44,7 @@ export async function getTaskClassList() {
const response = await http.get<ApiResponse<{ task_classes: TaskClassListItem[] }>>('/task-class/list')
return response.data.data?.task_classes ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '任务类列表加载失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u4efb\u52a1\u7c7b\u5217\u8868\u52a0\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -40,7 +57,7 @@ export async function getTaskClassDetail(taskClassId: number) {
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '任务类详情加载失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u4efb\u52a1\u7c7b\u8be6\u60c5\u52a0\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -53,7 +70,7 @@ export async function createTaskClass(payload: TaskClassCreatePayload, idempoten
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '创建任务类失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u521b\u5efa\u4efb\u52a1\u7c7b\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -66,7 +83,7 @@ export async function smartPlanning(taskClassId: number) {
})
return response.data.data ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '智能粗排失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u667a\u80fd\u7c97\u6392\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -77,7 +94,7 @@ export async function smartPlanningMulti(taskClassIds: number[]) {
})
return response.data.data ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '批量智能粗排失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u6279\u91cf\u667a\u80fd\u7c97\u6392\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -97,7 +114,7 @@ export async function applyBatchIntoSchedule(taskClassId: number, items: ApplyBa
)
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '正式应用日程失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u6b63\u5f0f\u5e94\u7528\u65e5\u7a0b\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -111,7 +128,7 @@ export async function deleteScheduleEntries(items: ScheduleDeletePayloadItem[],
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '解除安排失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u89e3\u9664\u5b89\u6392\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}
@@ -127,6 +144,6 @@ export async function deleteTaskClassItem(taskItemId: number, idempotencyKey = c
})
return response.data
} catch (error) {
throw new Error(extractErrorMessage(error, '删除任务块失败,请稍后重试'))
throw new Error(extractErrorMessage(error, '\u5220\u9664\u4efb\u52a1\u5757\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'))
}
}

View File

@@ -677,6 +677,27 @@ function resolveVisibleUserMessageBeforeAssistant(messageId: string) {
return null
}
function findMessageIndexInList(messages: AssistantMessage[], messageId: string) {
return messages.findIndex((message) => message.id === messageId)
}
function resolveUserMessageBeforeAssistantInBucket(conversationId: string, assistantMessageId: string) {
const bucket = conversationMessagesMap[conversationId] ?? []
const index = findMessageIndexInList(bucket, assistantMessageId)
if (index <= 0) {
return null
}
for (let current = index - 1; current >= 0; current -= 1) {
const candidate = bucket[current]
if (candidate?.role === 'user') {
return candidate
}
}
return null
}
function isLocalEphemeralMessageId(id: string) {
return /^(user|assistant|system)-\d{13}-[a-z0-9]+$/i.test(id)
}
@@ -697,6 +718,81 @@ function resolvePersistedMessageId(message: AssistantMessage | null) {
return message.id
}
function resolveBestMatchedMessageFromBucket(conversationId: string, targetMessage: AssistantMessage) {
const bucket = conversationMessagesMap[conversationId] ?? []
const directMatchedMessage = bucket.find((message) => message.id === targetMessage.id)
if (directMatchedMessage) {
return directMatchedMessage
}
const targetTimestamp = resolveMessageTimestamp(targetMessage)
const logicalMatchedMessages = bucket
.filter((message) => isSameLogicalMessage(message, targetMessage))
.sort((left, right) => {
// 1. 优先命中已经拿到后端稳定主键的消息,避免继续引用本地占位态。
// 2. 若候选状态一致,则优先选择时间更接近原消息的那条。
// 3. 时间也一致时再按较新的记录兜底,降低重复文案时误命中旧消息的概率。
const persistedScoreDiff =
Number(!isLocalEphemeralMessageId(right.id)) - Number(!isLocalEphemeralMessageId(left.id))
if (persistedScoreDiff !== 0) {
return persistedScoreDiff
}
const leftGap = Math.abs(resolveMessageTimestamp(left) - targetTimestamp)
const rightGap = Math.abs(resolveMessageTimestamp(right) - targetTimestamp)
if (leftGap !== rightGap) {
return leftGap - rightGap
}
return resolveMessageTimestamp(right) - resolveMessageTimestamp(left)
})
return logicalMatchedMessages[0] ?? null
}
async function resolveRetrySourceMessages(
conversationId: string,
sourceUserMessage: AssistantMessage,
sourceAssistantMessage: AssistantMessage,
) {
let resolvedUserMessage: AssistantMessage | null = sourceUserMessage
let resolvedAssistantMessage: AssistantMessage | null = sourceAssistantMessage
let persistedUserMessageId = resolvePersistedMessageId(resolvedUserMessage)
let persistedAssistantMessageId = resolvePersistedMessageId(resolvedAssistantMessage)
if (persistedUserMessageId && persistedAssistantMessageId) {
return {
sourceUserMessage: resolvedUserMessage,
sourceAssistantMessage: resolvedAssistantMessage,
persistedUserMessageId,
persistedAssistantMessageId,
}
}
// 1. 若当前点击时仍是本地占位消息,先静默拉一次权威历史,尽量把真实 ID 补回来。
// 2. 这里复用现有 history 接口即可,避免为了一次重试再新增额外查询接口。
// 3. 若静默刷新后依然拿不到稳定 ID则说明消息大概率仍处于异步持久化窗口期。
await loadConversationMessages(conversationId, true)
resolvedAssistantMessage =
resolveBestMatchedMessageFromBucket(conversationId, sourceAssistantMessage) ?? sourceAssistantMessage
resolvedUserMessage =
resolveUserMessageBeforeAssistantInBucket(conversationId, resolvedAssistantMessage.id) ??
resolveBestMatchedMessageFromBucket(conversationId, sourceUserMessage) ??
sourceUserMessage
persistedUserMessageId = resolvePersistedMessageId(resolvedUserMessage)
persistedAssistantMessageId = resolvePersistedMessageId(resolvedAssistantMessage)
return {
sourceUserMessage: resolvedUserMessage,
sourceAssistantMessage: resolvedAssistantMessage,
persistedUserMessageId,
persistedAssistantMessageId,
}
}
function createRetryGroupId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `retry-${crypto.randomUUID()}`
@@ -1407,32 +1503,36 @@ async function regenerateAssistantMessage(message: AssistantMessage) {
}
const sourceUserMessage = resolveVisibleUserMessageBeforeAssistant(message.id)
const text = sourceUserMessage?.content.trim() || ''
const conversationId = selectedConversationId.value
const persistedUserMessageId = resolvePersistedMessageId(sourceUserMessage)
const persistedAssistantMessageId = resolvePersistedMessageId(message)
if (!text || !conversationId || !sourceUserMessage) {
if (!conversationId || !sourceUserMessage) {
ElMessage.warning('没有找到可用于重试的用户消息')
return
}
if (!persistedUserMessageId) {
ElMessage.info('当前消息仍在本地态,稍后刷新完成后再试重试')
const retrySource = await resolveRetrySourceMessages(conversationId, sourceUserMessage, message)
const text = retrySource.sourceUserMessage?.content.trim() || sourceUserMessage.content.trim()
if (!text) {
ElMessage.warning('没有找到可用于重试的用户消息')
return
}
if (!persistedAssistantMessageId) {
ElMessage.info('当前回复仍在本地态,稍后刷新完成后再试重试')
if (!retrySource.persistedUserMessageId || !retrySource.persistedAssistantMessageId) {
ElMessage.info('消息正在处理,请稍后再重试,或者直接复制消息重新发送')
return
}
chatLoading.value = true
cancelEditUserMessage()
const retryGroup = resolveRetryPageGroup(message)
const retryGroup = resolveRetryPageGroup(retrySource.sourceAssistantMessage)
const retryGroupId = retryGroup?.groupId || createRetryGroupId()
const nextRetryIndex = (retryGroup?.total ?? 1) + 1
applyRetryGroupToExistingMessages(retryGroupId, nextRetryIndex, sourceUserMessage.id, message.id)
applyRetryGroupToExistingMessages(
retryGroupId,
nextRetryIndex,
retrySource.sourceUserMessage.id,
retrySource.sourceAssistantMessage.id,
)
const now = new Date().toISOString()
appendConversationMessage(conversationId, {
@@ -1464,8 +1564,8 @@ async function regenerateAssistantMessage(message: AssistantMessage) {
try {
const actualConversationId = await streamAssistantReply(conversationId, text, retryAssistantMessage, now, true, {
retryGroupId,
retryFromUserMessageId: persistedUserMessageId,
retryFromAssistantMessageId: persistedAssistantMessageId,
retryFromUserMessageId: retrySource.persistedUserMessageId,
retryFromAssistantMessageId: retrySource.persistedAssistantMessageId,
})
await loadConversationMessages(actualConversationId, true)
} catch (error) {
@@ -3169,4 +3269,3 @@ onBeforeUnmount(() => {
background: rgba(51, 95, 194, 0.16);
}
</style>

View File

@@ -72,7 +72,6 @@ function resolveDetailPanelStyle(items: TaskClassDetail['items']) {
// 2. 条目超过“当前屏幕可安全展示的最大条数”后,立即锁住高度并进入内部滚动。
// 3. 这样像 8 条 task_item 这类中等长度列表会稳定触发滚动,不会再因为估算过大而失效。
return {
height: `${finalHeight}px`,
maxHeight: `${finalHeight}px`,
}
}
@@ -299,13 +298,15 @@ watch(
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
display: grid;
align-content: start;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
scrollbar-gutter: stable;
}
.task-class-sidebar__skeleton-item {
flex: 0 0 auto;
height: 120px;
border-radius: 24px;
background: linear-gradient(90deg, rgba(234, 239, 246, 0.9), rgba(248, 251, 255, 1), rgba(234, 239, 246, 0.9));
@@ -314,6 +315,7 @@ watch(
}
.task-class-card {
flex: 0 0 auto;
min-width: 0;
border-radius: 24px;
border: 1px solid rgba(216, 225, 238, 0.9);
@@ -330,6 +332,7 @@ watch(
.task-class-card__summary {
width: 100%;
min-width: 0;
min-height: 92px;
border: none;
background: transparent;
padding: 18px 20px 18px 18px;
@@ -394,7 +397,9 @@ watch(
}
.task-class-card__detail {
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 0 14px 14px;
overflow-y: auto;
overflow-x: hidden;
@@ -490,6 +495,7 @@ watch(
}
.task-class-sidebar__create {
flex: 0 0 auto;
min-width: 0;
min-height: 108px;
border: 1px dashed rgba(204, 216, 232, 0.92);
@@ -542,6 +548,7 @@ watch(
.task-class-card__summary {
padding: 16px 16px 16px 15px;
min-height: 84px;
}
}
@@ -590,6 +597,7 @@ watch(
.task-class-card__summary {
padding: 14px 14px 14px 13px;
min-height: 76px;
}
.task-class-card__content {
@@ -616,6 +624,7 @@ watch(
.task-class-card__summary {
padding: 12px;
min-height: 72px;
}
.task-class-card__corner {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { ScheduleWeekData, ScheduleWeekEvent } from '@/types/schedule'
@@ -15,18 +15,31 @@ interface SectionSlot {
timeRange: string
}
interface PreviewMovePayload {
week: number
sourceDayOfWeek: number
sourceOrder: number
targetDayOfWeek: number
targetOrder: number
}
const props = defineProps<{
weekLabel: string
weekHeaders: WeekDayHeader[]
weekData: ScheduleWeekData | null
scheduleSelectionMode: boolean
selectedScheduleEventIds: number[]
previewDragEnabled: boolean
}>()
const emit = defineEmits<{
toggleScheduleEvent: [eventId: number]
movePreviewEvent: [payload: PreviewMovePayload]
}>()
const draggingCellKey = ref<string | null>(null)
const dragOverCellKey = ref<string | null>(null)
const sectionSlots: SectionSlot[] = [
{ order: 1, title: '1-2', timeRange: '08:00\n09:40' },
{ order: 2, title: '3-4', timeRange: '10:15\n11:55' },
@@ -54,11 +67,24 @@ function isSelected(eventId: number) {
return props.selectedScheduleEventIds.includes(eventId)
}
function hasEmbeddedTask(event?: ScheduleWeekEvent) {
return Boolean(
event &&
event.type === 'course' &&
event.embedded_task_info &&
event.embedded_task_info.id > 0,
)
}
function resolveEventTone(event?: ScheduleWeekEvent) {
if (!event || event.type === 'empty') {
return 'empty'
}
if (hasEmbeddedTask(event)) {
return 'course-embedded'
}
if (event.type === 'course') {
return 'course'
}
@@ -88,6 +114,149 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
}
return event.location || '未定'
}
function resolveEmbeddedTaskName(event?: ScheduleWeekEvent) {
if (!hasEmbeddedTask(event)) {
return ''
}
return event!.embedded_task_info.name
}
// isSuggestedPreviewEvent 负责判断当前格子是否允许作为“拖拽源”。
//
// 职责边界:
// 1. 这里只判断前端交互条件,不负责真正改写 preview JSON。
// 2. 只有 preview 模式下的 suggested 条目才允许拖拽,正式课表与普通课程保持只读。
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
return Boolean(
props.previewDragEnabled &&
!props.scheduleSelectionMode &&
event &&
event.status === 'suggested',
)
}
function isEmbeddedSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
return Boolean(
isSuggestedPreviewEvent(event) &&
event &&
event.type === 'course' &&
hasEmbeddedTask(event),
)
}
function isWholeCellDraggable(event?: ScheduleWeekEvent) {
return Boolean(isSuggestedPreviewEvent(event) && !isEmbeddedSuggestedPreviewEvent(event))
}
// canDropPreviewEvent 负责判断当前格子是否允许作为“拖拽目标”。
//
// 设计说明:
// 1. 空白格允许放置 suggested 任务。
// 2. 课程格允许接收 suggested 任务,父组件会把它转换成“嵌入课程”的预览结构。
// 3. suggested 格本身也允许作为目标,用于交换两个建议任务的位置。
function canDropPreviewEvent(event?: ScheduleWeekEvent) {
if (!props.previewDragEnabled || props.scheduleSelectionMode) {
return false
}
if (!event || event.type === 'empty') {
return true
}
if (event.status === 'suggested') {
return true
}
return event.type === 'course'
}
function buildCellKey(dayOfWeek: number, order: number) {
return `${dayOfWeek}-${order}`
}
function handlePreviewDragStart(dayOfWeek: number, order: number, dragEvent: DragEvent) {
const event = resolveEvent(dayOfWeek, order)
if (!isSuggestedPreviewEvent(event) || !props.weekData) {
dragEvent.preventDefault()
return
}
draggingCellKey.value = buildCellKey(dayOfWeek, order)
dragOverCellKey.value = null
dragEvent.dataTransfer?.setData(
'application/json',
JSON.stringify({
week: props.weekData.week,
sourceDayOfWeek: dayOfWeek,
sourceOrder: order,
}),
)
if (dragEvent.dataTransfer) {
dragEvent.dataTransfer.effectAllowed = 'move'
}
}
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
}
dragEvent.preventDefault()
dragOverCellKey.value = cellKey
if (dragEvent.dataTransfer) {
dragEvent.dataTransfer.dropEffect = 'move'
}
}
function handlePreviewDrop(dayOfWeek: number, order: number, dragEvent: DragEvent) {
if (!draggingCellKey.value) {
return
}
const cellKey = buildCellKey(dayOfWeek, order)
const payloadText = dragEvent.dataTransfer?.getData('application/json')
if (!payloadText || cellKey === draggingCellKey.value || !canDropPreviewEvent(resolveEvent(dayOfWeek, order))) {
draggingCellKey.value = null
dragOverCellKey.value = null
return
}
try {
const payload = JSON.parse(payloadText) as Partial<PreviewMovePayload>
if (
typeof payload.week !== 'number' ||
typeof payload.sourceDayOfWeek !== 'number' ||
typeof payload.sourceOrder !== 'number'
) {
return
}
dragEvent.preventDefault()
emit('movePreviewEvent', {
week: payload.week,
sourceDayOfWeek: payload.sourceDayOfWeek,
sourceOrder: payload.sourceOrder,
targetDayOfWeek: dayOfWeek,
targetOrder: order,
})
} finally {
draggingCellKey.value = null
dragOverCellKey.value = null
}
}
function handlePreviewDragEnd() {
draggingCellKey.value = null
dragOverCellKey.value = null
}
</script>
<template>
@@ -119,8 +288,16 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
{
'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--dragging': draggingCellKey === buildCellKey(header.dayOfWeek, slot.order),
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
},
]"
:draggable="isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order))"
@dragstart="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
@dragover="handlePreviewDragOver(header.dayOfWeek, slot.order, $event)"
@drop="handlePreviewDrop(header.dayOfWeek, slot.order, $event)"
@dragend="handlePreviewDragEnd"
>
<button
v-if="scheduleSelectionMode && resolveEvent(header.dayOfWeek, slot.order)?.type !== 'empty'"
@@ -131,7 +308,33 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
/>
<template v-if="resolveEvent(header.dayOfWeek, slot.order)">
<div class="planning-board__cell-main">
<div
v-if="hasEmbeddedTask(resolveEvent(header.dayOfWeek, slot.order))"
class="planning-board__embedded-shell"
>
<div class="planning-board__embedded-course">
<strong>{{ resolveCellTitle(resolveEvent(header.dayOfWeek, slot.order)) }}</strong>
</div>
<div class="planning-board__embedded-task">
<strong
class="planning-board__embedded-task-dragger"
:class="{
'planning-board__embedded-task-dragger--active': isEmbeddedSuggestedPreviewEvent(resolveEvent(header.dayOfWeek, slot.order)),
}"
:draggable="isEmbeddedSuggestedPreviewEvent(resolveEvent(header.dayOfWeek, slot.order))"
@dragstart.stop="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
@dragend.stop="handlePreviewDragEnd"
>
{{ resolveEmbeddedTaskName(resolveEvent(header.dayOfWeek, slot.order)) }}
</strong>
</div>
</div>
<div
v-else
class="planning-board__cell-main"
>
<strong>{{ resolveCellTitle(resolveEvent(header.dayOfWeek, slot.order)) }}</strong>
<span>{{ resolveCellMeta(resolveEvent(header.dayOfWeek, slot.order)) }}</span>
</div>
@@ -261,11 +464,85 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
background: #acd6f4;
}
.planning-board__cell--course-embedded {
background: linear-gradient(180deg, rgba(121, 187, 239, 0.96) 0%, rgba(88, 161, 225, 0.96) 100%);
align-items: stretch;
padding: 9px;
}
.planning-board__cell--course .planning-board__cell-main strong,
.planning-board__cell--course .planning-board__cell-main span {
color: #2576cc;
}
.planning-board__embedded-shell {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
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;
color: #ffffff;
}
.planning-board__embedded-course strong,
.planning-board__embedded-task strong {
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
overflow-wrap: anywhere;
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: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 18px rgba(31, 82, 145, 0.14);
}
.planning-board__embedded-task strong {
color: #1f5db3;
font-size: 11px;
line-height: 1.24;
font-weight: 800;
-webkit-line-clamp: 2;
}
.planning-board__embedded-task-dragger {
width: 100%;
}
.planning-board__embedded-task-dragger--active {
cursor: grab;
}
.planning-board__cell--amber {
background: #ffe58b;
}
@@ -319,6 +596,20 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
box-shadow: inset 0 0 0 2px rgba(32, 102, 212, 0.52);
}
.planning-board__cell--draggable {
cursor: grab;
}
.planning-board__cell--dragging {
opacity: 0.42;
}
.planning-board__cell--dragover {
box-shadow:
inset 0 0 0 2px rgba(20, 92, 192, 0.58),
0 0 0 4px rgba(33, 109, 215, 0.1);
}
.planning-board__checkbox {
position: absolute;
top: 16px;
@@ -378,6 +669,14 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
.planning-board__cell-main strong {
font-size: 14px;
}
.planning-board__embedded-course strong {
font-size: 12px;
}
.planning-board__cell--course-embedded {
padding: 8px;
}
}
@media (max-width: 1180px) {
@@ -433,5 +732,23 @@ function resolveCellMeta(event?: ScheduleWeekEvent) {
.planning-board__cell {
padding: 12px 8px;
}
.planning-board__embedded-task {
padding: 5px 7px;
border-radius: 12px;
}
.planning-board__embedded-course {
padding: 4px 2px;
}
.planning-board__embedded-course strong,
.planning-board__embedded-task strong {
font-size: 11px;
}
.planning-board__cell--course-embedded {
padding: 7px;
}
}
</style>

View File

@@ -33,6 +33,86 @@ interface WeekDayHeader {
dateLabel: string
}
interface SchedulePreviewRuntimeState {
weeks: ScheduleWeekData[] | null
taskClassIds: number[]
currentWeek: number | null
}
interface PreviewMovePayload {
week: number
sourceDayOfWeek: number
sourceOrder: number
targetDayOfWeek: number
targetOrder: number
}
interface SuggestedPreviewItem {
id: number
name: string
type: string
span: number
}
interface SchedulePreviewSlotRef {
week: number
dayOfWeek: number
order: number
}
type SchedulePageWindow = Window & {
__schedulePreviewBeforeUnloadRegistered__?: boolean
}
// schedulePreviewRuntimeState 负责在“单页应用未刷新”的生命周期内保留智能编排预览。
//
// 设计说明:
// 1. 这里故意不用 sessionStorage/localStorage因为用户明确要求“刷新就丢”所以只保留运行时内存。
// 2. 这里负责跨路由回到 /schedule 时恢复预览;不负责持久化到浏览器磁盘。
// 3. 真正需要清空预览的时机,统一由 clearPreviewState 显式控制,避免切周时误清空。
const schedulePreviewRuntimeState: SchedulePreviewRuntimeState = {
weeks: null,
taskClassIds: [],
currentWeek: null,
}
const EMPTY_EMBEDDED_TASK_INFO = {
id: 0,
name: '',
type: 'task',
}
const SCHEDULE_SECTION_TIME_MAP: Record<number, [string, string]> = {
1: ['08:00', '09:40'],
2: ['10:15', '11:55'],
3: ['14:00', '15:40'],
4: ['16:15', '17:55'],
5: ['19:00', '20:40'],
6: ['20:50', '22:30'],
}
// handleSchedulePreviewBeforeUnload 负责在存在未应用预览时阻止页面刷新/关闭。
//
// 职责边界:
// 1. 这里只负责触发浏览器原生确认弹框,不负责展示自定义 UI。
// 2. 只有存在未应用的智能编排结果时才拦截,避免影响正常刷新体验。
function handleSchedulePreviewBeforeUnload(event: BeforeUnloadEvent) {
if (!schedulePreviewRuntimeState.weeks?.length) {
return
}
event.preventDefault()
event.returnValue = ''
}
if (typeof window !== 'undefined') {
const schedulePageWindow = window as SchedulePageWindow
if (!schedulePageWindow.__schedulePreviewBeforeUnloadRegistered__) {
window.addEventListener('beforeunload', handleSchedulePreviewBeforeUnload)
schedulePageWindow.__schedulePreviewBeforeUnloadRegistered__ = true
}
}
const router = useRouter()
const route = useRoute()
@@ -61,10 +141,19 @@ const scheduleSelectionMode = ref(false)
const selectedScheduleEventIds = ref<number[]>([])
const liveWeeks = ref<ScheduleWeekData[]>([])
const previewWeeks = ref<ScheduleWeekData[] | null>(null)
const currentWeek = ref<number | null>(null)
const previewWeeks = ref<ScheduleWeekData[] | null>(schedulePreviewRuntimeState.weeks)
const previewTaskClassIds = ref<number[]>([...schedulePreviewRuntimeState.taskClassIds])
const currentWeek = ref<number | null>(schedulePreviewRuntimeState.currentWeek)
const weekBase = ref<number | null>(null)
const baseMonday = ref<Date | null>(null)
const lastStableWeekData = ref<ScheduleWeekData | null>(null)
const weekScheduleCache = ref<Record<number, ScheduleWeekData>>({})
const MIN_SCHEDULE_WEEK = 1
const MAX_SCHEDULE_WEEK = 24
let weekRequestSequence = 0
let activeWeekRequestSequence = 0
const activeSidebarKey = computed<SidebarItem['key']>(() => {
if (route.path.startsWith('/assistant')) {
@@ -84,16 +173,46 @@ const effectiveSelectedTaskClassIds = computed(() => {
return expandedTaskClassId.value ? [expandedTaskClassId.value] : []
})
const currentWeekData = computed(() => {
const source = previewWeeks.value ?? liveWeeks.value
if (!source.length) {
const previewWeekLookup = computed(() => {
const map = new Map<number, ScheduleWeekData>()
for (const item of previewWeeks.value ?? []) {
map.set(item.week, item)
}
return map
})
const liveWeekLookup = computed(() => {
const map = new Map<number, ScheduleWeekData>()
for (const item of liveWeeks.value) {
map.set(item.week, item)
}
return map
})
const hasPendingPreview = computed(() => Boolean(previewWeeks.value?.length))
const resolvedCurrentWeekData = computed(() => {
if (!previewWeeks.value?.length && !liveWeeks.value.length) {
return null
}
const targetWeek = currentWeek.value ?? source[0].week
return source.find((item) => item.week === targetWeek) ?? source[0]
if (currentWeek.value === null) {
return previewWeeks.value?.[0] ?? liveWeeks.value[0] ?? null
}
return previewWeekLookup.value.get(currentWeek.value)
?? liveWeekLookup.value.get(currentWeek.value)
?? null
})
const currentWeekData = computed(() =>
resolvedCurrentWeekData.value ?? lastStableWeekData.value,
)
const weekHeaders = computed<WeekDayHeader[]>(() => {
const weekdayMap = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -131,8 +250,15 @@ const showDeleteModeButton = computed(() =>
const showApplyButton = computed(() =>
!scheduleSelectionMode.value &&
Boolean(previewWeeks.value?.length) &&
effectiveSelectedTaskClassIds.value.length === 1,
hasPendingPreview.value,
)
const canGoPreviousWeek = computed(() =>
currentWeek.value !== null && currentWeek.value > MIN_SCHEDULE_WEEK,
)
const canGoNextWeek = computed(() =>
currentWeek.value !== null && currentWeek.value < MAX_SCHEDULE_WEEK,
)
function handleSidebarNavigate(item: SidebarItem) {
@@ -178,6 +304,277 @@ function numberToChinese(value: number) {
return `${digits[tens]}${units ? digits[units] : ''}`
}
// clampWeekIntoRange 负责把周次限制在后端允许的 1-24 周内。
//
// 职责边界:
// 1. 这里只做前端边界保护,不替代后端校验。
// 2. 调用方若需要交互提示,应在函数外自行决定是否提示。
function clampWeekIntoRange(week: number) {
return Math.min(MAX_SCHEDULE_WEEK, Math.max(MIN_SCHEDULE_WEEK, week))
}
// syncLiveWeeksFromCache 负责把按周缓存拍平成页面当前使用的 liveWeeks 数组。
//
// 设计说明:
// 1. 页面内部仍按数组消费周数据,因此缓存层统一在这里做结构转换。
// 2. 固定按 week 升序输出,避免切周过快时 currentWeekData 出现不稳定回退。
function syncLiveWeeksFromCache() {
liveWeeks.value = Object.values(weekScheduleCache.value)
.sort((left, right) => left.week - right.week)
}
// cacheWeekSchedules 负责把成功请求到的周数据写入页面级本地缓存。
//
// 设计说明:
// 1. 只缓存 1-24 周的合法结果,越界数据直接丢弃,避免污染页面状态。
// 2. 相同周次直接覆盖,保证强刷后的新结果能够替换旧缓存。
function cacheWeekSchedules(weeks: ScheduleWeekData[]) {
for (const item of weeks) {
if (item.week < MIN_SCHEDULE_WEEK || item.week > MAX_SCHEDULE_WEEK) {
continue
}
weekScheduleCache.value[item.week] = item
}
syncLiveWeeksFromCache()
}
// setPreviewState 负责同步“组件内预览态”和“模块级运行时预览态”。
//
// 设计说明:
// 1. 统一从这个入口写预览,避免 ref 和模块级缓存出现一边更新、一边遗漏。
// 2. taskClassIds 记录本次预览来自哪个任务类,用于刷新前提示、回到页面后继续应用。
// 3. 这里不负责决定何时清空预览,清空策略由 clearPreviewState 和具体业务动作控制。
function setPreviewState(weeks: ScheduleWeekData[] | null, taskClassIds: number[] = []) {
previewWeeks.value = weeks
previewTaskClassIds.value = [...taskClassIds]
schedulePreviewRuntimeState.weeks = weeks
schedulePreviewRuntimeState.taskClassIds = [...taskClassIds]
}
// clearPreviewState 负责显式清空未应用的智能编排结果。
//
// 职责边界:
// 1. 这里只做状态清理,不做消息提示。
// 2. 调用方负责在“应用成功 / 删除后重算 / 用户主动替换预览”等时机决定是否清空。
function clearPreviewState() {
setPreviewState(null, [])
}
function isSuggestedPreviewEvent(event?: ScheduleWeekEvent) {
return Boolean(event && event.status === 'suggested')
}
function cloneScheduleEvent(event: ScheduleWeekEvent): ScheduleWeekEvent {
return {
...event,
embedded_task_info: event.embedded_task_info
? { ...event.embedded_task_info }
: { ...EMPTY_EMBEDDED_TASK_INFO },
}
}
// clonePreviewWeeks 负责复制 preview 周数据,确保拖拽编辑只改前端预览态,不污染原引用。
//
// 设计说明:
// 1. 这里只做前端内存数据的浅层结构复制 + 事件深复制,足够支撑拖拽改位。
// 2. 不负责校验事件语义是否合法,语义校验由后面的 move helper 负责。
function clonePreviewWeeks(weeks: ScheduleWeekData[]) {
return weeks.map((week) => ({
...week,
events: week.events.map((event) => cloneScheduleEvent(event)),
}))
}
function findPreviewEventIndex(weeks: ScheduleWeekData[], slot: SchedulePreviewSlotRef) {
const weekIndex = weeks.findIndex((week) => week.week === slot.week)
if (weekIndex < 0) {
return { weekIndex: -1, eventIndex: -1 }
}
const eventIndex = weeks[weekIndex]!.events.findIndex((event) =>
event.day_of_week === slot.dayOfWeek && event.order === slot.order)
return { weekIndex, eventIndex }
}
function buildEmptyPreviewEvent(slot: SchedulePreviewSlotRef): ScheduleWeekEvent {
const [startTime, endTime] = SCHEDULE_SECTION_TIME_MAP[slot.order] ?? ['', '']
return {
id: 0,
order: slot.order,
day_of_week: slot.dayOfWeek,
name: '空白',
start_time: startTime,
end_time: endTime,
location: '',
type: 'empty',
span: 2,
status: 'normal',
embedded_task_info: { ...EMPTY_EMBEDDED_TASK_INFO },
}
}
function extractSuggestedPreviewItem(event?: ScheduleWeekEvent): SuggestedPreviewItem | null {
if (!isSuggestedPreviewEvent(event)) {
return null
}
if (event!.type === 'course' && event!.embedded_task_info?.id) {
return {
id: event!.embedded_task_info.id,
name: event!.embedded_task_info.name,
type: event!.embedded_task_info.type || 'task',
span: event!.span || 2,
}
}
return {
id: event!.id,
name: event!.name,
type: event!.type || 'task',
span: event!.span || 2,
}
}
function canDropSuggestedIntoCell(event?: ScheduleWeekEvent) {
if (!event || event.type === 'empty') {
return true
}
if (event.status === 'suggested') {
return true
}
return event.type === 'course'
}
// buildPreviewEventWithSuggested 负责把“某个格子的底板”与“某个 suggested 任务”重新拼装成最终事件。
//
// 职责边界:
// 1. 课程格收到 suggested 时,转换为“课程 + embedded_task_info + suggested 状态”。
// 2. 空白格收到 suggested 时,转换为独立 task 建议块。
// 3. 不带 suggested 时,会把格子还原成普通课程或空白格,保证拖拽前后 JSON 与画面一致。
function buildPreviewEventWithSuggested(
baseEvent: ScheduleWeekEvent | undefined,
slot: SchedulePreviewSlotRef,
suggestedItem: SuggestedPreviewItem | null,
): ScheduleWeekEvent {
if (baseEvent?.type === 'course') {
return {
...cloneScheduleEvent(baseEvent),
status: suggestedItem ? 'suggested' : 'normal',
embedded_task_info: suggestedItem
? {
id: suggestedItem.id,
name: suggestedItem.name,
type: suggestedItem.type || 'task',
}
: { ...EMPTY_EMBEDDED_TASK_INFO },
}
}
if (!suggestedItem) {
return buildEmptyPreviewEvent(slot)
}
const [startTime, endTime] = SCHEDULE_SECTION_TIME_MAP[slot.order] ?? ['', '']
return {
id: suggestedItem.id,
order: slot.order,
day_of_week: slot.dayOfWeek,
name: suggestedItem.name,
start_time: startTime,
end_time: endTime,
location: '',
type: suggestedItem.type || 'task',
span: suggestedItem.span || 2,
status: 'suggested',
embedded_task_info: { ...EMPTY_EMBEDDED_TASK_INFO },
}
}
function replacePreviewEventAtSlot(
weeks: ScheduleWeekData[],
slot: SchedulePreviewSlotRef,
nextEvent: ScheduleWeekEvent,
) {
const { weekIndex, eventIndex } = findPreviewEventIndex(weeks, slot)
if (weekIndex < 0) {
return
}
if (eventIndex >= 0) {
weeks[weekIndex]!.events[eventIndex] = nextEvent
} else {
weeks[weekIndex]!.events.push(nextEvent)
}
weeks[weekIndex]!.events.sort((left, right) => {
if (left.day_of_week !== right.day_of_week) {
return left.day_of_week - right.day_of_week
}
return left.order - right.order
})
}
// handleMovePreviewEvent 负责把用户拖拽后的 suggested 位置回写到前端预览 JSON。
//
// 处理步骤:
// 1. 只允许修改当前内存中的 previewWeeks正式课表与后端缓存都不在这里改。
// 2. 源格必须是 suggested目标格允许是空白、课程或另一个 suggested交换
// 3. 修改完成后立即回写 setPreviewState保证界面显示与最终 apply 用到的 JSON 完全一致。
function handleMovePreviewEvent(payload: PreviewMovePayload) {
if (!previewWeeks.value?.length) {
return
}
const sourceSlot: SchedulePreviewSlotRef = {
week: payload.week,
dayOfWeek: payload.sourceDayOfWeek,
order: payload.sourceOrder,
}
const targetSlot: SchedulePreviewSlotRef = {
week: payload.week,
dayOfWeek: payload.targetDayOfWeek,
order: payload.targetOrder,
}
if (
sourceSlot.dayOfWeek === targetSlot.dayOfWeek &&
sourceSlot.order === targetSlot.order
) {
return
}
const nextWeeks = clonePreviewWeeks(previewWeeks.value)
const sourceLocate = findPreviewEventIndex(nextWeeks, sourceSlot)
const targetLocate = findPreviewEventIndex(nextWeeks, targetSlot)
if (sourceLocate.weekIndex < 0 || sourceLocate.eventIndex < 0) {
return
}
const sourceEvent = nextWeeks[sourceLocate.weekIndex]!.events[sourceLocate.eventIndex]
const targetEvent = targetLocate.weekIndex >= 0 && targetLocate.eventIndex >= 0
? nextWeeks[targetLocate.weekIndex]!.events[targetLocate.eventIndex]
: undefined
const sourceSuggestedItem = extractSuggestedPreviewItem(sourceEvent)
if (!sourceSuggestedItem || !canDropSuggestedIntoCell(targetEvent)) {
return
}
const targetSuggestedItem = extractSuggestedPreviewItem(targetEvent)
const nextSourceEvent = buildPreviewEventWithSuggested(sourceEvent, sourceSlot, targetSuggestedItem)
const nextTargetEvent = buildPreviewEventWithSuggested(targetEvent, targetSlot, sourceSuggestedItem)
replacePreviewEventAtSlot(nextWeeks, sourceSlot, nextSourceEvent)
replacePreviewEventAtSlot(nextWeeks, targetSlot, nextTargetEvent)
setPreviewState(nextWeeks, previewTaskClassIds.value)
}
async function loadTaskClasses() {
taskClassLoading.value = true
try {
@@ -189,26 +586,55 @@ async function loadTaskClasses() {
}
}
async function loadWeekData(week?: number) {
async function loadWeekData(week?: number, options: { force?: boolean } = {}) {
const normalizedWeek = typeof week === 'number' ? clampWeekIntoRange(week) : undefined
// 1. 已缓存的周直接命中本地数据,避免左右来回切周时重复请求后端。
// 2. force 只用于“应用/删除后刷新当前周”,此时必须跳过缓存回源拿最新结果。
if (typeof normalizedWeek === 'number' && !options.force) {
const cachedWeek = weekScheduleCache.value[normalizedWeek]
if (cachedWeek) {
syncLiveWeeksFromCache()
currentWeek.value = normalizedWeek
return
}
}
// 1. 每次真实请求都分配一个递增序号。
// 2. 只有“当前最新”的那次请求才允许修改 loading 与当前展示周。
// 3. 旧请求如果晚回,只允许悄悄写缓存,不能把页面状态回滚。
const requestSequence = ++weekRequestSequence
activeWeekRequestSequence = requestSequence
weekLoading.value = true
try {
const result = await getWeekSchedule(week)
liveWeeks.value = result
const result = await getWeekSchedule(normalizedWeek)
cacheWeekSchedules(result)
if (result[0]?.week && weekBase.value === null) {
weekBase.value = result[0].week
baseMonday.value = startOfWeek(new Date())
}
if (typeof week === 'number') {
currentWeek.value = week
if (requestSequence !== activeWeekRequestSequence) {
return
}
if (typeof normalizedWeek === 'number') {
currentWeek.value = normalizedWeek
} else if (result[0]?.week) {
currentWeek.value = result[0].week
}
} catch (error) {
if (requestSequence !== activeWeekRequestSequence) {
return
}
ElMessage.error(error instanceof Error ? error.message : '周日程加载失败')
} finally {
weekLoading.value = false
if (requestSequence === activeWeekRequestSequence) {
weekLoading.value = false
}
}
}
@@ -234,7 +660,6 @@ async function handleActivateTaskClass(taskClassId: number) {
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
previewWeeks.value = null
if (expandedTaskClassId.value === taskClassId) {
expandedTaskClassId.value = null
@@ -249,7 +674,6 @@ async function handleActivateTaskClass(taskClassId: number) {
function handleToggleTaskClassMultiMode() {
taskClassMultiSelectMode.value = !taskClassMultiSelectMode.value
previewWeeks.value = null
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
@@ -285,9 +709,11 @@ async function handleSmartPlanning() {
smartPlanningLoading.value = true
try {
previewWeeks.value = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
if (previewWeeks.value[0]?.week) {
currentWeek.value = previewWeeks.value[0].week
const plannedWeeks = ids.length === 1 ? await smartPlanning(ids[0]!) : await smartPlanningMulti(ids)
setPreviewState(plannedWeeks, ids)
if (plannedWeeks[0]?.week) {
currentWeek.value = plannedWeeks[0].week
}
ElMessage.success(ids.length === 1 ? '已生成粗排预览' : '已生成批量粗排预览')
} catch (error) {
@@ -326,8 +752,8 @@ async function handleDeleteSelectedScheduleEvents() {
ElMessage.success('已完成解除安排')
scheduleSelectionMode.value = false
selectedScheduleEventIds.value = []
previewWeeks.value = null
await loadWeekData(currentWeek.value ?? undefined)
clearPreviewState()
await loadWeekData(currentWeek.value ?? undefined, { force: true })
await loadTaskClasses()
if (expandedTaskClassId.value) {
await loadTaskClassDetail(expandedTaskClassId.value)
@@ -367,24 +793,82 @@ function buildApplyItemsFromPreview(weeks: ScheduleWeekData[]) {
return items
}
async function handleApplyPreview() {
if (!previewWeeks.value?.length || effectiveSelectedTaskClassIds.value.length !== 1) {
ElMessage.info('当前预览暂不支持正式应用')
return
// buildApplyGroupsFromPreview 负责把当前预览里的 suggested 任务按“所属任务类”重新分组。
//
// 处理步骤:
// 1. 单任务类预览直接复用现有单接口,不做额外解析。
// 2. 多任务类预览先拉各任务类详情,建立 task_item_id -> task_class_id 映射。
// 3. 再把预览中的建议项按任务类分桶,后续逐桶调用现有 applyBatchIntoSchedule。
//
// 说明:
// 1. 这里不改 preview JSON只负责为“正式应用”准备提交载荷。
// 2. 如果发现某个 suggested 任务找不到所属任务类,直接报错,避免提交错桶导致脏数据。
async function buildApplyGroupsFromPreview(
weeks: ScheduleWeekData[],
taskClassIds: number[],
) {
const items = buildApplyItemsFromPreview(weeks)
const groupedItems = new Map<number, ApplyBatchIntoScheduleItem[]>()
if (items.length === 0) {
return groupedItems
}
const items = buildApplyItemsFromPreview(previewWeeks.value)
if (!items.length) {
ElMessage.info('当前预览没有可应用的建议排程')
if (taskClassIds.length === 1) {
groupedItems.set(taskClassIds[0]!, items)
return groupedItems
}
const ownerMap = new Map<number, number>()
const details = await Promise.all(taskClassIds.map(async (taskClassId) => ({
taskClassId,
detail: await getTaskClassDetail(taskClassId),
})))
for (const { taskClassId, detail } of details) {
for (const item of detail.items) {
if (typeof item.id === 'number' && item.id > 0) {
ownerMap.set(item.id, taskClassId)
}
}
}
for (const item of items) {
const ownerTaskClassId = ownerMap.get(item.task_item_id)
if (!ownerTaskClassId) {
throw new Error(`未找到任务块 ${item.task_item_id} 对应的任务类,无法正式应用批量粗排结果`)
}
if (!groupedItems.has(ownerTaskClassId)) {
groupedItems.set(ownerTaskClassId, [])
}
groupedItems.get(ownerTaskClassId)!.push(item)
}
return groupedItems
}
async function handleApplyPreview() {
if (!previewWeeks.value?.length || previewTaskClassIds.value.length === 0) {
ElMessage.info('当前没有可正式应用的预览结果')
return
}
applyingLoading.value = true
try {
await applyBatchIntoSchedule(effectiveSelectedTaskClassIds.value[0]!, items)
ElMessage.success('已正式应用到日程')
previewWeeks.value = null
await loadWeekData(currentWeek.value ?? undefined)
const groupedItems = await buildApplyGroupsFromPreview(previewWeeks.value, previewTaskClassIds.value)
if (!groupedItems.size) {
ElMessage.info('当前预览没有可应用的建议排程')
return
}
for (const [taskClassId, items] of groupedItems) {
await applyBatchIntoSchedule(taskClassId, items)
}
ElMessage.success(previewTaskClassIds.value.length > 1 ? '已正式应用批量粗排结果' : '已正式应用到日程')
clearPreviewState()
await loadWeekData(currentWeek.value ?? undefined, { force: true })
await loadTaskClasses()
if (expandedTaskClassId.value) {
await loadTaskClassDetail(expandedTaskClassId.value)
@@ -411,14 +895,14 @@ async function handleCreateTaskClass(payload: Parameters<typeof createTaskClass>
}
function goPreviousWeek() {
if (currentWeek.value === null) {
if (currentWeek.value === null || currentWeek.value <= MIN_SCHEDULE_WEEK) {
return
}
currentWeek.value -= 1
}
function goNextWeek() {
if (currentWeek.value === null) {
if (currentWeek.value === null || currentWeek.value >= MAX_SCHEDULE_WEEK) {
return
}
currentWeek.value += 1
@@ -429,19 +913,33 @@ watch(currentWeek, async (nextWeek, previousWeek) => {
return
}
const normalizedWeek = clampWeekIntoRange(nextWeek)
if (normalizedWeek !== nextWeek) {
currentWeek.value = normalizedWeek
return
}
if (previewWeeks.value?.some((item) => item.week === nextWeek)) {
return
}
if (previewWeeks.value) {
previewWeeks.value = null
}
await loadWeekData(nextWeek)
await loadWeekData(normalizedWeek)
})
watch(currentWeek, (nextWeek) => {
schedulePreviewRuntimeState.currentWeek = nextWeek
}, { immediate: true })
watch(resolvedCurrentWeekData, (nextWeekData) => {
// 1. 只有真正解析到“当前目标周”的数据时,才更新稳定展示态。
// 2. 当目标周仍在请求中时,这里保持旧值不动,避免课表闪回到别的周。
if (nextWeekData) {
lastStableWeekData.value = nextWeekData
}
}, { immediate: true })
onMounted(async () => {
await Promise.all([loadTaskClasses(), loadWeekData()])
await Promise.all([loadTaskClasses(), loadWeekData(currentWeek.value ?? undefined)])
})
</script>
@@ -535,6 +1033,7 @@ onMounted(async () => {
<button
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--ghost"
:disabled="!canGoPreviousWeek"
@click="goPreviousWeek"
>
上一周
@@ -542,6 +1041,7 @@ onMounted(async () => {
<button
type="button"
class="schedule-board__toolbar-button schedule-board__toolbar-button--primary"
:disabled="!canGoNextWeek"
@click="goNextWeek"
>
下一周
@@ -555,7 +1055,9 @@ onMounted(async () => {
:week-data="currentWeekData"
:schedule-selection-mode="scheduleSelectionMode"
:selected-schedule-event-ids="selectedScheduleEventIds"
:preview-drag-enabled="hasPendingPreview"
@toggle-schedule-event="handleToggleScheduleEvent"
@move-preview-event="handleMovePreviewEvent"
/>
<div v-if="showApplyButton || scheduleSelectionMode" class="schedule-board__footer">