1. 教室容量按 1/2 计算

- ExamArrangementService.cs: SelectRooms() 使用 Capacity / 2 选择房间、计算剩余座位和总容量
  - MakeupExamArrangementService.cs: 数据库查询用 x.Capacity >= enrolledCount * 2(等价于 Capacity/2 >=
  enrolledCount),消息显示有效座位数

  2. 教学楼限制多选

  - Domain: ExamSession 和 MakeupExamSession 新增 RequiredBuildingIds (JSON string),保留旧 RequiredBuildingId 向后兼容
  - Service: RoomGroupKey 改为字符串键确保值相等;GroupKey() 合并新旧字段
  - Controller: 请求 DTO 增加 RequiredBuildingIds (Guid 数组),响应包含该字段
  - DB: MySQL 迁移 + SQLite migrator 添加新列
  - Frontend: <el-select> 改为 multiple,新增 parseBuildingIds() 解析服务器返回的 JSON

  3. 导出签名单后台任务

  - 新增: ExamSignInExportJob 实体、ExamSignInExportJobProcessor、ExamSignInExportJobStatus 枚举
  - BackgroundJobKind: 新增 ExamSignInExport = 5
  - RabbitMQ: routing key exam.sign-in-export,队列 jiaowu.background-jobs.exam.sign-in-export
  - API: POST /sign-in-export 创建任务返回 202;GET /sign-in-exports/{jobId} 查询状态;GET
  /sign-in-exports/{jobId}/download 下载文件
  - Frontend: 导出改为异步任务 + 轮询 + 自动下载,显示进度条
  - 恢复: OutboxPublisher 启动时恢复未完成的任务,重试超限自动标记失败
This commit is contained in:
2026-07-27 22:00:35 +08:00 Unverified
parent 9e0d4062a4
commit 52e4a8a5be
24 changed files with 11220 additions and 35 deletions
+87 -9
View File
@@ -35,6 +35,10 @@ const arrangementProgress = computed(() =>
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
const exportLoading = ref(false)
const exportJob = ref<any | null>(null)
let exportPollTimer: ReturnType<typeof setTimeout> | null = null
const exportJobRunning = computed(() =>
['Queued', 'Running'].includes(exportJob.value?.status))
const removeLoading = ref(false)
const deletePlanLoading = ref(false)
const planDialog = ref(false)
@@ -136,6 +140,8 @@ async function load() {
}
async function selectPlan(id: string) {
stopArrangementPolling()
stopExportPolling()
exportJob.value = null
sessionPage.value = 1
selectedSessionIds.value = []
await loadSelectedPlan(id)
@@ -243,6 +249,7 @@ async function openSession(existing?: any) {
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredBuildingIds: parseBuildingIds(existing?.requiredBuildingIds),
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
@@ -265,6 +272,7 @@ async function saveSession() {
startPeriod: payload.startPeriod,
periodCount: payload.periodCount,
requiredBuildingId: payload.requiredBuildingId,
requiredBuildingIds: payload.requiredBuildingIds,
requiredInvigilatorCount: payload.requiredInvigilatorCount,
notes: payload.notes,
})
@@ -478,17 +486,61 @@ async function exportSignInSheets() {
if (!selected.value) return
exportLoading.value = true
try {
await downloadApiFile(
`/exams/plans/${selected.value.id}/sign-in-sheets.xlsx`,
`考场签名单-${selected.value.name}.xlsx`,
)
ElMessage.success('考场签名单已导出')
const res = await http.post(`/exams/plans/${selected.value.id}/sign-in-export`)
exportJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台生成',
}
scheduleExportPoll(true)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
exportLoading.value = false
}
}
function scheduleExportPoll(notifyTerminal: boolean) {
stopExportPolling()
exportPollTimer = setTimeout(
() => pollExportJob(notifyTerminal),
2000,
)
}
async function pollExportJob(notifyTerminal: boolean) {
if (!exportJob.value?.id) return
const jobId = exportJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(`/exams/sign-in-exports/${jobId}`)
if (selected.value?.id !== planId || exportJob.value?.id !== jobId) return
exportJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleExportPoll(notifyTerminal)
return
}
stopExportPolling()
if (job.status === 'Succeeded') {
await downloadApiFile(
`/exams/sign-in-exports/${jobId}/download`,
job.fileName || `考场签名单.xlsx`,
)
if (notifyTerminal) ElMessage.success('考场签名单已导出')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '考场签名单导出失败')
}
} catch {
if (selected.value?.id === planId && exportJob.value?.id === jobId) {
scheduleExportPoll(notifyTerminal)
}
}
}
function stopExportPolling() {
if (exportPollTimer) {
clearTimeout(exportPollTimer)
exportPollTimer = null
}
}
async function showRoster(row: any) {
try {
roster.value = (await http.get(`/exams/sessions/${row.id}/roster`)).data
@@ -506,8 +558,12 @@ function classroomLabel(room: any) {
return `${room.name} · ${room.capacity}座 · ${room.buildingName}`
}
function filteredRooms() {
if (!sessionForm.requiredBuildingId) return rooms.value
return rooms.value.filter((r: any) => r.buildingId === sessionForm.requiredBuildingId)
if (!sessionForm.requiredBuildingIds || sessionForm.requiredBuildingIds.length === 0) return rooms.value
return rooms.value.filter((r: any) => sessionForm.requiredBuildingIds.includes(r.buildingId))
}
function parseBuildingIds(raw: string | undefined | null): string[] {
if (!raw) return []
try { return JSON.parse(raw) } catch { return [] }
}
onMounted(async () => {
@@ -527,7 +583,10 @@ onMounted(async () => {
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
onBeforeUnmount(stopArrangementPolling)
onBeforeUnmount(() => {
stopArrangementPolling()
stopExportPolling()
})
</script>
<template>
@@ -598,6 +657,25 @@ onBeforeUnmount(stopArrangementPolling)
/>
</template>
</el-alert>
<el-alert
v-if="exportJob"
:title="exportJob.status === 'Failed' ? '签名单导出失败' : exportJob.status === 'Succeeded' ? '签名单导出完成' : '正在生成考场签名单'"
:type="exportJob.status === 'Failed' ? 'error' : exportJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ exportJob.errorMessage || exportJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="exportJobRunning"
:percentage="exportJob.status === 'Running' ? 60 : 10"
:show-text="false"
:stroke-width="6"
:indeterminate="exportJob.status === 'Running'"
/>
</template>
</el-alert>
<div class="exam-filter-bar">
<el-input
v-model="sessionFilter.keyword"
@@ -799,7 +877,7 @@ onBeforeUnmount(stopArrangementPolling)
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-select v-model="sessionForm.requiredBuildingIds" multiple clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
+9 -3
View File
@@ -171,6 +171,7 @@ function openSession(existing?: any) {
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredBuildingIds: parseBuildingIds(existing?.requiredBuildingIds),
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
@@ -193,6 +194,7 @@ async function saveSession() {
startPeriod: payload.startPeriod,
periodCount: payload.periodCount,
requiredBuildingId: payload.requiredBuildingId,
requiredBuildingIds: payload.requiredBuildingIds,
requiredInvigilatorCount: payload.requiredInvigilatorCount,
notes: payload.notes,
})
@@ -357,8 +359,12 @@ function classroomLabel(room: any) {
return `${room.name} · ${room.capacity}座 · ${room.buildingName}`
}
function filteredRooms() {
if (!sessionForm.requiredBuildingId) return rooms.value
return rooms.value.filter((r: any) => r.buildingId === sessionForm.requiredBuildingId)
if (!sessionForm.requiredBuildingIds || sessionForm.requiredBuildingIds.length === 0) return rooms.value
return rooms.value.filter((r: any) => sessionForm.requiredBuildingIds.includes(r.buildingId))
}
function parseBuildingIds(raw: string | undefined | null): string[] {
if (!raw) return []
try { return JSON.parse(raw) } catch { return [] }
}
function openEnrollment(session: any) {
@@ -782,7 +788,7 @@ onBeforeUnmount(() => {
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-select v-model="sessionForm.requiredBuildingIds" multiple clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>