主要改动:

自动排课接口立即返回 202 + jobId
后台服务独立执行,不受浏览器关闭或前端超时影响
进度、成功、失败状态持久化到数据库
服务重启后自动恢复排队中或中断的任务
同一草稿禁止重复提交后台任务
运行期间禁止编辑、发布或删除该课表
排课结果和任务成功状态在同一事务中提交
前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
2026-07-24 21:55:07 +08:00 Unverified
parent 49b550560a
commit d67a07f23e
14 changed files with 4011 additions and 32 deletions
+127 -20
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
@@ -14,7 +14,7 @@ const timeSlots = ref<any[]>([])
const constraints = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const autoLoading = ref(false)
const autoJob = ref<any | null>(null)
const planDialog = ref(false)
const cloneDialog = ref(false)
const entryDialog = ref(false)
@@ -29,6 +29,7 @@ const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const entryForm = reactive<Record<string, any>>({})
const constraintForm = reactive<Record<string, any>>({})
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
const weekdays = [
{ value: 1, label: '星期一' },
@@ -56,6 +57,32 @@ const patternLabels: Record<string, string> = {
Even: '双周',
}
const isDraft = computed(() => selected.value?.status === 'Draft')
const autoLoading = computed(() =>
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
)
const autoProgress = computed(() => {
if (!autoJob.value?.totalTasks) return 0
return Math.min(
100,
Math.round(autoJob.value.processedTasks / autoJob.value.totalTasks * 100),
)
})
const autoProgressStatus = computed(() => {
if (autoJob.value?.status === 'Succeeded') return 'success'
if (autoJob.value?.status === 'Failed') return 'exception'
return undefined
})
const autoStatusText = computed(() => {
if (!autoJob.value) return ''
if (autoJob.value.status === 'Queued') return '任务已进入队列,等待后台执行'
if (autoJob.value.status === 'Running') {
return `正在处理 ${autoJob.value.processedTasks}/${autoJob.value.totalTasks} 个教学班,已规划 ${autoJob.value.createdEntries} 条安排`
}
if (autoJob.value.status === 'Succeeded') {
return `后台排课已完成,共生成 ${autoJob.value.createdEntries} 条安排`
}
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
})
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
@@ -196,21 +223,66 @@ async function autoSchedule() {
'开始自动排课',
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
)
autoLoading.value = true
const { data } = await http.post(`/schedules/plans/${selected.value.id}/auto-schedule`)
await loadDetail(selected.value.id)
if (data.messages.length) {
ElMessage.warning(`已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`)
} else {
ElMessage.success(`自动排课完成,共生成 ${data.createdEntries} 条安排`)
}
const planId = selected.value.id
const { data } = await http.post(`/schedules/plans/${planId}/auto-schedule`)
autoJob.value = data
ElMessage.success('自动排课任务已提交,可留在当前页面查看进度')
scheduleAutoSchedulePoll(data.id, planId)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally {
autoLoading.value = false
}
}
function clearAutoSchedulePoll() {
if (autoPollTimer) clearTimeout(autoPollTimer)
autoPollTimer = undefined
}
function scheduleAutoSchedulePoll(jobId: string, planId: string) {
clearAutoSchedulePoll()
autoPollTimer = setTimeout(() => pollAutoScheduleJob(jobId, planId), 1000)
}
async function pollAutoScheduleJob(jobId: string, planId: string) {
try {
const { data } = await http.get(`/schedules/auto-schedule-jobs/${jobId}`)
if (selected.value?.id !== planId) return
autoJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
scheduleAutoSchedulePoll(jobId, planId)
return
}
clearAutoSchedulePoll()
if (data.status === 'Succeeded') {
await loadDetail(planId, false)
const summary = plans.value.find((item) => item.id === planId)
if (summary) summary.entryCount = selected.value.entries.length
if (data.messages.length) {
ElMessage.warning(
`后台排课完成,已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`,
)
} else {
ElMessage.success(`后台排课完成,共生成 ${data.createdEntries} 条安排`)
}
} else {
ElMessage.error(data.errorMessage || '后台排课失败,请稍后重试')
}
} catch {
if (selected.value?.id === planId) {
scheduleAutoSchedulePoll(jobId, planId)
}
}
}
async function resumeAutoSchedule(planId: string) {
clearAutoSchedulePoll()
autoJob.value = null
const { data } = await http.get(`/schedules/plans/${planId}/auto-schedule-job`)
if (selected.value?.id !== planId || !data) return
autoJob.value = data
scheduleAutoSchedulePoll(data.id, planId)
}
async function loadPlans(keepSelection = true) {
loading.value = true
try {
@@ -220,8 +292,13 @@ async function loadPlans(keepSelection = true) {
const id = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (id && plans.value.some((item) => item.id === id)) await loadDetail(id)
else selected.value = null
if (id && plans.value.some((item) => item.id === id)) {
await loadDetail(id)
} else {
selected.value = null
clearAutoSchedulePoll()
autoJob.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -229,10 +306,16 @@ async function loadPlans(keepSelection = true) {
}
}
async function loadDetail(id: string) {
async function loadDetail(id: string, resumeJob = true) {
detailLoading.value = true
try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data
if (resumeJob && selected.value.status === 'Draft') {
await resumeAutoSchedule(id)
} else if (resumeJob) {
clearAutoSchedulePoll()
autoJob.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -376,6 +459,8 @@ onMounted(async () => {
termId.value = terms.value.find((item) => item.isCurrent)?.id
await Promise.all([loadPlans(false), loadSchedulingSettings()])
})
onBeforeUnmount(clearAutoSchedulePoll)
</script>
<template>
@@ -420,8 +505,8 @@ onMounted(async () => {
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" @click="openPlan(selected)">编辑版本</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制调整</el-button>
<el-button v-if="isDraft" :disabled="autoLoading" @click="openPlan(selected)">编辑版本</el-button>
<el-button :icon="CopyDocument" :disabled="autoLoading" @click="openClone">复制调整</el-button>
<el-button
v-if="isDraft"
type="warning"
@@ -432,12 +517,34 @@ onMounted(async () => {
>
自动排课
</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="autoLoading" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" :disabled="autoLoading" @click="publishPlan">发布课表</el-button>
<el-button v-if="isDraft" type="danger" plain :disabled="autoLoading" @click="deletePlan">删除草稿</el-button>
</div>
</header>
<div
v-if="autoJob"
class="auto-schedule-progress"
:class="`is-${String(autoJob.status).toLowerCase()}`"
>
<div>
<b>{{ autoStatusText }}</b>
<span v-if="autoJob.status === 'Running'">
后台运行中离开页面不会中断任务
</span>
<span v-else-if="autoJob.status === 'Succeeded' && autoJob.messages.length">
{{ autoJob.messages.length }} 个教学任务仍需人工处理
</span>
</div>
<el-progress
:percentage="autoProgress"
:status="autoProgressStatus"
:indeterminate="autoJob.status === 'Queued'"
:duration="2"
/>
</div>
<div class="schedule-search">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>