其他类型考试
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "2.3.0-rc2",
|
||||
"version": "2.3.0-rc3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -177,6 +177,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
|
||||
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
|
||||
{ path: '/other-exams', label: '其他考试成绩' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']),
|
||||
{
|
||||
|
||||
@@ -247,6 +247,12 @@ const router = createRouter({
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'other-exams',
|
||||
name: 'other-exams',
|
||||
component: () => import('../views/OtherExamsView.vue'),
|
||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Student'] },
|
||||
},
|
||||
{
|
||||
path: 'exams',
|
||||
name: 'exams',
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
type ResultRow = { studentNumber: string; studentName?: string; collegeName?: string; className?: string; attemptNumber?: number; score: number | null; level: string; isPassed: boolean | null; notes: string }
|
||||
const auth = useAuthStore()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student'))
|
||||
const loading = ref(false)
|
||||
const batches = ref<any[]>([])
|
||||
const selected = ref<any>(null)
|
||||
const results = ref<ResultRow[]>([])
|
||||
const mine = reactive({ best: [] as any[], history: [] as any[] })
|
||||
const dialog = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ examCode: '', name: '', organizer: '', examDate: new Date().toISOString().slice(0, 10), metricKind: 3, maxScore: 100, levelOptions: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
|
||||
else batches.value = (await http.get('/other-exams/batches')).data
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
|
||||
}
|
||||
async function openBatch(row: any) {
|
||||
try {
|
||||
const data = (await http.get(`/other-exams/batches/${row.id}`)).data
|
||||
selected.value = data.batch
|
||||
results.value = data.results.map((item: any) => ({ ...item, score: item.score ?? null, level: item.level ?? '', notes: item.notes ?? '' }))
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
function addResult() { results.value.push({ studentNumber: '', studentName: '', collegeName: '', className: '', score: null, level: '', isPassed: null, notes: '' }) }
|
||||
async function lookupStudent(row: ResultRow | any) {
|
||||
row.studentName = ''; row.collegeName = ''; row.className = ''
|
||||
if (!row.studentNumber.trim()) return
|
||||
try {
|
||||
const { data } = await http.get('/other-exams/students/lookup', { params: { studentNumber: row.studentNumber.trim() } })
|
||||
row.studentNumber = data.studentNumber; row.studentName = data.name; row.collegeName = data.collegeName; row.className = data.className
|
||||
} catch { ElMessage.warning(`未找到学号 ${row.studentNumber}`) }
|
||||
}
|
||||
async function saveResults() {
|
||||
saving.value = true
|
||||
try {
|
||||
await http.put(`/other-exams/batches/${selected.value.id}/results`, { results: results.value.map(row => ({ studentNumber: row.studentNumber, score: selected.value.metricKind === 3 ? row.score : null, level: selected.value.metricKind === 2 ? row.level : null, isPassed: selected.value.metricKind === 1 ? row.isPassed : null, notes: row.notes })) })
|
||||
ElMessage.success('成绩已保存,参加次数由系统自动计算'); await openBatch(selected.value); await load()
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { saving.value = false }
|
||||
}
|
||||
async function publish() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认发布本批次成绩?发布后学生可查看;修改后可再次发布。', '发布其他考试成绩')
|
||||
await http.post(`/other-exams/batches/${selected.value.id}/publish`)
|
||||
ElMessage.success('成绩已发布'); await openBatch(selected.value); await load()
|
||||
} catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
async function createBatch() {
|
||||
try { await http.post('/other-exams/batches', form); dialog.value = false; ElMessage.success('考试批次已建立'); await load() }
|
||||
catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
const response = await http.get(`/other-exams/batches/${selected.value.id}/template`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `其他考试成绩导入模板-${selected.value.examCode || selected.value.name}.xlsx`; link.click(); URL.revokeObjectURL(url)
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
async function importExcel(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
if (!file) return
|
||||
const data = new FormData(); data.append('file', file)
|
||||
try { await http.post(`/other-exams/batches/${selected.value.id}/import`, data, { headers: { 'Content-Type': 'multipart/form-data' } }); ElMessage.success('其他考试成绩导入成功'); await openBatch(selected.value); await load() }
|
||||
catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
function metricName(kind: number) { return kind === 1 ? '合格制' : kind === 2 ? '等级制' : '分数制' }
|
||||
function displayResult(row: any) { return row.metricKind === 3 ? `${row.score} / ${row.maxScore}` : row.metricKind === 2 ? row.level : row.isPassed ? '合格' : '不合格' }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="other-exams-page">
|
||||
<section class="exam-hero"><div><span class="kicker">ASSESSMENT ARCHIVE</span><h1>其他考试成绩</h1><p>{{ isStudent ? '你的证书、等级考试与校外考试,按考试归档,最优结果一目了然。' : '用考试编码归并同一考试,按场次维护成绩,系统自动记录每位学生的参加次数。' }}</p></div><el-button v-if="!isStudent" type="primary" :icon="Plus" @click="dialog = true">新建考试场次</el-button></section>
|
||||
<template v-if="isStudent">
|
||||
<section class="result-board"><div class="board-title"><div><span class="kicker">BEST OUTCOME</span><h2>我的最优结果</h2></div><span class="board-note">同一考试编码下自动比较</span></div><el-table :data="mine.best" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="最优结果" min-width="150"><template #default="{ row }"><strong class="best-value">{{ displayResult(row) }}</strong></template></el-table-column><el-table-column prop="attemptNumber" label="参加次数" width="100"/></el-table><el-empty v-if="!loading && !mine.best.length" description="暂无已发布的其他考试成绩"/></section>
|
||||
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">FULL HISTORY</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">EXAM SESSIONS</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} 次` : '草稿' }}</el-tag></template></el-table-column></el-table></section>
|
||||
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
|
||||
</template>
|
||||
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.other-exams-page { --ink:#172b3a; --muted:#718391; --line:#dce6e9; --teal:#087f78; --navy:#193b68; padding-bottom:40px; }
|
||||
.exam-hero { display:flex; justify-content:space-between; align-items:end; gap:24px; padding:26px 0 24px; border-bottom:1px solid var(--line); margin-bottom:20px; }
|
||||
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
|
||||
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user