365 lines
16 KiB
Vue
365 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
|
|
import http, { apiErrorMessage } from '../api/http'
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
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 planDialog = ref(false)
|
|
const sessionDialog = ref(false)
|
|
const editingSession = ref<any | null>(null)
|
|
const rosterDrawer = ref(false)
|
|
const roster = ref<any | null>(null)
|
|
const planForm = reactive<Record<string, any>>({})
|
|
const sessionForm = reactive<Record<string, any>>({})
|
|
const statusLabels: Record<string, string> = {
|
|
Draft: '草稿', Published: '已发布', Archived: '已归档',
|
|
}
|
|
|
|
function dateText(value: string) {
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
month: '2-digit', day: '2-digit', weekday: 'short',
|
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
}).format(new Date(value))
|
|
}
|
|
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[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
|
|
}
|
|
function openPlan() {
|
|
Object.assign(planForm, {
|
|
academicTermId: terms.value.find((x) => x.isCurrent)?.id,
|
|
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
|
|
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 {
|
|
await http.post(`/exams/plans/${selected.value.id}/sessions`, payload)
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
async function autoArrange() {
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
|
|
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
|
|
arrangeLoading.value = true
|
|
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`)
|
|
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 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 currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
|
|
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = 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)) }
|
|
})
|
|
</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 }" @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 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'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
|
|
</div>
|
|
</header>
|
|
<div class="exam-timeline">
|
|
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
|
|
<time>
|
|
<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="尚未安排考试场次,点击「安排场次」开始。" />
|
|
</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>
|
|
</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="x.name" :value="x.id" />
|
|
</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="720px" top="5vh">
|
|
<el-form label-position="top">
|
|
<el-form-item 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>
|
|
<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 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-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 ? '保存修改' : '保存场次' }}</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="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;
|
|
}
|
|
.exam-timeline article.unassigned {
|
|
border-left-color: #e6a23c;
|
|
}
|
|
</style>
|