切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1) 桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211) 归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。 已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
164 lines
6.5 KiB
Vue
164 lines
6.5 KiB
Vue
<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 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 = defaultAcademicTermId(terms.value)
|
||
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="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||
</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>
|