排课
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 32m28s

This commit is contained in:
2026-07-27 09:55:22 +08:00 Unverified
parent 2f5c6af37c
commit 727a429abf
7 changed files with 1091 additions and 108 deletions
+230 -20
View File
@@ -25,11 +25,55 @@ const sessionDialog = ref(false)
const editingSession = ref<any | null>(null)
const rosterDrawer = ref(false)
const roster = ref<any | null>(null)
const selectedTaskIds = ref<string[]>([])
const selectedSessionIds = ref<string[]>([])
const planForm = reactive<Record<string, any>>({})
const sessionForm = reactive<Record<string, any>>({})
const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' })
const sessionFilter = reactive({ keyword: '', allocation: '' })
const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
const courseNatureLabels: Record<string, string> = {
GeneralRequired: '公共必修',
GeneralElective: '公共选修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
Practice: '实践教学',
}
const taskColleges = computed(() => {
const values = new Map<string, string>()
tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName))
return Array.from(values, ([id, name]) => ({ id, name }))
})
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))
return tasks.value.filter((task: any) => {
if (arrangedIds.has(task.id)) return false
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false
if (!keyword) return true
return [task.taskNumber, task.courseCode, task.courseName, task.name,
...(task.teacherNames ?? []), ...(task.classNames ?? [])]
.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' && session.classroomId) return false
if (sessionFilter.allocation === 'invigilator' &&
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false
if (sessionFilter.allocation === 'complete' &&
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false
if (!keyword) return true
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
@@ -63,6 +107,18 @@ async function load() {
}
async function selectPlan(id: string) {
selected.value = (await http.get(`/exams/plans/${id}`)).data
selectedSessionIds.value = []
await loadPlanResources(selected.value.academicTermId)
}
async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([
http.get('/teaching-tasks/options', {
params: { academicTermId, status: 'Published' },
}),
http.get('/exams/time-slots-for-term', { params: { academicTermId } }),
])
tasks.value = taskRes.data
timeSlots.value = slotRes.data
}
function openPlan() {
Object.assign(planForm, {
@@ -80,6 +136,8 @@ async function savePlan() {
}
function openSession(existing?: any) {
editingSession.value = existing ?? null
selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
const firstSlot = timeSlots.value[0]
Object.assign(sessionForm, {
teachingTaskId: existing?.teachingTaskId ?? undefined,
@@ -100,7 +158,20 @@ async function saveSession() {
if (editingSession.value) {
await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
} else {
await http.post(`/exams/plans/${selected.value.id}/sessions`, payload)
if (selectedTaskIds.value.length === 0) {
ElMessage.warning('请至少选择一个教学班')
return
}
const res = await http.post(`/exams/plans/${selected.value.id}/sessions/batch`, {
teachingTaskIds: selectedTaskIds.value,
examDate: payload.examDate,
startPeriod: payload.startPeriod,
periodCount: payload.periodCount,
requiredBuildingId: payload.requiredBuildingId,
requiredInvigilatorCount: payload.requiredInvigilatorCount,
notes: payload.notes,
})
ElMessage.success(`已批量创建 ${res.data.createdCount} 个考试场次`)
}
sessionDialog.value = false
editingSession.value = null
@@ -116,13 +187,52 @@ async function removeSession(row: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function autoArrange() {
function selectFilteredTasks() {
selectedTaskIds.value = Array.from(new Set([
...selectedTaskIds.value,
...filteredTasks.value.map((task: any) => task.id),
])).slice(0, 100)
}
function clearFilteredTasks() {
const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id))
selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id))
}
function toggleTaskSelection(id: string, checked: boolean) {
if (checked && selectedTaskIds.value.length >= 100) {
ElMessage.warning('一次最多选择100个教学班')
return
}
selectedTaskIds.value = checked
? Array.from(new Set([...selectedTaskIds.value, id]))
: selectedTaskIds.value.filter(value => value !== id)
}
function selectFilteredSessions() {
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
}
function clearSessionSelection() {
selectedSessionIds.value = []
}
function toggleSessionSelection(id: string, checked: boolean) {
selectedSessionIds.value = checked
? Array.from(new Set([...selectedSessionIds.value, id]))
: selectedSessionIds.value.filter(value => value !== id)
}
async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
try {
const target = selectedSessionIds.value.length
? `所选 ${selectedSessionIds.value.length} 个场次`
: '当前计划全部场次'
const action = mode === 'rooms' ? '分配考场'
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
await ElMessageBox.confirm(
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
`系统将为${target}${action},已有安排不会被覆盖。`,
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
arrangeLoading.value = true
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`)
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, {
sessionIds: selectedSessionIds.value,
assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms',
})
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
} catch (error: any) {
@@ -164,21 +274,16 @@ function filteredRooms() {
onMounted(async () => {
try {
if (isManager.value) {
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
currentTermId ? http.get('/exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
timeSlots.value = slotRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
@@ -212,14 +317,35 @@ onMounted(async () => {
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
</div>
</header>
<div class="exam-filter-bar">
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
<el-option label="待分配考场" value="room" />
<el-option label="待补足监考" value="invigilator" />
<el-option label="已完成分配" value="complete" />
</el-select>
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
<template v-if="selected.status === 'Draft'">
<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>
</template>
</div>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
<el-checkbox
v-if="selected.status === 'Draft'"
:model-value="selectedSessionIds.includes(session.id)"
@change="toggleSessionSelection(session.id, Boolean($event))"
>选择</el-checkbox>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
@@ -245,7 +371,8 @@ 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-if="!selected.sessions.length" description="尚未安排考试场次,点击「批量安排场次」开始。" />
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的考试场次" />
</div>
</section>
</template>
@@ -260,6 +387,7 @@ onMounted(async () => {
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
<el-tag v-if="!isTeacher && item.seatNumber" size="small" type="success">座位号 {{ item.seatNumber }}</el-tag>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
@@ -287,13 +415,49 @@ onMounted(async () => {
</el-dialog>
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '安排考试场次'" width="720px" top="5vh">
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '批量安排考试场次'" width="960px" top="3vh">
<el-form label-position="top">
<el-form-item label="教学班">
<el-form-item v-if="editingSession" label="教学班">
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
</el-select>
</el-form-item>
<template v-else>
<div class="task-filter-grid">
<el-input v-model="taskFilter.keyword" clearable placeholder="课程、教学班、教师或行政班" />
<el-select v-model="taskFilter.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="x in taskColleges" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
<el-select v-model="taskFilter.courseNature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
</el-select>
</div>
<div class="task-selection-actions">
<span>可选 {{ filteredTasks.length }} 已选 {{ selectedTaskIds.length }} </span>
<el-button link type="primary" @click="selectFilteredTasks">选择全部筛选结果</el-button>
<el-button link @click="clearFilteredTasks">清除筛选结果选择</el-button>
</div>
<el-table :data="filteredTasks" height="260" size="small">
<el-table-column label="选择" width="64">
<template #default="scope">
<el-checkbox
:model-value="selectedTaskIds.includes(scope.row.id)"
@click.stop
@update:model-value="toggleTaskSelection(scope.row.id, Boolean($event))"
/>
</template>
</el-table-column>
<el-table-column prop="taskNumber" label="教学班号" width="130" />
<el-table-column prop="courseName" label="课程" min-width="150" />
<el-table-column prop="collegeName" label="开课单位" min-width="130" />
<el-table-column label="任课教师" min-width="120">
<template #default="scope">{{ scope.row.teacherNames?.join('、') || '待定' }}</template>
</el-table-column>
<el-table-column label="行政班" min-width="150">
<template #default="scope">{{ scope.row.classNames?.join('、') || '-' }}</template>
</el-table-column>
</el-table>
</template>
<div class="form-grid">
<el-form-item label="考试日期">
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
@@ -319,12 +483,12 @@ onMounted(async () => {
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<el-form-item label="考场(可留空,由自动编排分配)">
<el-form-item v-if="editingSession" label="考场(可留空,由自动编排分配)">
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考教师(可留空,由自动编排分配)">
<el-form-item v-if="editingSession" label="监考教师(可留空,由自动编排分配)">
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
</el-select>
@@ -335,13 +499,16 @@ onMounted(async () => {
</el-form>
<template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
<el-button type="primary" @click="saveSession">
{{ editingSession ? '保存修改' : `批量创建 ${selectedTaskIds.length} 个场次` }}
</el-button>
</template>
</el-dialog>
<!-- Roster Drawer -->
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
<el-table v-if="roster" :data="roster.students">
<el-table-column prop="seatNumber" label="座位号" width="80" />
<el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" />
<el-table-column prop="className" label="行政班" />
@@ -354,8 +521,51 @@ onMounted(async () => {
.exam-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.exam-filter-bar,
.task-filter-grid,
.task-selection-actions {
display: flex;
align-items: center;
gap: 10px;
}
.exam-filter-bar {
margin-bottom: 16px;
flex-wrap: wrap;
}
.exam-filter-bar .el-input {
width: min(320px, 100%);
}
.exam-filter-bar .el-select {
width: 180px;
}
.task-filter-grid {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(160px, 1fr) minmax(160px, 1fr);
margin-bottom: 10px;
}
.task-selection-actions {
justify-content: flex-end;
margin-bottom: 8px;
}
.task-selection-actions span {
margin-right: auto;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
}
@media (max-width: 720px) {
.exam-actions {
justify-content: flex-start;
}
.task-filter-grid {
grid-template-columns: 1fr;
}
.task-selection-actions {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+231 -21
View File
@@ -35,11 +35,55 @@ const enrollmentSession = ref<any | null>(null)
const eligibleStudents = ref<any[]>([])
const eligibleLoading = ref(false)
const selectedStudentIds = ref<string[]>([])
const selectedTaskIds = ref<string[]>([])
const selectedSessionIds = ref<string[]>([])
const planForm = reactive<Record<string, any>>({})
const sessionForm = reactive<Record<string, any>>({})
const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' })
const sessionFilter = reactive({ keyword: '', allocation: '' })
const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
const courseNatureLabels: Record<string, string> = {
GeneralRequired: '公共必修',
GeneralElective: '公共选修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
Practice: '实践教学',
}
const taskColleges = computed(() => {
const values = new Map<string, string>()
tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName))
return Array.from(values, ([id, name]) => ({ id, name }))
})
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))
return tasks.value.filter((task: any) => {
if (arrangedIds.has(task.id)) return false
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false
if (!keyword) return true
return [task.taskNumber, task.courseCode, task.courseName, task.name,
...(task.teacherNames ?? []), ...(task.classNames ?? [])]
.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' && session.classroomId) return false
if (sessionFilter.allocation === 'invigilator' &&
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false
if (sessionFilter.allocation === 'complete' &&
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false
if (!keyword) return true
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
@@ -76,6 +120,18 @@ async function load() {
}
async function selectPlan(id: string) {
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
selectedSessionIds.value = []
await loadPlanResources(selected.value.academicTermId)
}
async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([
http.get('/teaching-tasks/options', {
params: { academicTermId, status: 'Published' },
}),
http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId } }),
])
tasks.value = taskRes.data
timeSlots.value = slotRes.data
}
function openPlan() {
Object.assign(planForm, {
@@ -93,6 +149,8 @@ async function savePlan() {
}
function openSession(existing?: any) {
editingSession.value = existing ?? null
selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
const firstSlot = timeSlots.value[0]
Object.assign(sessionForm, {
teachingTaskId: existing?.teachingTaskId ?? undefined,
@@ -113,7 +171,20 @@ async function saveSession() {
if (editingSession.value) {
await http.put(`/makeup-exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
} else {
await http.post(`/makeup-exams/plans/${selected.value.id}/sessions`, payload)
if (selectedTaskIds.value.length === 0) {
ElMessage.warning('请至少选择一个教学班')
return
}
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/sessions/batch`, {
teachingTaskIds: selectedTaskIds.value,
examDate: payload.examDate,
startPeriod: payload.startPeriod,
periodCount: payload.periodCount,
requiredBuildingId: payload.requiredBuildingId,
requiredInvigilatorCount: payload.requiredInvigilatorCount,
notes: payload.notes,
})
ElMessage.success(`已批量创建 ${res.data.createdCount} 个补考场次`)
}
sessionDialog.value = false
editingSession.value = null
@@ -129,13 +200,52 @@ async function removeSession(row: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function autoArrange() {
function selectFilteredTasks() {
selectedTaskIds.value = Array.from(new Set([
...selectedTaskIds.value,
...filteredTasks.value.map((task: any) => task.id),
])).slice(0, 100)
}
function clearFilteredTasks() {
const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id))
selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id))
}
function toggleTaskSelection(id: string, checked: boolean) {
if (checked && selectedTaskIds.value.length >= 100) {
ElMessage.warning('一次最多选择100个教学班')
return
}
selectedTaskIds.value = checked
? Array.from(new Set([...selectedTaskIds.value, id]))
: selectedTaskIds.value.filter(value => value !== id)
}
function selectFilteredSessions() {
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
}
function clearSessionSelection() {
selectedSessionIds.value = []
}
function toggleSessionSelection(id: string, checked: boolean) {
selectedSessionIds.value = checked
? Array.from(new Set([...selectedSessionIds.value, id]))
: selectedSessionIds.value.filter(value => value !== id)
}
async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
try {
const target = selectedSessionIds.value.length
? `所选 ${selectedSessionIds.value.length} 个场次`
: '当前计划全部场次'
const action = mode === 'rooms' ? '分配考场'
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
await ElMessageBox.confirm(
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
`系统将为${target}${action},已有安排不会被覆盖。`,
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
arrangeLoading.value = true
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`)
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`, {
sessionIds: selectedSessionIds.value,
assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms',
})
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
} catch (error: any) {
@@ -307,21 +417,16 @@ function stopPolling() {
onMounted(async () => {
try {
if (isManager.value) {
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
currentTermId ? http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
timeSlots.value = slotRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
@@ -356,8 +461,10 @@ onMounted(async () => {
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排补考场次</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div>
@@ -369,9 +476,28 @@ onMounted(async () => {
</template>
</el-alert>
</div>
<div class="exam-filter-bar">
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
<el-option label="待分配考场" value="room" />
<el-option label="待补足监考" value="invigilator" />
<el-option label="已完成分配" value="complete" />
</el-select>
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
<template v-if="selected.status === 'Draft'">
<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>
</template>
</div>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
<el-checkbox
v-if="selected.status === 'Draft'"
:model-value="selectedSessionIds.includes(session.id)"
@change="toggleSessionSelection(session.id, Boolean($event))"
>选择</el-checkbox>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
@@ -381,7 +507,7 @@ onMounted(async () => {
<p>
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template>
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
· {{ session.studentCount }}
· {{ session.enrolledCount ?? session.studentCount ?? 0 }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
</p>
@@ -398,7 +524,8 @@ 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-if="!selected.sessions.length" description="尚未安排补考场次,点击「批量安排场次」开始。" />
<el-empty v-else-if="!filteredSessions.length" description="没有符合筛选条件的补考场次" />
</div>
</section>
</template>
@@ -443,6 +570,7 @@ onMounted(async () => {
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
<el-tag v-if="item.seatNumber" size="small" type="success">座位号 {{ item.seatNumber }}</el-tag>
<p v-if="item.reason" style="margin-top: 4px">
<span>补考原因{{ item.reason === 1 ? '不及格' : item.reason === 2 ? '缺考' : '缓考通过' }}</span>
<span v-if="item.makeupScore != null"> · 成绩<b>{{ item.makeupScore }}</b></span>
@@ -494,13 +622,49 @@ onMounted(async () => {
</el-dialog>
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '安排补考场次'" width="720px" top="5vh">
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '批量安排补考场次'" width="960px" top="3vh">
<el-form label-position="top">
<el-form-item label="教学班">
<el-form-item v-if="editingSession" label="教学班">
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
</el-select>
</el-form-item>
<template v-else>
<div class="task-filter-grid">
<el-input v-model="taskFilter.keyword" clearable placeholder="课程、教学班、教师或行政班" />
<el-select v-model="taskFilter.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="x in taskColleges" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
<el-select v-model="taskFilter.courseNature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
</el-select>
</div>
<div class="task-selection-actions">
<span>可选 {{ filteredTasks.length }} 已选 {{ selectedTaskIds.length }} </span>
<el-button link type="primary" @click="selectFilteredTasks">选择全部筛选结果</el-button>
<el-button link @click="clearFilteredTasks">清除筛选结果选择</el-button>
</div>
<el-table :data="filteredTasks" height="260" size="small">
<el-table-column label="选择" width="64">
<template #default="scope">
<el-checkbox
:model-value="selectedTaskIds.includes(scope.row.id)"
@click.stop
@update:model-value="toggleTaskSelection(scope.row.id, Boolean($event))"
/>
</template>
</el-table-column>
<el-table-column prop="taskNumber" label="教学班号" width="130" />
<el-table-column prop="courseName" label="课程" min-width="150" />
<el-table-column prop="collegeName" label="开课单位" min-width="130" />
<el-table-column label="任课教师" min-width="120">
<template #default="scope">{{ scope.row.teacherNames?.join('、') || '待定' }}</template>
</el-table-column>
<el-table-column label="行政班" min-width="150">
<template #default="scope">{{ scope.row.classNames?.join('、') || '-' }}</template>
</el-table-column>
</el-table>
</template>
<div class="form-grid">
<el-form-item label="考试日期">
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
@@ -526,12 +690,12 @@ onMounted(async () => {
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<el-form-item label="考场(可留空,由自动编排分配)">
<el-form-item v-if="editingSession" label="考场(可留空,由自动编排分配)">
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考教师(可留空,由自动编排分配)">
<el-form-item v-if="editingSession" label="监考教师(可留空,由自动编排分配)">
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
</el-select>
@@ -542,13 +706,16 @@ onMounted(async () => {
</el-form>
<template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
<el-button type="primary" @click="saveSession">
{{ editingSession ? '保存修改' : `批量创建 ${selectedTaskIds.length} 个场次` }}
</el-button>
</template>
</el-dialog>
<!-- Roster Drawer -->
<el-drawer v-model="rosterDrawer" title="考生名单" size="600px">
<el-table v-if="roster" :data="roster.students">
<el-table-column prop="seatNumber" label="座位号" width="80" />
<el-table-column prop="studentNumber" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column prop="className" label="行政班" width="130" />
@@ -629,6 +796,37 @@ onMounted(async () => {
.exam-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.exam-filter-bar,
.task-filter-grid,
.task-selection-actions {
display: flex;
align-items: center;
gap: 10px;
}
.exam-filter-bar {
margin-bottom: 16px;
flex-wrap: wrap;
}
.exam-filter-bar .el-input {
width: min(320px, 100%);
}
.exam-filter-bar .el-select {
width: 180px;
}
.task-filter-grid {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(160px, 1fr) minmax(160px, 1fr);
margin-bottom: 10px;
}
.task-selection-actions {
justify-content: flex-end;
margin-bottom: 8px;
}
.task-selection-actions span {
margin-right: auto;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
@@ -641,4 +839,16 @@ onMounted(async () => {
.enrollment-header p {
margin: 0;
}
@media (max-width: 720px) {
.exam-actions {
justify-content: flex-start;
}
.task-filter-grid {
grid-template-columns: 1fr;
}
.task-selection-actions {
align-items: flex-start;
flex-direction: column;
}
}
</style>