自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。

任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
2026-07-27 21:40:14 +08:00 Unverified
parent 9d9f54a3b0
commit 9e0d4062a4
29 changed files with 6386 additions and 45 deletions
+104 -8
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 { Delete, Download, Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
@@ -26,6 +26,14 @@ const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false)
const arrangeLoading = ref(false)
const arrangementJob = ref<any | null>(null)
let arrangementPollTimer: ReturnType<typeof setTimeout> | null = null
const arrangementJobRunning = computed(() =>
['Queued', 'Running'].includes(arrangementJob.value?.status))
const arrangementProgress = computed(() =>
arrangementJob.value?.status === 'Queued' ? 10
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
const exportLoading = ref(false)
const removeLoading = ref(false)
const deletePlanLoading = ref(false)
@@ -127,10 +135,14 @@ async function load() {
finally { loading.value = false }
}
async function selectPlan(id: string) {
stopArrangementPolling()
sessionPage.value = 1
selectedSessionIds.value = []
await loadSelectedPlan(id)
await loadPlanResources(selected.value.academicTermId)
await Promise.all([
loadPlanResources(selected.value.academicTermId),
restoreArrangementJob(id),
])
}
async function loadSelectedPlan(id: string) {
sessionLoading.value = true
@@ -359,12 +371,75 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms',
})
arrangementJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台编排',
}
ElMessage.success(res.data.message)
await reloadSelectedPlan()
scheduleArrangementPoll(true)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function restoreArrangementJob(planId: string) {
try {
const res = await http.get(`/exams/plans/${planId}/arrangement-job`)
if (res.status === 204 || !res.data ||
!['Queued', 'Running'].includes(res.data.status)) {
arrangementJob.value = null
return
}
arrangementJob.value = res.data
scheduleArrangementPoll(false)
} catch {
arrangementJob.value = null
}
}
function scheduleArrangementPoll(notifyTerminal: boolean) {
stopArrangementPolling()
arrangementPollTimer = setTimeout(
() => pollArrangementJob(notifyTerminal),
1500,
)
}
async function pollArrangementJob(notifyTerminal: boolean) {
if (!arrangementJob.value?.id) return
const jobId = arrangementJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(
`/exams/arrangement-jobs/${jobId}`,
)
if (selected.value?.id !== planId ||
arrangementJob.value?.id !== jobId) return
arrangementJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleArrangementPoll(notifyTerminal)
return
}
stopArrangementPolling()
if (job.status === 'Succeeded') {
selectedSessionIds.value = []
await reloadSelectedPlan()
if (notifyTerminal) ElMessage.success(job.resultMessage || '考试编排完成')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '考试编排失败')
}
} catch {
if (selected.value?.id === planId &&
arrangementJob.value?.id === jobId) {
scheduleArrangementPoll(notifyTerminal)
}
}
}
function stopArrangementPolling() {
if (arrangementPollTimer) {
clearTimeout(arrangementPollTimer)
arrangementPollTimer = null
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
@@ -452,6 +527,7 @@ onMounted(async () => {
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
onBeforeUnmount(stopArrangementPolling)
</script>
<template>
@@ -487,21 +563,41 @@ onMounted(async () => {
:disabled="planSessionTotal === 0"
@click="exportSignInSheets"
>导出考场签名单</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
<el-button
v-if="selected.status === 'Draft'"
type="danger"
plain
:icon="Delete"
:loading="deletePlanLoading"
:disabled="arrangementJobRunning"
@click="deleteDraftPlan"
>删除草稿</el-button>
</div>
</header>
<el-alert
v-if="arrangementJob"
:title="arrangementJob.status === 'Failed' ? '后台编排失败' : arrangementJob.status === 'Succeeded' ? '后台编排完成' : '后台正在编排考试'"
:type="arrangementJob.status === 'Failed' ? 'error' : arrangementJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ arrangementJob.errorMessage || arrangementJob.resultMessage || arrangementJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="arrangementJobRunning"
:percentage="arrangementProgress"
:show-text="false"
:stroke-width="6"
:indeterminate="arrangementJob.status === 'Running'"
/>
</template>
</el-alert>
<div class="exam-filter-bar">
<el-input
v-model="sessionFilter.keyword"
+109 -9
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 { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -25,6 +25,14 @@ const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false)
const arrangeLoading = ref(false)
const arrangementJob = ref<any | null>(null)
let arrangementPollTimer: ReturnType<typeof setTimeout> | null = null
const arrangementJobRunning = computed(() =>
['Queued', 'Running'].includes(arrangementJob.value?.status))
const arrangementProgress = computed(() =>
arrangementJob.value?.status === 'Queued' ? 10
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
const planDialog = ref(false)
const sessionDialog = ref(false)
const editingSession = ref<any | null>(null)
@@ -119,9 +127,13 @@ async function load() {
finally { loading.value = false }
}
async function selectPlan(id: string) {
stopArrangementPolling()
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
selectedSessionIds.value = []
await loadPlanResources(selected.value.academicTermId)
await Promise.all([
loadPlanResources(selected.value.academicTermId),
restoreArrangementJob(id),
])
}
async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([
@@ -246,12 +258,77 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms',
})
arrangementJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台编排',
}
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
scheduleArrangementPoll(true)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function restoreArrangementJob(planId: string) {
try {
const res = await http.get(
`/makeup-exams/plans/${planId}/arrangement-job`,
)
if (res.status === 204 || !res.data ||
!['Queued', 'Running'].includes(res.data.status)) {
arrangementJob.value = null
return
}
arrangementJob.value = res.data
scheduleArrangementPoll(false)
} catch {
arrangementJob.value = null
}
}
function scheduleArrangementPoll(notifyTerminal: boolean) {
stopArrangementPolling()
arrangementPollTimer = setTimeout(
() => pollArrangementJob(notifyTerminal),
1500,
)
}
async function pollArrangementJob(notifyTerminal: boolean) {
if (!arrangementJob.value?.id) return
const jobId = arrangementJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(
`/makeup-exams/arrangement-jobs/${jobId}`,
)
if (selected.value?.id !== planId ||
arrangementJob.value?.id !== jobId) return
arrangementJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleArrangementPoll(notifyTerminal)
return
}
stopArrangementPolling()
if (job.status === 'Succeeded') {
const planId = selected.value?.id
if (planId) await selectPlan(planId)
if (notifyTerminal) ElMessage.success(job.resultMessage || '补考编排完成')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '补考编排失败')
}
} catch {
if (selected.value?.id === planId &&
arrangementJob.value?.id === jobId) {
scheduleArrangementPoll(notifyTerminal)
}
}
}
function stopArrangementPolling() {
if (arrangementPollTimer) {
clearTimeout(arrangementPollTimer)
arrangementPollTimer = null
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', {
@@ -431,6 +508,10 @@ onMounted(async () => {
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
onBeforeUnmount(() => {
stopPolling()
stopArrangementPolling()
})
</script>
<template>
@@ -460,15 +541,34 @@ onMounted(async () => {
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p>
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" :disabled="arrangementJobRunning" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div>
</header>
<el-alert
v-if="arrangementJob"
:title="arrangementJob.status === 'Failed' ? '后台编排失败' : arrangementJob.status === 'Succeeded' ? '后台编排完成' : '后台正在编排补考'"
:type="arrangementJob.status === 'Failed' ? 'error' : arrangementJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ arrangementJob.errorMessage || arrangementJob.resultMessage || arrangementJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="arrangementJobRunning"
:percentage="arrangementProgress"
:show-text="false"
:stroke-width="6"
:indeterminate="arrangementJob.status === 'Running'"
/>
</template>
</el-alert>
<div v-if="autoJobId && autoJobStatus !== 'Succeeded' && autoJobStatus !== 'Failed'" style="margin-bottom: 16px">
<el-alert :title="autoJobMessage" type="info" :closable="false">
<template #default>
+1
View File
@@ -586,6 +586,7 @@ onMounted(refreshAll)
<el-option label="自动排课" value="AutomaticSchedule" />
<el-option label="课表发布" value="SchedulePublish" />
<el-option label="补考自动安排" value="MakeupExamAuto" />
<el-option label="考试与补考编排" value="ExamArrangement" />
</el-select>
<el-date-picker
v-model="jobFilter.range"