已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。
发布接口立即返回 202 Accepted,页面轮询显示检查进度。 校验改为批量读取,减少重复数据库查询。 检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。 同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。 发布期间锁定编辑、自动排课等冲突操作。 已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
+119
-10
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user