自动排考

This commit is contained in:
2026-07-27 21:27:00 +08:00 Unverified
parent c3e8a42641
commit 9d9f54a3b0
4 changed files with 443 additions and 92 deletions
+133 -40
View File
@@ -12,6 +12,11 @@ const isManager = computed(() =>
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const sessionPage = ref(1)
const sessionPageSize = ref(20)
const sessionTotal = ref(0)
const planSessionTotal = ref(0)
const sessionLoading = ref(false)
const personal = ref<any[]>([])
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
@@ -52,9 +57,10 @@ const taskColleges = computed(() => {
})
const filteredTasks = computed(() => {
const keyword = taskFilter.keyword.trim().toLowerCase()
const arrangedIds = new Set((selected.value?.sessions ?? [])
.filter((session: any) => session.id !== editingSession.value?.id)
.map((session: any) => session.teachingTaskId))
const arrangedIds = new Set<string>(selected.value?.scheduledTeachingTaskIds ?? [])
if (editingSession.value?.teachingTaskId) {
arrangedIds.delete(editingSession.value.teachingTaskId)
}
return tasks.value.filter((task: any) => {
if (arrangedIds.has(task.id)) return false
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
@@ -65,19 +71,7 @@ const filteredTasks = computed(() => {
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
const filteredSessions = computed(() => {
const keyword = sessionFilter.keyword.trim().toLowerCase()
return (selected.value?.sessions ?? []).filter((session: any) => {
if (sessionFilter.allocation === 'room' && hasRoomAssignment(session)) return false
if (sessionFilter.allocation === 'invigilator' &&
hasCompleteInvigilators(session)) return false
if (sessionFilter.allocation === 'complete' &&
(!hasRoomAssignment(session) || !hasCompleteInvigilators(session))) return false
if (!keyword) return true
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
const filteredSessions = computed(() => selected.value?.sessions ?? [])
function sessionRooms(session: any) {
return session.examRooms ?? []
@@ -85,14 +79,6 @@ function sessionRooms(session: any) {
function hasRoomAssignment(session: any) {
return sessionRooms(session).length > 0 || Boolean(session.classroomId)
}
function hasCompleteInvigilators(session: any) {
const roomAssignments = sessionRooms(session)
if (roomAssignments.length > 0) {
return roomAssignments.every((room: any) =>
room.invigilatorIds.length >= session.requiredInvigilatorCount)
}
return session.invigilatorIds.length >= session.requiredInvigilatorCount
}
function sessionInvigilatorNames(session: any) {
const roomAssignments = sessionRooms(session)
const names = roomAssignments.length > 0
@@ -131,6 +117,9 @@ async function load() {
else {
selected.value = null
selectedSessionIds.value = []
sessionPage.value = 1
sessionTotal.value = 0
planSessionTotal.value = 0
tasks.value = []
timeSlots.value = []
}
@@ -138,10 +127,63 @@ async function load() {
finally { loading.value = false }
}
async function selectPlan(id: string) {
selected.value = (await http.get(`/exams/plans/${id}`)).data
sessionPage.value = 1
selectedSessionIds.value = []
await loadSelectedPlan(id)
await loadPlanResources(selected.value.academicTermId)
}
async function loadSelectedPlan(id: string) {
sessionLoading.value = true
try {
const { data } = await http.get(`/exams/plans/${id}`, {
params: {
page: sessionPage.value,
pageSize: sessionPageSize.value,
keyword: sessionFilter.keyword.trim() || undefined,
allocation: sessionFilter.allocation || undefined,
},
})
selected.value = data
sessionPage.value = data.sessionPage
sessionPageSize.value = data.sessionPageSize
sessionTotal.value = data.filteredSessionCount
planSessionTotal.value = data.totalSessionCount
const planSummary = plans.value.find((plan: any) => plan.id === data.id)
if (planSummary) planSummary.sessionCount = data.totalSessionCount
} finally {
sessionLoading.value = false
}
}
async function reloadSelectedPlan() {
if (!selected.value) return
await loadSelectedPlan(selected.value.id)
}
async function applySessionFilters() {
if (!selected.value) return
sessionPage.value = 1
try {
await reloadSelectedPlan()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function changeSessionPage(page: number) {
sessionPage.value = page
try {
await reloadSelectedPlan()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function changeSessionPageSize(pageSize: number) {
sessionPageSize.value = pageSize
sessionPage.value = 1
try {
await reloadSelectedPlan()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([
http.get('/teaching-tasks/options', {
@@ -218,14 +260,15 @@ async function saveSession() {
}
sessionDialog.value = false
editingSession.value = null
await selectPlan(selected.value.id)
await reloadSelectedPlan()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeSession(row: any) {
try {
await ElMessageBox.confirm(`移除"${row.courseName}"考试场次?`, '移除场次', { type: 'warning' })
await http.delete(`/exams/plans/${selected.value.id}/sessions/${row.id}`)
await selectPlan(selected.value.id)
selectedSessionIds.value = selectedSessionIds.value.filter(id => id !== row.id)
await reloadSelectedPlan()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
@@ -252,7 +295,8 @@ async function removeSelectedSessions() {
{ sessionIds: selectedSessionIds.value },
)
ElMessage.success(`已移除 ${res.data.removedCount} 个考试场次`)
await selectPlan(selected.value.id)
selectedSessionIds.value = []
await reloadSelectedPlan()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally {
@@ -279,7 +323,14 @@ function toggleTaskSelection(id: string, checked: boolean) {
: selectedTaskIds.value.filter(value => value !== id)
}
function selectFilteredSessions() {
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
const selectedIds = Array.from(new Set([
...selectedSessionIds.value,
...filteredSessions.value.map((session: any) => session.id),
]))
if (selectedIds.length > 100) {
ElMessage.warning('一次最多选择100个考试场次')
}
selectedSessionIds.value = selectedIds.slice(0, 100)
}
function clearSessionSelection() {
selectedSessionIds.value = []
@@ -309,7 +360,7 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
assignInvigilators: mode !== 'rooms',
})
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
await reloadSelectedPlan()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
@@ -329,7 +380,7 @@ async function deleteDraftPlan() {
if (!selected.value || selected.value.status !== 'Draft') return
try {
await ElMessageBox.confirm(
`确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${selected.value.sessions.length} 个考试场次也会一并删除,此操作无法撤销。`,
`确定删除草稿考试计划“${selected.value.name}”吗?计划内 ${planSessionTotal.value} 个考试场次也会一并删除,此操作无法撤销。`,
'删除考试计划',
{
type: 'warning',
@@ -427,13 +478,13 @@ onMounted(async () => {
<div>
<span>EXAM TIMELINE</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
<p>{{ selected.termName }} · {{ planSessionTotal }} 个考试场次</p>
</div>
<div class="exam-actions">
<el-button
:icon="Download"
:loading="exportLoading"
:disabled="selected.sessions.length === 0"
:disabled="planSessionTotal === 0"
@click="exportSignInSheets"
>导出考场签名单</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
@@ -452,15 +503,32 @@ onMounted(async () => {
</div>
</header>
<div class="exam-filter-bar">
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
<el-input
v-model="sessionFilter.keyword"
clearable
placeholder="筛选课程、课程号或教学班号"
@keyup.enter="applySessionFilters"
@clear="applySessionFilters"
/>
<el-select
v-model="sessionFilter.allocation"
clearable
placeholder="全部分配状态"
@change="applySessionFilters"
>
<el-option label="待分配考场" value="room" />
<el-option label="待补足监考" value="invigilator" />
<el-option label="已完成分配" value="complete" />
</el-select>
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
<el-button :loading="sessionLoading" @click="applySessionFilters">查询</el-button>
<span>
本页 {{ filteredSessions.length }} {{ sessionTotal }} 个符合条件
<template v-if="sessionTotal !== planSessionTotal">
计划共 {{ planSessionTotal }}
</template>
</span>
<template v-if="selected.status === 'Draft'">
<el-button link type="primary" @click="selectFilteredSessions">选择筛选结果</el-button>
<el-button link type="primary" @click="selectFilteredSessions">选择本页</el-button>
<el-button link @click="clearSessionSelection">清空选择</el-button>
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} </el-tag>
<el-button
@@ -473,7 +541,7 @@ onMounted(async () => {
>批量移除已选场次</el-button>
</template>
</div>
<div class="exam-timeline">
<div class="exam-timeline" v-loading="sessionLoading">
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !hasRoomAssignment(session) }">
<time>
<el-checkbox
@@ -513,9 +581,27 @@ onMounted(async () => {
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
</div>
</article>
<el-empty v-if="!selected.sessions.length" description="尚未安排考试场次,点击「批量安排场次」开始。" />
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的考试场次" />
<el-empty
v-if="planSessionTotal === 0"
description="尚未安排考试场次,点击「批量安排场次」开始。"
/>
<el-empty
v-else-if="sessionTotal === 0"
description="没有符合筛选条件的考试场次"
/>
</div>
<el-pagination
v-if="sessionTotal > 0"
class="exam-pagination"
background
:current-page="sessionPage"
:page-size="sessionPageSize"
:page-sizes="[10, 20, 50, 100]"
:total="sessionTotal"
layout="total, sizes, prev, pager, next"
@current-change="changeSessionPage"
@size-change="changeSessionPageSize"
/>
</section>
</template>
@@ -704,6 +790,10 @@ onMounted(async () => {
display: block;
margin: 3px 0;
}
.exam-pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 720px) {
.exam-actions {
justify-content: flex-start;
@@ -715,5 +805,8 @@ onMounted(async () => {
align-items: flex-start;
flex-direction: column;
}
.exam-pagination {
justify-content: center;
}
}
</style>