143 lines
18 KiB
Vue
143 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Download, Plus, Promotion, Refresh, Search, 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 batchKeyword = ref('')
|
|
const batchStatus = ref<number | undefined>()
|
|
const batchMetricKind = ref<number | undefined>()
|
|
const batchDateRange = ref<string[]>([])
|
|
const batchPage = ref(1)
|
|
const batchPageSize = 20
|
|
const batchTotal = ref(0)
|
|
const selected = ref<any>(null)
|
|
const results = ref<ResultRow[]>([])
|
|
const mine = reactive({ best: [] as any[], history: [] as any[] })
|
|
const dialog = ref(false)
|
|
const publishDialog = ref(false)
|
|
const saving = ref(false)
|
|
const form = reactive({ examCode: '', name: '', organizer: '', examDate: new Date().toISOString().slice(0, 10), metricKind: 3, maxScore: 100, levelOptions: '' })
|
|
const selectedMetricKind = computed(() => Number(selected.value?.metricKind ?? 0))
|
|
const isScoreMetric = computed(() => selectedMetricKind.value === 3)
|
|
const isLevelMetric = computed(() => selectedMetricKind.value === 2)
|
|
const isPassMetric = computed(() => selectedMetricKind.value === 1)
|
|
|
|
async function load(resetPage = false) {
|
|
loading.value = true
|
|
try {
|
|
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
|
|
else {
|
|
if (resetPage) batchPage.value = 1
|
|
const response = (await http.get('/other-exams/batches', {
|
|
params: {
|
|
keyword: batchKeyword.value.trim() || undefined,
|
|
status: batchStatus.value,
|
|
metricKind: batchMetricKind.value,
|
|
examDateFrom: batchDateRange.value[0] || undefined,
|
|
examDateTo: batchDateRange.value[1] || undefined,
|
|
page: batchPage.value,
|
|
pageSize: batchPageSize,
|
|
},
|
|
})).data
|
|
batches.value = response.items
|
|
batchTotal.value = response.total
|
|
}
|
|
} 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
|
|
const metricKind = Number(data.batch.metricKind ?? 3)
|
|
data.batch.metricKind = [1, 2, 3].includes(metricKind) ? metricKind : 3
|
|
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: isScoreMetric.value ? row.score : null, level: isLevelMetric.value ? row.level : null, isPassed: isPassMetric.value ? 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() {
|
|
publishDialog.value = true
|
|
}
|
|
async function confirmPublish() {
|
|
saving.value = true
|
|
try {
|
|
await http.post(`/other-exams/batches/${selected.value.id}/publish`)
|
|
publishDialog.value = false
|
|
ElMessage.success('成绩已发布')
|
|
await openBatch(selected.value)
|
|
await load()
|
|
} catch (error) {
|
|
ElMessage.error(`发布失败:${apiErrorMessage(error)}`)
|
|
} finally { saving.value = false }
|
|
}
|
|
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 | string) { const value = Number(kind); return value === 1 ? '合格制' : value === 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">考试成绩</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">成绩汇总</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">历史记录</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">考试场次</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><div class="batch-filters"><el-input v-model="batchKeyword" clearable placeholder="考试编码、名称或组织方" @keyup.enter="load(true)" @clear="load(true)"/><el-select v-model="batchStatus" clearable placeholder="全部状态" @change="load(true)"><el-option label="草稿" :value="1"/><el-option label="已发布" :value="2"/></el-select><el-select v-model="batchMetricKind" clearable placeholder="全部方式" @change="load(true)"><el-option label="合格制" :value="1"/><el-option label="等级制" :value="2"/><el-option label="分数制" :value="3"/></el-select><el-date-picker v-model="batchDateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" @change="load(true)"/><el-button :icon="Search" @click="load(true)">查询</el-button><el-button :icon="Refresh" @click="() => load()">刷新</el-button></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><el-pagination v-if="batchTotal > batchPageSize" small background layout="total, prev, pager, next" :current-page="batchPage" :page-size="batchPageSize" :total="batchTotal" @current-change="(page: number) => { batchPage = page; load() }"/></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>
|
|
<el-dialog v-model="publishDialog" title="发布其他考试成绩" width="460px" :close-on-click-modal="false"><div class="publish-confirm"><strong>确认发布“{{ selected?.name }}”成绩?</strong><p>发布后学生可以查看本批次成绩;如需更正,可以修改后再次发布。</p></div><template #footer><el-button @click="publishDialog = false">取消</el-button><el-button type="success" :loading="saving" @click="confirmPublish">确认发布</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; } }
|
|
.editor-heading > div:first-child { min-width: 0; }
|
|
.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.batch-panel :deep(.el-pagination) { justify-content:flex-end; padding:14px 22px; }
|
|
.editor-actions { flex: 0 0 auto; }
|
|
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
|
|
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
|
|
</style>
|