主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
+127
-20
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user