成绩+点名

This commit is contained in:
2026-07-25 14:08:50 +08:00 Unverified
parent 7800951aee
commit 36049af692
16 changed files with 1809 additions and 98 deletions
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Download, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
const loading = ref(false)
const detailLoading = ref(false)
const terms = ref<any[]>([])
const offerings = ref<any[]>([])
const selectedOffering = ref<any>(null)
const roster = ref<any>(null)
const termId = ref<string>()
async function loadOfferings() {
loading.value = true
try {
offerings.value = (await http.get('/course-selections/my-offerings', {
params: { academicTermId: termId.value || undefined },
})).data
const preferred = offerings.value.find((item: any) => item.id === selectedOffering.value?.id)
?? offerings.value[0]
if (preferred) await selectOffering(preferred)
else {
selectedOffering.value = null
roster.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectOffering(offering: any) {
selectedOffering.value = offering
detailLoading.value = true
try {
roster.value = (await http.get(`/course-selections/offerings/${offering.id}/roster`)).data
} catch (error) {
roster.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function exportRoster() {
if (!selectedOffering.value) return
try {
await downloadApiFile(
`/course-selections/offerings/${selectedOffering.value.id}/roster/export.xlsx`,
`选课名单-${selectedOffering.value.taskNumber}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
await loadOfferings()
})
</script>
<template>
<div class="page-stack roster-page">
<section class="page-intro">
<div>
<span class="section-kicker">ENROLLMENT ROSTER</span>
<h2>选课名单</h2>
<p>查看本人授课班级的选课学生名单支持 Excel 导出</p>
</div>
<el-button :icon="Refresh" @click="loadOfferings">刷新</el-button>
</section>
<section class="roster-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings">
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
</el-select>
<span> {{ offerings.length }} 个教学班</span>
</section>
<section class="roster-workspace" v-loading="loading">
<aside class="roster-task-list">
<button
v-for="offering in offerings"
:key="offering.id"
type="button"
:class="{ active: selectedOffering?.id === offering.id }"
@click="selectOffering(offering)"
>
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
<b>{{ offering.courseName }}</b>
<small>{{ offering.termName }} · {{ offering.roundName }}</small>
<i>{{ offering.enrolledCount }} / {{ offering.capacity }} </i>
</button>
<el-empty v-if="!offerings.length" description="当前学期没有选课教学班" />
</aside>
<main class="roster-detail" v-loading="detailLoading">
<el-empty v-if="!selectedOffering" description="请选择一个教学班" />
<template v-else-if="roster">
<header class="roster-head">
<div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<h3>{{ roster.courseName }}选课名单</h3>
<p>已选 {{ roster.enrolledCount }} / 容量 {{ roster.capacity }} </p>
</div>
<el-button :icon="Download" @click="exportRoster">导出 Excel</el-button>
</header>
<el-table :data="roster.students" class="data-table">
<el-table-column label="学号" width="135">
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="班级" min-width="130" />
<el-table-column prop="majorName" label="专业" min-width="150" />
<el-table-column label="选课时间" width="170">
<template #default="{ row }">{{ new Date(row.enrolledAt).toLocaleString('zh-CN') }}</template>
</el-table-column>
</el-table>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.roster-toolbar {
min-height: 56px; padding: 10px 16px;
display: flex; align-items: center; gap: 12px;
border: 1px solid var(--line); background: #fbfcfd;
}
.roster-toolbar .el-select { width: 260px; }
.roster-toolbar > span { color: var(--muted); font-size: 12px; }
.roster-workspace { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 420px; border: 1px solid var(--line); background: white; }
.roster-task-list { border-right: 1px solid var(--line); overflow-y: auto; max-height: 600px; }
.roster-task-list button {
display: grid; gap: 3px; width: 100%; padding: 14px 16px; border: none; border-bottom: 1px solid #edf0f4;
background: none; cursor: pointer; text-align: left; transition: background .15s;
}
.roster-task-list button:hover { background: #f5f7fa; }
.roster-task-list button.active { background: #e9f3f5; border-left: 3px solid #176b87; }
.roster-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; }
.roster-task-list button > b { font-size: 14px; }
.roster-task-list button > small { color: var(--muted); font-size: 11px; }
.roster-task-list button > i { color: var(--indigo); font-size: 10px; font-weight: 700; }
.roster-detail { min-height: 420px; }
.roster-head {
padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
border-bottom: 1px solid var(--line); background: #f8fafb;
}
.roster-head h3 { margin: 5px 0 4px; font-size: 18px; }
.roster-head p { color: var(--muted); font-size: 12px; margin: 0; }
.roster-head span { color: var(--teal); font-size: 10px; font-weight: 700; }
@media (max-width: 760px) {
.roster-workspace { grid-template-columns: 1fr; }
.roster-task-list { max-height: 220px; border-right: none; border-bottom: 1px solid var(--line); }
}
</style>