已完成“考试安排与考场管理”模块:

考试计划草稿、场次配置与正式发布。
教学班、考试时间、考场、监考教师关联。
自动校验考场容量。
阻止考场、监考教师和学生考试时间冲突。
管理员查看考生名单。
教师查看个人监考安排。
学生查看已发布考试日程。
SQLite 增量升级和 MySQL 正式迁移。
This commit is contained in:
2026-07-24 15:43:55 +08:00 Unverified
parent af52e27a3a
commit 7f3e37de20
18 changed files with 3057 additions and 2 deletions
+9
View File
@@ -11,6 +11,7 @@ import {
Calendar,
CircleCheck,
DocumentChecked,
AlarmClock,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -43,6 +44,7 @@ const pageTitle = computed(() => {
schedules: '排课与课表',
'course-selections': '选课与教学班',
grades: '成绩与学业档案',
exams: '考试与考场',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -138,6 +140,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><DocumentChecked /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '学业成绩' : '成绩管理' }}</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'].includes(role))"
index="/exams"
>
<el-icon><AlarmClock /></el-icon>
<template #title>{{ auth.user?.roles.includes('Student') ? '我的考试' : auth.user?.roles.includes('Teacher') ? '我的监考' : '考试管理' }}</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
+6
View File
@@ -74,6 +74,12 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
},
},
{
path: 'exams',
name: 'exams',
component: () => import('../views/ExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] },
},
{
path: 'users',
name: 'users',
+44
View File
@@ -510,6 +510,42 @@ button { cursor: pointer; }
.transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.transcript-result small { color: var(--muted); font-size: 9px; }
.exam-plan-strip { padding: 10px; display: flex; gap: 9px; overflow-x: auto; border: 1px solid var(--line); background: white; }
.exam-plan-strip button { flex: 0 0 275px; padding: 14px; display: grid; grid-template-columns: 1fr auto; gap: 6px; text-align: left; border: 1px solid var(--line); background: #fafbfc; }
.exam-plan-strip button.active { color: white; border-color: var(--indigo); background: var(--indigo); }
.exam-plan-strip b { grid-column: 1/-1; font-family: "STZhongsong","Songti SC",serif; }
.exam-plan-strip span, .exam-plan-strip small, .exam-plan-strip i { color: #8c94a3; font-size: 9px; font-style: normal; }
.exam-plan-strip button.active span, .exam-plan-strip button.active small { color: #c5cee5; }
.exam-board { border: 1px solid var(--line); background: white; }
.exam-board > header { padding: 22px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); }
.exam-board header span { color: var(--teal); font: 700 9px Consolas,monospace; letter-spacing: .13em; }
.exam-board header h3 { margin: 7px 0 5px; font: 600 21px "STZhongsong","Songti SC",serif; }
.exam-board header p { margin: 0; color: var(--muted); font-size: 10px; }
.exam-timeline { padding: 10px 22px 24px 42px; }
.exam-timeline article { min-height: 108px; display: grid; grid-template-columns: 120px minmax(220px,1fr) 210px 120px; align-items: center; gap: 20px; position: relative; border-bottom: 1px solid var(--line); }
.exam-timeline article::before { content:""; position:absolute; left:-24px; width:9px; height:9px; border:3px solid white; border-radius:50%; background:var(--teal); box-shadow:0 0 0 1px var(--teal); }
.exam-timeline article::after { content:""; position:absolute; left:-20px; top:0; bottom:0; width:1px; background:#cdd4df; z-index:-1; }
.exam-timeline time b, .exam-timeline time span { display:block; }
.exam-timeline time b { color:var(--indigo); font:700 15px Consolas,monospace; }
.exam-timeline time span { margin-top:5px; color:var(--muted); font-size:9px; }
.exam-timeline article > div > span { color:var(--teal); font-size:9px; }
.exam-timeline h4 { margin:6px 0 4px; font-size:14px; }
.exam-timeline p { margin:0; color:var(--muted); font-size:9px; }
.exam-staff { padding-left:18px; border-left:1px solid var(--line); }
.exam-staff b,.exam-staff small { display:block; margin-top:5px; font-size:10px; }
.exam-staff small { color:var(--muted); }
.exam-row-actions { text-align:right; }
.exam-ticket-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.exam-ticket-grid article { min-height:180px; display:grid; grid-template-columns:105px 1fr; border:1px solid var(--line); background:white; overflow:hidden; }
.exam-ticket-date { padding:20px 14px; display:grid; place-content:center; text-align:center; color:white; background:linear-gradient(145deg,#182d64,#21706f); }
.exam-ticket-date b { font:700 17px Consolas,monospace; }
.exam-ticket-date span { margin-top:8px; color:#bfe0da; font-size:9px; }
.exam-ticket-grid article > div:nth-child(2) { padding:24px 20px; }
.exam-ticket-grid article > div:nth-child(2) span { color:var(--teal); font-size:9px; }
.exam-ticket-grid h3 { margin:9px 0 7px; font:600 19px "STZhongsong","Songti SC",serif; }
.exam-ticket-grid p { margin:0; color:var(--muted); font-size:10px; }
.exam-ticket-grid footer { grid-column:1/-1; padding:12px 17px; display:flex; align-items:center; gap:7px; border-top:1px dashed var(--line); color:#586275; font-size:10px; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -615,6 +651,8 @@ button { cursor: pointer; }
.transcript-list > article { grid-template-columns: 1fr 120px; }
.grade-scale { grid-column: 1 / -1; grid-row: 2; }
.transcript-result { grid-column: 2; grid-row: 1; }
.exam-timeline article { grid-template-columns:100px 1fr 180px; }
.exam-row-actions { grid-column:2/-1; padding-bottom:10px; text-align:left; }
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
@@ -654,6 +692,12 @@ button { cursor: pointer; }
.transcript-list > article { grid-template-columns: 1fr; gap: 17px; }
.transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; }
.grade-scale { grid-column: auto; grid-row: auto; }
.exam-board > header { align-items:flex-start; flex-direction:column; }
.exam-timeline { padding-left:28px; }
.exam-timeline article { padding:16px 0; grid-template-columns:1fr; gap:9px; }
.exam-staff { padding:10px 0 0; border-left:none; border-top:1px solid var(--line); }
.exam-row-actions { grid-column:auto; padding:0; }
.exam-ticket-grid { grid-template-columns:1fr; }
.el-drawer { width: 100% !important; }
}
+7 -1
View File
@@ -117,13 +117,18 @@ onMounted(async () => {
<b>{{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布</b>
<i class="done">已建立</i>
</div>
<div>
<span>考试考场</span>
<b>{{ data.counts.examPlans ?? 0 }} 个计划 · {{ data.counts.examSessions ?? 0 }} 个场次</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>成绩录入审核发布与学生成绩单已就绪下一步进入考试安排与考场管理</p>
<p>考试计划考场监考与个人日程已就绪下一步进入学籍异动与毕业审核</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
@@ -131,6 +136,7 @@ onMounted(async () => {
<span class="active">排课课表</span>
<span class="active">学生选课</span>
<span class="active">成绩管理</span>
<span class="active">考试考场</span>
</div>
</article>
</section>
+203
View File
@@ -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>