自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user