课程库 Excel 导入导出已完成:
支持下载标准导入模板。 导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。 以课程编码为唯一键:存在则更新,不存在则新增。 校验学院编码、课程分类、课程性质、学分学时及考核方式。 整批原子导入,任一行错误则全部不写入,并提示具体行号。 学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。 界面入口沿用现有课程库工具栏样式。
This commit is contained in:
@@ -74,6 +74,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
key: 'teaching-construction',
|
||||
label: '教学建设',
|
||||
items: [
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||
{ path: '/base-data/course-categories', label: '课程分类' },
|
||||
),
|
||||
{ path: '/courses', label: '课程库' },
|
||||
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
||||
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
||||
|
||||
@@ -52,6 +52,15 @@ const router = createRouter({
|
||||
baseGroup: 'facilities',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'base-data/course-categories',
|
||||
name: 'course-category-data',
|
||||
component: () => import('../views/BaseDataView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin'],
|
||||
baseGroup: 'course-categories',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'personnel',
|
||||
redirect: '/teachers',
|
||||
|
||||
@@ -227,6 +227,14 @@ button { cursor: pointer; }
|
||||
.record-name, .course-name { display: grid; gap: 3px; }
|
||||
.record-name b, .course-name b { color: var(--ink); font-size: 13px; }
|
||||
.record-name span, .course-name span { color: #9299a7; font-size: 10px; }
|
||||
.course-category { display: inline-flex; padding: 3px 8px; color: #335b75; background: #eaf2f5; border-radius: 12px; font-size: 10px; white-space: nowrap; }
|
||||
.taxonomy-note { min-height: 56px; padding: 8px 12px; display: grid; align-content: center; gap: 4px; border-left: 3px solid var(--teal); background: #f4f8f8; }
|
||||
.taxonomy-note b { color: var(--ink); font-size: 11px; }
|
||||
.taxonomy-note span { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.form-help { margin-left: 10px; color: var(--muted); font-size: 10px; }
|
||||
.course-rule-help { margin-top: 4px; padding: 12px 14px; display: grid; grid-template-columns: 74px 1fr; gap: 7px 12px; background: #f5f8fa; border-left: 3px solid var(--teal); }
|
||||
.course-rule-help b { color: var(--ink); font-size: 11px; }
|
||||
.course-rule-help span { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.pagination-bar { min-height: 62px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
|
||||
.pagination-bar > span { color: var(--muted); font-size: 11px; }
|
||||
.course-ledger { min-height: 92px; display: flex; align-items: stretch; color: white; background: linear-gradient(105deg, #1a2d61, #263f80); overflow: hidden; position: relative; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user