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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user