Files
Academic-Affairs-System/web/src/views/TeacherRosterView.vue
T
2026-08-04 10:48:08 +08:00

192 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const loading = ref(false)
const detailLoading = ref(false)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const selectedTask = ref<any>(null)
const roster = ref<any>(null)
const termId = ref<string>()
async function loadTasks() {
loading.value = true
try {
tasks.value = (await http.get('/course-selections/my-teaching-tasks', {
params: { academicTermId: termId.value || undefined },
})).data
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
?? tasks.value[0]
if (preferred) await selectTask(preferred)
else {
selectedTask.value = null
roster.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectTask(task: any) {
selectedTask.value = task
detailLoading.value = true
try {
roster.value = (await http.get(`/course-selections/teaching-tasks/${task.id}/roster`)).data
} catch (error) {
roster.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function exportRoster() {
if (!selectedTask.value) return
try {
await downloadApiFile(
`/course-selections/teaching-tasks/${selectedTask.value.id}/roster/export.xlsx`,
`教学班名单-${selectedTask.value.taskNumber}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value)
await loadTasks()
})
</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="loadTasks">刷新</el-button>
</section>
<section class="roster-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select>
<span> {{ tasks.length }} 个教学班</span>
</section>
<section class="roster-workspace" v-loading="loading">
<aside class="roster-task-list">
<button
v-for="task in tasks"
:key="task.id"
type="button"
:class="{ active: selectedTask?.id === task.id }"
@click="selectTask(task)"
>
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
<b>{{ task.courseName }}</b>
<small>{{ task.termName }}</small>
<i>{{ task.studentCount }} / {{ task.capacity }} </i>
</button>
<el-empty v-if="!tasks.length" description="当前学期没有授课教学班" />
</aside>
<main class="roster-detail" v-loading="detailLoading">
<el-empty v-if="!selectedTask" description="请选择一个教学班" />
<template v-else-if="roster">
<header class="roster-head">
<div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<h3>{{ roster.courseName }}教学班名单</h3>
<p> {{ roster.studentCount }} / 容量 {{ 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="联系方式" min-width="210">
<template #default="{ row }">
<div class="contact-cell">
<span>{{ row.phone || '未填写电话' }}</span>
<small>{{ row.email || row.weChat ? [row.email, row.weChat && `微信 ${row.weChat}`].filter(Boolean).join(' · ') : '未填写邮箱或微信' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="紧急联系人" min-width="180">
<template #default="{ row }">
<div class="contact-cell">
<span>{{ row.emergencyContactName || '—' }}<template v-if="row.emergencyContactRelationship">{{ row.emergencyContactRelationship }}</template></span>
<small>{{ row.emergencyContactPhone || '未填写联系电话' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="特殊标记" min-width="210">
<template #default="{ row }">
<div class="special-cell">
<el-tag v-if="row.specialTags" type="warning" effect="light">{{ row.specialTags }}</el-tag>
<span v-else></span>
<small v-if="row.specialNeeds">{{ row.specialNeeds }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="名单来源" width="210">
<template #default="{ row }">{{ row.enrolledAt ? `选课 · ${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; }
.contact-cell, .special-cell { display: grid; gap: 4px; line-height: 1.35; }
.contact-cell small, .special-cell small { color: var(--muted); white-space: normal; }
.special-cell .el-tag { width: fit-content; max-width: 100%; white-space: normal; height: auto; padding-block: 3px; }
@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>