已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。

发布接口立即返回 202 Accepted,页面轮询显示检查进度。
校验改为批量读取,减少重复数据库查询。
检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。
同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。
发布期间锁定编辑、自动排课等冲突操作。
已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
2026-07-25 12:07:03 +08:00 Unverified
parent fdca6a3edf
commit b015514115
12 changed files with 4116 additions and 81 deletions
+119 -10
View File
@@ -15,6 +15,7 @@ const constraints = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const autoJob = ref<any | null>(null)
const publishJob = ref<any | null>(null)
const planDialog = ref(false)
const cloneDialog = ref(false)
const entryDialog = ref(false)
@@ -41,6 +42,7 @@ const constraintFilters = reactive({
constraintState: undefined as string | undefined,
})
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
let publishPollTimer: ReturnType<typeof setTimeout> | undefined
const weekdays = [
{ value: 1, label: '星期一' },
@@ -71,6 +73,10 @@ const isDraft = computed(() => selected.value?.status === 'Draft')
const autoLoading = computed(() =>
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
)
const publishLoading = computed(() =>
publishJob.value?.status === 'Queued' || publishJob.value?.status === 'Running',
)
const scheduleJobLoading = computed(() => autoLoading.value || publishLoading.value)
const autoProgress = computed(() => {
if (!autoJob.value?.totalTasks) return 0
return Math.min(
@@ -94,6 +100,27 @@ const autoStatusText = computed(() => {
}
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
})
const publishProgress = computed(() => {
if (!publishJob.value?.totalSteps) return 0
return Math.min(
100,
Math.round(publishJob.value.completedSteps / publishJob.value.totalSteps * 100),
)
})
const publishProgressStatus = computed(() => {
if (publishJob.value?.status === 'Succeeded') return 'success'
if (publishJob.value?.status === 'Failed') return 'exception'
return undefined
})
const publishStatusText = computed(() => {
if (!publishJob.value) return ''
if (publishJob.value.status === 'Queued') return '发布任务已进入队列,等待后台检查'
if (publishJob.value.status === 'Running') {
return publishJob.value.currentStep || '正在检查并发布课表'
}
if (publishJob.value.status === 'Succeeded') return '检查已通过,课表发布成功'
return publishJob.value.errorMessage || '课表检查未通过'
})
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
@@ -402,6 +429,50 @@ async function autoSchedule() {
}
}
function clearPublishPoll() {
if (publishPollTimer) clearTimeout(publishPollTimer)
publishPollTimer = undefined
}
function schedulePublishPoll(jobId: string, planId: string) {
clearPublishPoll()
publishPollTimer = setTimeout(() => pollPublishJob(jobId, planId), 1000)
}
async function pollPublishJob(jobId: string, planId: string) {
try {
const { data } = await http.get(`/schedules/publish-jobs/${jobId}`)
if (selected.value?.id !== planId) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(jobId, planId)
return
}
clearPublishPoll()
if (data.status === 'Succeeded') {
ElMessage.success('课表检查通过,已发布')
await loadPlans()
} else {
ElMessage.error(data.errorMessage || '课表检查未通过,请调整后重试')
}
} catch {
if (selected.value?.id === planId) {
schedulePublishPoll(jobId, planId)
}
}
}
async function resumePublish(planId: string) {
clearPublishPoll()
publishJob.value = null
const { data } = await http.get(`/schedules/plans/${planId}/publish-job`)
if (selected.value?.id !== planId || !data) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(data.id, planId)
}
}
function clearAutoSchedulePoll() {
if (autoPollTimer) clearTimeout(autoPollTimer)
autoPollTimer = undefined
@@ -467,6 +538,8 @@ async function loadPlans(keepSelection = true) {
selected.value = null
clearAutoSchedulePoll()
autoJob.value = null
clearPublishPoll()
publishJob.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -480,10 +553,11 @@ async function loadDetail(id: string, resumeJob = true) {
try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data
if (resumeJob && selected.value.status === 'Draft') {
await resumeAutoSchedule(id)
await Promise.all([resumeAutoSchedule(id), resumePublish(id)])
} else if (resumeJob) {
clearAutoSchedulePoll()
autoJob.value = null
await resumePublish(id)
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -556,9 +630,11 @@ async function publishPlan() {
'发布课表',
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
)
await http.post(`/schedules/plans/${selected.value.id}/publish`)
ElMessage.success('课表已发布')
await loadPlans()
const planId = selected.value.id
const { data } = await http.post(`/schedules/plans/${planId}/publish`)
publishJob.value = data
ElMessage.success('检查并发布任务已提交,可在当前页面查看进度')
schedulePublishPoll(data.id, planId)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
@@ -677,7 +753,10 @@ onMounted(async () => {
await Promise.all([loadPlans(false), loadSchedulingSettings()])
})
onBeforeUnmount(clearAutoSchedulePoll)
onBeforeUnmount(() => {
clearAutoSchedulePoll()
clearPublishPoll()
})
</script>
<template>
@@ -722,7 +801,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" :disabled="autoLoading" @click="openPlan(selected)">编辑版本</el-button>
<el-button v-if="isDraft" :disabled="scheduleJobLoading" @click="openPlan(selected)">编辑版本</el-button>
<el-button
v-if="selected.status === 'Published'"
type="warning"
@@ -735,7 +814,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
<el-button
v-else
:icon="CopyDocument"
:disabled="autoLoading"
:disabled="scheduleJobLoading"
@click="openClone"
>
复制调整
@@ -746,13 +825,23 @@ onBeforeUnmount(clearAutoSchedulePoll)
plain
:icon="Promotion"
:loading="autoLoading"
:disabled="publishLoading"
@click="autoSchedule"
>
自动排课
</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>
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="scheduleJobLoading" @click="openEntry()">添加排课</el-button>
<el-button
v-if="isDraft"
type="success"
:icon="Promotion"
:loading="publishLoading"
:disabled="autoLoading"
@click="publishPlan"
>
{{ publishLoading ? '检查并发布中' : '发布课表' }}
</el-button>
<el-button v-if="isDraft" type="danger" plain :disabled="scheduleJobLoading" @click="deletePlan">删除草稿</el-button>
</div>
</header>
@@ -792,6 +881,26 @@ onBeforeUnmount(clearAutoSchedulePoll)
</div>
</div>
<div
v-if="publishJob"
class="auto-schedule-progress"
:class="`is-${String(publishJob.status).toLowerCase()}`"
>
<div>
<b>{{ publishStatusText }}</b>
<span v-if="publishLoading">后台运行中离开页面不会中断任务</span>
<span v-else-if="publishJob.status === 'Failed'">
草稿未发布请根据上方原因调整后重试
</span>
</div>
<el-progress
:percentage="publishProgress"
:status="publishProgressStatus"
:indeterminate="publishJob.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>