课程库 Excel 导入导出已完成:

支持下载标准导入模板。
导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。
以课程编码为唯一键:存在则更新,不存在则新增。
校验学院编码、课程分类、课程性质、学分学时及考核方式。
整批原子导入,任一行错误则全部不写入,并提示具体行号。
学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。
界面入口沿用现有课程库工具栏样式。
This commit is contained in:
2026-07-24 18:56:25 +08:00 Unverified
parent 5a7a8d530b
commit d7cf3f9e76
24 changed files with 3710 additions and 32 deletions
+8 -1
View File
@@ -6,7 +6,7 @@ import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories'
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
const allTabs: { key: Kind; label: string; hint: string }[] = [
@@ -17,6 +17,7 @@ const allTabs: { key: Kind; label: string; hint: string }[] = [
{ key: 'terms', label: '学期', hint: '教学运行时间轴' },
{ key: 'buildings', label: '教学楼', hint: '校区内教学建筑' },
{ key: 'classrooms', label: '教室', hint: '可排课教学空间' },
{ key: 'course-categories', label: '课程分类', hint: '课程所属教学领域' },
]
const auth = useAuthStore()
@@ -40,6 +41,12 @@ const groups = {
description: '按校区和教学楼维护可用于排课的教室资源。',
kinds: ['buildings', 'classrooms'] as Kind[],
},
'course-categories': {
title: '课程分类',
kicker: 'COURSE TAXONOMY',
description: '统一维护基础课、美育、德育、创新创业等课程教学领域。课程性质与课程分类相互独立。',
kinds: ['course-categories'] as Kind[],
},
}
const group = computed(() => {
const key = String(route.meta.baseGroup ?? 'organization') as keyof typeof groups
+114 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { Document, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
@@ -9,13 +10,17 @@ const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const colleges = ref<any[]>([])
const categories = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref('')
const importing = ref(false)
const importInput = ref<HTMLInputElement>()
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
collegeId: undefined as string | undefined,
categoryId: undefined as string | undefined,
nature: undefined as string | undefined,
isEnabled: undefined as boolean | undefined,
})
@@ -68,6 +73,7 @@ function resetForm(row?: any) {
name: '',
englishName: '',
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
courseCategoryId: categories.value.find((item) => item.isEnabled)?.id,
credits: 2,
totalHours: 32,
lectureHours: 24,
@@ -89,6 +95,7 @@ async function load() {
pageSize: query.pageSize,
keyword: query.keyword || undefined,
collegeId: query.collegeId,
categoryId: query.categoryId,
nature: query.nature,
isEnabled: query.isEnabled,
},
@@ -107,12 +114,67 @@ async function resetFilters() {
page: 1,
keyword: '',
collegeId: undefined,
categoryId: undefined,
nature: undefined,
isEnabled: undefined,
})
await load()
}
function exportParams() {
return {
keyword: query.keyword || undefined,
collegeId: query.collegeId,
categoryId: query.categoryId,
nature: query.nature,
isEnabled: query.isEnabled,
}
}
async function downloadTemplate() {
try {
await downloadApiFile('/courses/template', '课程库导入模板.xlsx')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportRows() {
try {
await downloadApiFile(
'/courses/export',
'课程库.xlsx',
{ params: exportParams() },
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function chooseImportFile() {
importInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
importing.value = true
try {
const { data } = await importExcel('/courses/import', file)
ElMessage.success(
`导入完成:新增 ${data.created} 门,更新 ${data.updated} 门,共处理 ${data.total} 门。`,
)
query.page = 1
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
importing.value = false
}
}
function openCreate() {
editingId.value = ''
resetForm()
@@ -126,8 +188,9 @@ function openEdit(row: any) {
}
async function save() {
if (!form.code?.trim() || !form.name?.trim() || !form.collegeId) {
ElMessage.warning('请填写课程编码、名称和开课学院。')
if (!form.code?.trim() || !form.name?.trim() ||
!form.collegeId || !form.courseCategoryId) {
ElMessage.warning('请填写课程编码、名称、开课学院和课程分类。')
return
}
if (form.lectureHours + form.practiceHours > form.totalHours) {
@@ -161,7 +224,12 @@ async function remove(row: any) {
}
onMounted(async () => {
colleges.value = (await http.get('/base-data/colleges')).data
const [collegeRes, categoryRes] = await Promise.all([
http.get('/base-data/colleges'),
http.get('/base-data/course-categories'),
])
colleges.value = collegeRes.data
categories.value = categoryRes.data
await load()
})
</script>
@@ -174,7 +242,10 @@ onMounted(async () => {
<h2>课程库</h2>
<p>通识课程由校级统筹专业课与实践课由开课学院维护统一供培养方案与教学任务引用</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
<div class="page-actions">
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
</div>
</section>
<section class="course-ledger">
@@ -202,6 +273,9 @@ onMounted(async () => {
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.categoryId" clearable placeholder="全部课程分类">
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.nature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in natureLabels" :key="value" :label="label" :value="value" />
</el-select>
@@ -212,6 +286,20 @@ onMounted(async () => {
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<div class="table-toolbar">
<template v-if="canManage">
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
<input
ref="importInput"
class="visually-hidden"
type="file"
accept=".xlsx"
@change="handleImport"
/>
</template>
<span>当前筛选条件会原样用于 Excel 导出 · {{ total }} </span>
</div>
<el-table v-loading="loading" :data="rows" class="data-table course-table">
<el-table-column label="课程编码" width="130">
@@ -226,6 +314,11 @@ onMounted(async () => {
</template>
</el-table-column>
<el-table-column prop="collegeName" label="开课学院" min-width="145" />
<el-table-column label="课程分类" width="105">
<template #default="{ row }">
<span class="course-category">{{ row.categoryName || '未分类' }}</span>
</template>
</el-table-column>
<el-table-column label="性质" width="105">
<template #default="{ row }">{{ natureLabels[row.nature] }}</template>
</el-table-column>
@@ -279,11 +372,27 @@ onMounted(async () => {
<el-option v-for="item in formColleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课程分类" required>
<el-select v-model="form.courseCategoryId" filterable>
<el-option
v-for="item in categories.filter((category) => category.isEnabled)"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="课程性质">
<el-select v-model="form.nature">
<el-option v-for="(label, value) in collegeNatureLabels" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<div class="taxonomy-note">
<b>两个维度</b>
<span>分类表示教学领域性质决定课程在培养方案中的管理方式</span>
</div>
</div>
<div class="form-grid three">
<el-form-item label="学分"><el-input-number v-model="form.credits" :min="0.1" :max="99" :step="0.5" /></el-form-item>
+22 -7
View File
@@ -42,8 +42,8 @@ const statusLabels: Record<string, string> = {
Archived: '已归档',
}
const courseTypeLabels: Record<string, string> = {
Required: '必修',
Elective: '选修',
Required: '指定必修',
Elective: '组内选修',
}
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
const availableMajors = computed(() => majors.value)
@@ -410,7 +410,7 @@ onMounted(async () => {
<div class="module-toolbar">
<div>
<b>课程结构</b>
<span>发布前模块学分合计须与方案总学分一致</span>
<span>指定必修须逐门通过英语体育等多选课程用组内选修修满模块最低学分即可</span>
</div>
<el-button v-if="isDraft" :icon="Plus" @click="openModule()">新增模块</el-button>
</div>
@@ -421,7 +421,12 @@ onMounted(async () => {
<span>{{ module.code }}</span>
<h4>{{ module.name }}</h4>
</div>
<p>最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分</p>
<p>
最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分
<template v-if="module.courses.some((course: any) => course.type === 'Elective')">
· 课程组选项
</template>
</p>
<div v-if="isDraft">
<el-button link type="primary" @click="openModule(module)">编辑</el-button>
<el-button link type="danger" @click="deleteModule(module)">删除</el-button>
@@ -436,6 +441,9 @@ onMounted(async () => {
<el-table-column label="类别" width="80">
<template #default="{ row }">{{ courseTypeLabels[row.type] }}</template>
</el-table-column>
<el-table-column label="课程分类" width="100">
<template #default="{ row }">{{ row.categoryName || '未分类' }}</template>
</el-table-column>
<el-table-column prop="credits" label="学分" width="70" />
<el-table-column prop="totalHours" label="学时" width="70" />
<el-table-column label="建议学期" width="90">
@@ -489,7 +497,10 @@ onMounted(async () => {
<el-form-item label="排序"><el-input-number v-model="moduleForm.sortOrder" :min="0" /></el-form-item>
</div>
<el-form-item label="模块名称" required><el-input v-model="moduleForm.name" /></el-form-item>
<el-form-item label="最低学分要求"><el-input-number v-model="moduleForm.requiredCredits" :min="0" :precision="1" :step="0.5" /></el-form-item>
<el-form-item label="最低学分要求">
<el-input-number v-model="moduleForm.requiredCredits" :min="0" :precision="1" :step="0.5" />
<span class="form-help">体育英语等课程组填写学生必须从组内修满的学分</span>
</el-form-item>
</el-form>
<template #footer><el-button @click="moduleDialog = false">取消</el-button><el-button type="primary" @click="saveModule">保存</el-button></template>
</el-dialog>
@@ -498,14 +509,18 @@ onMounted(async () => {
<el-form label-position="top">
<el-form-item label="课程" required>
<el-select v-model="courseForm.courseId" filterable>
<el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}${item.categoryName ? ` · ${item.categoryName}` : ''}`" :value="item.id" />
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="修读类别"><el-select v-model="courseForm.type"><el-option v-for="(label, value) in courseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="修读规则"><el-select v-model="courseForm.type"><el-option v-for="(label, value) in courseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="建议学期"><el-input-number v-model="courseForm.recommendedSemester" :min="1" :max="selected?.schoolingYears * 2" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="courseForm.notes" type="textarea" :rows="2" /></el-form-item>
<div class="course-rule-help">
<b>指定必修</b><span>该门课程必须通过</span>
<b>组内选修</b><span>足球篮球英语分层等作为选项按模块最低学分验收</span>
</div>
</el-form>
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
</el-dialog>
+2 -2
View File
@@ -155,7 +155,7 @@ onMounted(load)
</div>
<div class="certificate-metrics">
<div><span>学分完成</span><b>{{ myResult.earnedCredits }} / {{ myResult.requiredCredits }}</b><el-progress :percentage="percent(myResult.earnedCredits, myResult.requiredCredits)" :show-text="false" /></div>
<div><span>必修课程</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>通过 / 通过</p></div>
<div><span>必修要求</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>完成 / 完成</p></div>
<div><span>未解决不及格</span><b>{{ myResult.failedCourseCount }}</b><p>门课程</p></div>
</div>
<footer>
@@ -206,7 +206,7 @@ onMounted(load)
<article v-for="row in filteredResults" :key="row.id">
<div class="graduation-student"><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}</p></div>
<div class="credit-progress"><span>学分完成度</span><b>{{ row.earnedCredits }} / {{ row.requiredCredits }}</b><el-progress :percentage="percent(row.earnedCredits, row.requiredCredits)" :show-text="false" /></div>
<div class="course-clearance"><span>必修通过</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">{{ row.missingCourseNames }}</small><small v-else>必修项目已完成</small></div>
<div class="course-clearance"><span>必修要求</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">{{ row.missingCourseNames }}</small><small v-else>指定必修与课程组均已完成</small></div>
<div class="graduation-conclusion" :class="{ eligible: row.conclusion === 'Eligible' }"><el-icon><Check /></el-icon><span>{{ conclusionLabels[row.conclusion] }}</span><small v-if="row.isOverridden">人工复核</small><small v-else>规则计算</small></div>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openDecision(row)">人工复核</el-button>
</article>