已完成“考试安排与考场管理”模块:
考试计划草稿、场次配置与正式发布。 教学班、考试时间、考场、监考教师关联。 自动校验考场容量。 阻止考场、监考教师和学生考试时间冲突。 管理员查看考生名单。 教师查看个人监考安排。 学生查看已发布考试日程。 SQLite 增量升级和 MySQL 正式迁移。
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Promotion, Refresh, UserFilled } 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 loading = ref(false)
|
||||
const planDialog = ref(false)
|
||||
const sessionDialog = ref(false)
|
||||
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 toIso(value: string) { return new Date(value.replace(' ', 'T')).toISOString() }
|
||||
|
||||
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() {
|
||||
Object.assign(sessionForm, {
|
||||
teachingTaskId: undefined, classroomId: undefined,
|
||||
startsAt: '', endsAt: '', invigilatorIds: [], notes: '',
|
||||
})
|
||||
sessionDialog.value = true
|
||||
}
|
||||
async function saveSession() {
|
||||
try {
|
||||
await http.post(`/exams/plans/${selected.value.id}/sessions`, {
|
||||
...sessionForm,
|
||||
startsAt: toIso(sessionForm.startsAt),
|
||||
endsAt: toIso(sessionForm.endsAt),
|
||||
})
|
||||
sessionDialog.value = false
|
||||
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 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)) }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (isManager.value) {
|
||||
const [termRes, taskRes, roomRes, teacherRes] = await Promise.all([
|
||||
http.get('/base-data/terms'),
|
||||
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } }),
|
||||
http.get('/base-data/classrooms'),
|
||||
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
|
||||
])
|
||||
terms.value = termRes.data
|
||||
tasks.value = taskRes.data.items
|
||||
rooms.value = roomRes.data
|
||||
teachers.value = teacherRes.data.items
|
||||
}
|
||||
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>
|
||||
<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">
|
||||
<time><b>{{ dateText(session.startsAt).split(' ')[0] }}</b><span>{{ dateText(session.startsAt).split(' ').slice(1).join(' ') }}</span></time>
|
||||
<div><span>{{ session.courseCode }} · {{ session.taskNumber }}</span><h4>{{ session.courseName }}</h4><p>{{ session.buildingName }} · {{ session.classroomName }} · {{ session.studentCount }} 人</p></div>
|
||||
<div class="exam-staff"><span>监考</span><b>{{ session.invigilatorNames.join('、') }}</b><small>{{ dateText(session.startsAt) }}—{{ dateText(session.endsAt).split(' ').slice(-1)[0] }}</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="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>{{ dateText(item.startsAt).split(' ')[0] }}</b><span>{{ dateText(item.startsAt).split(' ').slice(1).join(' ') }}</span></div>
|
||||
<div><span>{{ item.courseCode }} · {{ item.taskNumber }}</span><h3>{{ item.courseName }}</h3><p>{{ 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>
|
||||
|
||||
<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" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" /></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>
|
||||
<el-dialog v-model="sessionDialog" title="安排考试场次" width="680px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="教学班"><el-select v-model="sessionForm.teachingTaskId" filterable><el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" /></el-select></el-form-item>
|
||||
<el-form-item label="考场"><el-select v-model="sessionForm.classroomId"><el-option v-for="x in rooms" :key="x.id" :label="`${x.name} · ${x.capacity} 座`" :value="x.id" /></el-select></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="开始时间"><el-date-picker v-model="sessionForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="sessionForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" /></el-form-item></div>
|
||||
<el-form-item label="监考教师"><el-select v-model="sessionForm.invigilatorIds" multiple filterable><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>
|
||||
<template #footer><el-button @click="sessionDialog=false">取消</el-button><el-button type="primary" @click="saveSession">保存场次</el-button></template>
|
||||
</el-dialog>
|
||||
<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>
|
||||
Reference in New Issue
Block a user