Version: 0.8.3.dev.260328
后端: 1.彻底删除原agent文件夹,并将现agent2文件夹全量重命名为agent(包括全部涉及到的文件以及文档、注释),迁移工作完美结束 2.修复了重试消息的相关逻辑问题 前端: 1.改善了一些交互体验,修复了一些bug,现在只剩少的功能了,现存的bug基本都修复完毕 全仓库: 1.更新了决策记录和README文档
This commit is contained in:
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user