Files
Academic-Affairs-System/web/src/views/ExamsView.vue
T
biss fb2acc6751 已修改完成,同时解决了“考生名单都是 0 人”的根因。
考生范围统一为“行政班关联学生 + 已选课学生”,并自动去重;考场分配、人数显示、冲突检测、学生考试安排均使用同一口径。[TeachingTaskRosterQuery.cs (line 40)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskRosterQuery.cs:40)
新增考场签名单 Excel 导出接口。[ExamsController.cs (line 568)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:568)
考试计划页面新增“导出考场签名单”按钮。[ExamsView.vue (line 255)](E:/jiaowu/web/src/views/ExamsView.vue:255)
工作簿包含“考场汇总”以及每个考试场次独立的 A4 签到表,字段包括座位号、学号、姓名、行政班、考生签名和备注。[ExamSignInWorkbookExporter.cs (line 12)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs:12)
未分配考场或监考教师时,也能导出并显示“待分配”。
2026-07-27 18:24:32 +08:00

595 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some((r) => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const personal = ref<any[]>([])
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const rooms = ref<any[]>([])
const teachers = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false)
const arrangeLoading = ref(false)
const exportLoading = ref(false)
const planDialog = ref(false)
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 })
}
function dateOnlyText(value: string) {
if (!value) return ''
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' }).format(new Date(value))
}
function periodLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const startSlot = timeSlots.value.find((s: any) => s.periodNumber === session.startPeriod)
const endSlot = timeSlots.value.find((s: any) => s.periodNumber === end)
const timeRange = startSlot && endSlot ? `${startSlot.startsAt}${endSlot.endsAt}` : ''
return `第 ${session.startPeriod}-${end}${timeRange ? ' · ' + timeRange : ''}`
}
async function load() {
loading.value = true
try {
if (!isManager.value) {
personal.value = (await http.get('/exams/my-schedule')).data
return
}
plans.value = (await http.get('/exams/plans')).data
const plan = plans.value.find((x) => x.id === selected.value?.id)
?? plans.value.find((x) => x.termIsCurrent)
?? plans.value[0]
if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false }
}
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, {
academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '',
})
planDialog.value = true
}
async function savePlan() {
try {
await http.post('/exams/plans', planForm)
planDialog.value = false
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
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,
classroomId: existing?.classroomId ?? undefined,
examDate: existing?.examDate ?? '',
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
})
sessionDialog.value = true
}
async function saveSession() {
try {
const payload = { ...sessionForm }
if (editingSession.value) {
await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
} else {
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
await selectPlan(selected.value.id)
} 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)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
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(
`系统将为${target}${action},已有安排不会被覆盖。`,
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
arrangeLoading.value = true
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) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
type: 'warning', confirmButtonText: '确认发布',
})
await http.post(`/exams/plans/${selected.value.id}/publish`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
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('考场签名单已导出')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
exportLoading.value = false
}
}
async function showRoster(row: any) {
try {
roster.value = (await http.get(`/exams/sessions/${row.id}/roster`)).data
rosterDrawer.value = true
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function periodOptions() {
const max = timeSlots.value.length > 0 ? timeSlots.value[timeSlots.value.length - 1].periodNumber : 12
return Array.from({ length: max }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
}
function periodCountOptions() {
return [1, 2, 3, 4].map(n => ({ value: n, label: `${n} 小节` }))
}
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)
}
onMounted(async () => {
try {
if (isManager.value) {
const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
])
terms.value = termRes.data
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
</script>
<template>
<div class="page-stack exam-page">
<section class="page-intro">
<div>
<span class="section-kicker">EXAMINATION OFFICE</span>
<h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2>
<p>{{ isManager ? '基于课表节次安排考试,自动匹配考场与监考教师。' : '查看学校已经正式发布的考试日程。' }}</p>
</div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
</section>
<template v-if="isManager">
<section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button>
</section>
<section v-if="selected" class="exam-board" v-loading="loading">
<header>
<div>
<span>EXAM TIMELINE</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次</p>
</div>
<div class="exam-actions">
<el-button
:icon="Download"
:loading="exportLoading"
:disabled="selected.sessions.length === 0"
@click="exportSignInSheets"
>导出考场签名单</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 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>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<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 }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
</p>
</div>
<div class="exam-staff">
<span>监考</span>
<b>{{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }}</b>
<small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small>
</div>
<div class="exam-row-actions">
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openSession(session)">编辑</el-button>
<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="没有符合筛选条件的考试场次" />
</div>
</section>
</template>
<section v-else class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
<span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<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>
{{ isTeacher ? `${item.studentCount} 名考生` : `监考:${item.invigilatorNames.join('、') || '待定'}` }}
</footer>
</article>
<el-empty v-if="!personal.length" description="暂无已发布考试安排" />
</section>
<!-- Plan Dialog -->
<el-dialog v-model="planDialog" title="新建考试计划" width="600px">
<el-form label-position="top">
<el-form-item label="学期">
<el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select>
</el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" maxlength="500" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="planDialog = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存草稿</el-button>
</template>
</el-dialog>
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑考试场次' : '批量安排考试场次'" width="960px" top="3vh">
<el-form label-position="top">
<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="选择日期" />
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="sessionForm.startPeriod">
<el-option v-for="opt in periodOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-select v-model="sessionForm.periodCount">
<el-option v-for="opt in periodCountOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考人数">
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<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 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>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="sessionForm.notes" maxlength="500" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sessionDialog = false">取消</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="行政班" />
</el-table>
</el-drawer>
</div>
</template>
<style scoped>
.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>