培养方案:专业/年级版本、课程模块、学分校验、复制版本、发布锁定、旧版本归档。

教学任务:学期开课、授课教师、唯一主讲、合班、容量与周次校验、发布及结课。
SQLite 开发库已自动升级,无需删除原数据库。
新增对应 MySQL EF Core 迁移。
Vue 已编译进 wwwroot,仍可只运行 ASP.NET Core 单服务。
桌面端及 390px 窄屏页面验证通过,无前端控制台错误。
This commit is contained in:
2026-07-24 14:10:43 +08:00 Unverified
parent 30d793773c
commit 628944f64d
23 changed files with 5430 additions and 47 deletions
+20 -2
View File
@@ -6,6 +6,8 @@ import {
DataAnalysis,
OfficeBuilding,
Operation,
Reading,
Tickets,
User,
UserFilled,
} from '@element-plus/icons-vue'
@@ -23,6 +25,8 @@ const pageTitle = computed(() => {
'base-data': '基础数据',
personnel: '人员档案',
courses: '课程库',
curriculum: '培养方案',
'teaching-tasks': '教学任务',
users: '用户与权限',
}
return titles[String(route.name)] ?? '教务管理'
@@ -80,6 +84,20 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><Collection /></el-icon>
<template #title>课程库</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role))"
index="/curriculum"
>
<el-icon><Reading /></el-icon>
<template #title>培养方案</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role))"
index="/teaching-tasks"
>
<el-icon><Tickets /></el-icon>
<template #title>教学任务</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -87,8 +105,8 @@ onMounted(() => auth.refresh().catch(() => undefined))
</el-menu>
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 基础底座</span>
<p>组织权限和教学资源正在运行</p>
<span>第一阶段 · 核心可用版</span>
<p>主数据与培养方案正在运行</p>
</div>
</aside>
+12
View File
@@ -39,6 +39,18 @@ const router = createRouter({
name: 'courses',
component: () => import('../views/CoursesView.vue'),
},
{
path: 'curriculum',
name: 'curriculum',
component: () => import('../views/CurriculumView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{
path: 'teaching-tasks',
name: 'teaching-tasks',
component: () => import('../views/TeachingTasksView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{
path: 'users',
name: 'users',
+66
View File
@@ -102,6 +102,7 @@ button { cursor: pointer; }
.phase-line span.active::before { background: var(--amber); box-shadow: 0 0 0 4px #f5ead6; }
.page-stack { display: grid; gap: 18px; }
.page-stack > * { min-width: 0; }
.page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; }
.page-intro p { margin: 0; color: var(--muted); font-size: 13px; }
@@ -156,6 +157,55 @@ button { cursor: pointer; }
.ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; }
.ledger-rule span { padding: 8px 28px; border-right: 1px solid rgba(255,255,255,.12); color: #d2d8e9; font-size: 11px; letter-spacing: .08em; }
.curriculum-filter { padding: 14px 18px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; border: 1px solid var(--line); background: white; }
.curriculum-filter > .el-input { width: 260px; }
.curriculum-filter > .el-select { width: 170px; }
.curriculum-workspace { min-height: 620px; display: grid; grid-template-columns: 285px minmax(0, 1fr); border: 1px solid var(--line); background: white; }
.plan-list { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: #fafbfc; }
.plan-list > button { position: relative; padding: 19px 20px; display: grid; gap: 6px; text-align: left; border: none; border-bottom: 1px solid var(--line); background: transparent; }
.plan-list > button:hover { background: white; }
.plan-list > button.active { padding-left: 17px; border-left: 3px solid var(--teal); background: white; }
.plan-list > button span { padding-right: 55px; color: var(--muted); font-size: 10px; }
.plan-list > button b { font-size: 13px; line-height: 1.5; }
.plan-list > button small { color: #969eac; font-size: 10px; }
.plan-list > button i { position: absolute; right: 14px; top: 16px; padding: 3px 7px; border-radius: 10px; color: #777f8d; background: #eef0f3; font-size: 9px; font-style: normal; }
.plan-list > button i.published { color: var(--teal); background: #e5f4f1; }
.plan-list > button i.archived { color: #8d7760; background: #f2ece4; }
.plan-pagination { margin-top: auto; min-height: 58px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
.plan-pagination > span { color: var(--muted); font-size: 10px; }
.plan-detail { min-width: 0; padding: 26px; }
.plan-detail-head { display: flex; justify-content: space-between; gap: 20px; padding-bottom: 21px; border-bottom: 1px solid var(--line); }
.plan-detail-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .08em; }
.plan-detail-head h3 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 23px; }
.plan-detail-head p { margin: 0; color: var(--muted); font-size: 11px; }
.plan-actions { display: flex; flex-wrap: wrap; align-content: flex-start; justify-content: flex-end; gap: 7px; }
.plan-actions .el-button + .el-button { margin-left: 0; }
.plan-metrics { margin: 18px 0; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); }
.plan-metrics > div { padding: 15px 17px; border-right: 1px solid var(--line); }
.plan-metrics > div:last-child { border-right: none; }
.plan-metrics span, .plan-metrics b { display: block; }
.plan-metrics span { color: var(--muted); font-size: 10px; }
.plan-metrics b { margin-top: 6px; font: 700 18px/1.2 Consolas, monospace; }
.plan-metrics b.warning { color: #b36f12; }
.module-toolbar { margin: 25px 0 12px; display: flex; align-items: center; justify-content: space-between; }
.module-toolbar b, .module-toolbar span { display: block; }
.module-toolbar b { font-size: 15px; }
.module-toolbar span { margin-top: 4px; color: var(--muted); font-size: 10px; }
.curriculum-module { margin-top: 12px; border: 1px solid var(--line); }
.curriculum-module > header { min-height: 72px; padding: 13px 16px; display: flex; align-items: center; gap: 18px; background: #f8fafb; border-bottom: 1px solid var(--line); }
.curriculum-module > header > div:first-child { min-width: 160px; }
.curriculum-module > header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .12em; }
.curriculum-module h4 { margin: 6px 0 0; font-size: 14px; }
.curriculum-module > header p { margin: 0 auto 0 0; color: var(--muted); font-size: 11px; }
.curriculum-module > header > div:last-child { display: flex; align-items: center; white-space: nowrap; }
.curriculum-course-table { min-height: 80px; }
.task-summary { min-height: 82px; padding: 15px 22px; display: flex; align-items: center; gap: 28px; color: white; background: linear-gradient(108deg, #17295a, #263f80); }
.task-summary > div { min-width: 170px; display: flex; align-items: baseline; gap: 8px; }
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
.task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; }
.muted-action { color: #9aa1ae; font-size: 11px; }
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -222,6 +272,22 @@ button { cursor: pointer; }
.ledger-rule { overflow-x: auto; }
.ledger-rule span { padding: 12px 20px; white-space: nowrap; }
.data-card { overflow: hidden; }
.curriculum-filter > .el-input { width: 100%; }
.curriculum-filter > .el-select { flex: 1 1 140px; width: auto; }
.curriculum-workspace { display: block; }
.plan-list { max-height: 310px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
.plan-pagination { position: sticky; bottom: 0; background: #fafbfc; }
.plan-detail { padding: 18px 14px; }
.plan-detail-head { display: block; }
.plan-actions { margin-top: 16px; justify-content: flex-start; }
.plan-metrics { grid-template-columns: 1fr 1fr; }
.plan-metrics > div:nth-child(2) { border-right: none; }
.plan-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.curriculum-module > header { align-items: flex-start; flex-wrap: wrap; }
.curriculum-module > header p { order: 3; width: 100%; }
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
.task-summary > div { width: 100%; }
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
.el-dialog { width: calc(100vw - 24px) !important; }
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
+453
View File
@@ -0,0 +1,453 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const detailLoading = ref(false)
const plans = ref<any[]>([])
const total = ref(0)
const selected = ref<any | null>(null)
const majors = ref<any[]>([])
const courses = ref<any[]>([])
const planDialog = ref(false)
const moduleDialog = ref(false)
const courseDialog = ref(false)
const cloneDialog = ref(false)
const editingPlanId = ref('')
const editingModuleId = ref('')
const editingCourseId = ref('')
const activeModuleId = ref('')
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
majorId: undefined as string | undefined,
effectiveGrade: undefined as number | undefined,
status: undefined as string | undefined,
})
const planForm = reactive<Record<string, any>>({})
const moduleForm = reactive<Record<string, any>>({})
const courseForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const courseTypeLabels: Record<string, string> = {
Required: '必修',
Elective: '选修',
}
const availableMajors = computed(() =>
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
? majors.value.filter((item) => item.collegeId === auth.user!.collegeId)
: majors.value,
)
const isDraft = computed(() => selected.value?.status === 'Draft')
const configuredCredits = computed(() =>
selected.value?.modules.reduce(
(sum: number, module: any) => sum + Number(module.requiredCredits),
0,
) ?? 0,
)
async function loadPlans(keepSelection = true) {
loading.value = true
try {
const { data } = await http.get('/curriculum-plans', {
params: {
...query,
keyword: query.keyword || undefined,
},
})
plans.value = data.items
total.value = data.total
const targetId = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (targetId && plans.value.some((item) => item.id === targetId)) {
await loadDetail(targetId)
} else {
selected.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadDetail(id: string) {
detailLoading.value = true
try {
selected.value = (await http.get(`/curriculum-plans/${id}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
majorId: undefined,
effectiveGrade: undefined,
status: undefined,
})
loadPlans(false)
}
function openPlan(row?: any) {
editingPlanId.value = row?.id ?? ''
Object.keys(planForm).forEach((key) => delete planForm[key])
Object.assign(planForm, {
majorId: row?.majorId,
name: row?.name ?? '',
version: row?.version ?? `${new Date().getFullYear()}`,
effectiveGrade: row?.effectiveGrade ?? new Date().getFullYear(),
totalCredits: row?.totalCredits ?? 160,
description: row?.description ?? '',
})
planDialog.value = true
}
async function savePlan() {
if (!planForm.majorId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写专业、方案名称和版本号。')
return
}
try {
if (editingPlanId.value) {
await http.put(`/curriculum-plans/${editingPlanId.value}`, planForm)
} else {
await http.post('/curriculum-plans', planForm)
}
ElMessage.success(editingPlanId.value ? '方案信息已更新' : '培养方案草稿已创建')
planDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openClone() {
Object.assign(cloneForm, {
name: selected.value.name,
version: `${new Date().getFullYear()}`,
effectiveGrade: Math.max(new Date().getFullYear(), selected.value.effectiveGrade + 1),
})
cloneDialog.value = true
}
async function clonePlan() {
try {
await http.post(`/curriculum-plans/${selected.value.id}/clone`, cloneForm)
ElMessage.success('已复制为新的草稿版本')
cloneDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm(
'发布后该版本将锁定,并自动归档同专业、同年级的旧发布版本。确定发布吗?',
'发布培养方案',
{ type: 'warning', confirmButtonText: '确认发布', cancelButtonText: '取消' },
)
await http.post(`/curriculum-plans/${selected.value.id}/publish`)
ElMessage.success('培养方案已发布')
await loadPlans()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function deletePlan() {
try {
await ElMessageBox.confirm('确定删除这份草稿及其全部课程配置吗?', '删除草稿', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await http.delete(`/curriculum-plans/${selected.value.id}`)
ElMessage.success('草稿已删除')
await loadPlans(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openModule(module?: any) {
editingModuleId.value = module?.id ?? ''
Object.assign(moduleForm, {
code: module?.code ?? '',
name: module?.name ?? '',
requiredCredits: module?.requiredCredits ?? 0,
sortOrder: module?.sortOrder ?? (selected.value.modules.length + 1) * 10,
})
moduleDialog.value = true
}
async function saveModule() {
if (!moduleForm.code?.trim() || !moduleForm.name?.trim()) {
ElMessage.warning('请填写模块编码和名称。')
return
}
try {
const base = `/curriculum-plans/${selected.value.id}/modules`
if (editingModuleId.value) await http.put(`${base}/${editingModuleId.value}`, moduleForm)
else await http.post(base, moduleForm)
moduleDialog.value = false
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteModule(module: any) {
try {
await ElMessageBox.confirm(`删除“${module.name}”及其中课程?`, '删除课程模块', {
type: 'warning',
})
await http.delete(`/curriculum-plans/${selected.value.id}/modules/${module.id}`)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openCourse(moduleId: string, item?: any) {
activeModuleId.value = moduleId
editingCourseId.value = item?.id ?? ''
Object.assign(courseForm, {
courseId: item?.courseId,
recommendedSemester: item?.recommendedSemester ?? 1,
type: item?.type ?? 'Required',
notes: item?.notes ?? '',
})
courseDialog.value = true
}
async function saveCourse() {
if (!courseForm.courseId) {
ElMessage.warning('请选择课程。')
return
}
try {
const base = `/curriculum-plans/${selected.value.id}/modules/${activeModuleId.value}/courses`
if (editingCourseId.value) await http.put(`${base}/${editingCourseId.value}`, courseForm)
else await http.post(base, courseForm)
courseDialog.value = false
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteCourse(moduleId: string, item: any) {
try {
await ElMessageBox.confirm(`从方案中移除“${item.courseName}”?`, '移除课程', {
type: 'warning',
})
await http.delete(
`/curriculum-plans/${selected.value.id}/modules/${moduleId}/courses/${item.id}`,
)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [majorRes, courseRes] = await Promise.all([
http.get('/base-data/majors'),
http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }),
])
majors.value = majorRes.data
courses.value = courseRes.data.items
await loadPlans(false)
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">PROGRAMME OF STUDY</span>
<h2>培养方案</h2>
<p>按专业和入学年级管理版本化课程结构发布后锁定避免影响历史年级</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
</section>
<section class="curriculum-filter">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索方案、版本或专业" @keyup.enter="query.page = 1; loadPlans(false)" />
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
<el-option v-for="item in availableMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input-number v-model="query.effectiveGrade" :min="2000" :max="2200" placeholder="适用年级" />
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="query.page = 1; loadPlans(false)">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</section>
<div class="curriculum-workspace">
<aside v-loading="loading" class="plan-list">
<button
v-for="plan in plans"
:key="plan.id"
type="button"
:class="{ active: selected?.id === plan.id }"
@click="loadDetail(plan.id)"
>
<span>{{ plan.majorName }} · {{ plan.effectiveGrade }}</span>
<b>{{ plan.name }}</b>
<small>{{ plan.version }} · {{ plan.courseCount }} 门课程</small>
<i :class="plan.status.toLowerCase()">{{ statusLabels[plan.status] }}</i>
</button>
<el-empty v-if="!loading && plans.length === 0" description="暂无培养方案" :image-size="70" />
<div class="plan-pagination">
<span> {{ total }} </span>
<el-pagination v-model:current-page="query.page" :page-size="query.pageSize" :total="total" layout="prev, next" @current-change="loadPlans(false)" />
</div>
</aside>
<main v-loading="detailLoading" class="plan-detail">
<el-empty v-if="!selected" description="请选择或新建一份培养方案" />
<template v-else>
<header class="plan-detail-head">
<div>
<span>{{ selected.collegeName }} / {{ selected.majorName }}</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.version }} · 适用 {{ selected.effectiveGrade }} · {{ selected.schoolingYears }} 年制</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" @click="openPlan(selected)">编辑</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制版本</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除</el-button>
</div>
</header>
<div class="plan-metrics">
<div><span>方案状态</span><b>{{ statusLabels[selected.status] }}</b></div>
<div><span>总学分要求</span><b>{{ selected.totalCredits }}</b></div>
<div><span>模块学分合计</span><b :class="{ warning: configuredCredits !== Number(selected.totalCredits) }">{{ configuredCredits }}</b></div>
<div><span>课程数量</span><b>{{ selected.modules.reduce((sum: number, item: any) => sum + item.courses.length, 0) }}</b></div>
</div>
<div class="module-toolbar">
<div>
<b>课程结构</b>
<span>发布前模块学分合计须与方案总学分一致</span>
</div>
<el-button v-if="isDraft" :icon="Plus" @click="openModule()">新增模块</el-button>
</div>
<section v-for="module in selected.modules" :key="module.id" class="curriculum-module">
<header>
<div>
<span>{{ module.code }}</span>
<h4>{{ module.name }}</h4>
</div>
<p>最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分</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>
<el-button size="small" :icon="Plus" @click="openCourse(module.id)">添加课程</el-button>
</div>
</header>
<el-table :data="module.courses" class="curriculum-course-table">
<el-table-column prop="courseCode" label="课程编码" width="115" />
<el-table-column label="课程名称" min-width="180">
<template #default="{ row }"><b>{{ row.courseName }}</b></template>
</el-table-column>
<el-table-column label="类别" width="80">
<template #default="{ row }">{{ courseTypeLabels[row.type] }}</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">
<template #default="{ row }"> {{ row.recommendedSemester }} 学期</template>
</el-table-column>
<el-table-column v-if="isDraft" label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="openCourse(module.id, row)">编辑</el-button>
<el-button link type="danger" @click="deleteCourse(module.id, row)">移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="该模块尚未配置课程" :image-size="54" /></template>
</el-table>
</section>
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
</template>
</main>
</div>
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑培养方案' : '新建培养方案'" width="620px">
<el-form label-position="top">
<el-form-item label="适用专业" required>
<el-select v-model="planForm.majorId" filterable><el-option v-for="item in availableMajors" :key="item.id" :label="item.name" :value="item.id" /></el-select>
</el-form-item>
<el-form-item label="方案名称" required><el-input v-model="planForm.name" /></el-form-item>
<div class="form-grid">
<el-form-item label="版本号" required><el-input v-model="planForm.version" /></el-form-item>
<el-form-item label="适用入学年级" required><el-input-number v-model="planForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div>
<el-form-item label="毕业总学分要求" required><el-input-number v-model="planForm.totalCredits" :min="0.1" :precision="1" :step="0.5" /></el-form-item>
<el-form-item label="方案说明"><el-input v-model="planForm.description" type="textarea" :rows="3" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog>
<el-dialog v-model="cloneDialog" title="复制为新版本" width="520px">
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<div class="form-grid">
<el-form-item label="新版本号" required><el-input v-model="cloneForm.version" /></el-form-item>
<el-form-item label="适用入学年级" required><el-input-number v-model="cloneForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div>
</el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
</el-dialog>
<el-dialog v-model="moduleDialog" :title="editingModuleId ? '编辑课程模块' : '新增课程模块'" width="520px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="模块编码" required><el-input v-model="moduleForm.code" /></el-form-item>
<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>
<template #footer><el-button @click="moduleDialog = false">取消</el-button><el-button type="primary" @click="saveModule">保存</el-button></template>
</el-dialog>
<el-dialog v-model="courseDialog" :title="editingCourseId ? '编辑方案课程' : '添加方案课程'" width="560px">
<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-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-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>
</el-form>
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
</el-dialog>
</div>
</template>
+13 -3
View File
@@ -86,18 +86,28 @@ onMounted(async () => {
<b>教师档案 + 学生档案 + 课程库</b>
<i class="done">已建立</i>
</div>
<div>
<span>培养方案</span>
<b>{{ data.counts.curriculumPlans ?? 0 }} 个版本 · 模块化课程结构</b>
<i class="done">已建立</i>
</div>
<div>
<span>教学任务</span>
<b>{{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>人员与课程主数据已就绪下一步进入培养方案课程开设与教学任务</p>
<p>学期教学任务已可发布下一步进入时间教室冲突校验与课表发布</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span>培养方案</span>
<span>教学运行</span>
<span class="active">培养方案</span>
<span class="active">教学任务</span>
</div>
</article>
</section>
+284
View File
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const editingId = ref('')
const terms = ref<any[]>([])
const courses = ref<any[]>([])
const teachers = ref<any[]>([])
const classes = ref<any[]>([])
const majors = ref<any[]>([])
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
academicTermId: undefined as string | undefined,
status: undefined as string | undefined,
})
const form = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Closed: '已结课',
}
const availableClasses = computed(() => {
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return classes.value
const ids = new Set(
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
)
return classes.value.filter((item) => ids.has(item.majorId))
})
const selectedTeachers = computed(() =>
teachers.value.filter((item) => form.teacherIds?.includes(item.id)),
)
async function load() {
loading.value = true
try {
const { data } = await http.get('/teaching-tasks', {
params: {
...query,
keyword: query.keyword || undefined,
},
})
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
academicTermId: undefined,
status: undefined,
})
load()
}
function resetForm(detail?: any) {
Object.keys(form).forEach((key) => delete form[key])
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(form, {
taskNumber: '',
name: '',
academicTermId: currentTerm?.id,
courseId: undefined,
capacity: 60,
startWeek: 1,
endWeek: 16,
weeklyHours: 2,
teacherIds: [],
primaryTeacherId: undefined,
classIds: [],
notes: '',
}, detail ?? {})
}
function openCreate() {
editingId.value = ''
resetForm()
dialogVisible.value = true
}
async function openEdit(row: any) {
try {
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
editingId.value = row.id
resetForm(detail)
dialogVisible.value = true
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function onTeachersChanged(values: string[]) {
if (form.primaryTeacherId && !values.includes(form.primaryTeacherId)) {
form.primaryTeacherId = undefined
}
}
async function save() {
if (!form.taskNumber?.trim() || !form.name?.trim() || !form.academicTermId || !form.courseId) {
ElMessage.warning('请填写任务编号、名称、学期和课程。')
return
}
if (form.startWeek > form.endWeek) {
ElMessage.warning('开始周不能晚于结束周。')
return
}
try {
if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form)
else await http.post('/teaching-tasks', form)
ElMessage.success(editingId.value ? '教学任务已更新' : '教学任务草稿已创建')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(`确定删除教学任务“${row.name}”吗?`, '删除草稿', {
type: 'warning',
})
await http.delete(`/teaching-tasks/${row.id}`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function publish(row: any) {
try {
await ElMessageBox.confirm(
'发布后任务将锁定,并可进入排课与选课流程。确定发布吗?',
'发布教学任务',
{ type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' },
)
await http.post(`/teaching-tasks/${row.id}/publish`)
ElMessage.success('教学任务已发布')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function closeTask(row: any) {
try {
await ElMessageBox.confirm('结课后将停止后续选课和排课调整,确定继续吗?', '教学任务结课', {
type: 'warning',
confirmButtonText: '确认结课',
cancelButtonText: '取消',
})
await http.post(`/teaching-tasks/${row.id}/close`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [termRes, courseRes, teacherRes, classRes, majorRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
http.get('/base-data/classes'),
http.get('/base-data/majors'),
])
terms.value = termRes.data
courses.value = courseRes.data.items
teachers.value = teacherRes.data.items
classes.value = classRes.data
majors.value = majorRes.data
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id
await load()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">TEACHING COMMISSION</span>
<h2>教学任务</h2>
<p>将学期课程授课教师和行政班组合为可排课可选课的教学班</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
</section>
<section class="task-summary">
<div><span>当前结果</span><b>{{ total }}</b><small>个教学班</small></div>
<p>发布校验主讲教师唯一 · 行政班已关联 · 容量覆盖在籍学生 · 周次有效</p>
</section>
<section class="data-card">
<div class="filter-bar">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" />
<el-select v-model="query.academicTermId" clearable placeholder="全部学期">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table task-table">
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
<el-table-column label="教学班 / 课程" min-width="230">
<template #default="{ row }"><div class="course-name"><b>{{ row.name }}</b><span>{{ row.courseCode }} · {{ row.courseName }}</span></div></template>
</el-table-column>
<el-table-column prop="termName" label="开课学期" min-width="180" />
<el-table-column label="授课教师" min-width="130"><template #default="{ row }">{{ row.teacherNames.join('、') || '未安排' }}</template></el-table-column>
<el-table-column label="行政班" min-width="150"><template #default="{ row }">{{ row.classNames.join('、') || '未关联' }}</template></el-table-column>
<el-table-column label="人数 / 容量" width="105"><template #default="{ row }">{{ row.studentCount }} / {{ row.capacity }}</template></el-table-column>
<el-table-column label="周次" width="105"><template #default="{ row }">{{ row.startWeek }}{{ row.endWeek }} </template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column>
<el-table-column label="操作" width="185" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'Draft'" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'Draft'" link type="success" @click="publish(row)">发布</el-button>
<el-button v-if="row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
<el-button v-if="row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
</el-table>
<div class="pagination-bar">
<span> {{ total }} 个教学班</span>
<el-pagination v-model:current-page="query.page" :page-size="query.pageSize" :total="total" layout="prev, pager, next" @current-change="load" />
</div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑教学任务' : '新建教学任务'" width="720px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="任务编号" required><el-input v-model="form.taskNumber" placeholder="如 2026-1-CS101-01" /></el-form-item>
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="课程" required><el-select v-model="form.courseId" filterable><el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
<el-form-item label="开始周"><el-input-number v-model="form.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="form.endWeek" :min="1" :max="30" /></el-form-item>
</div>
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
<el-form-item label="授课教师">
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
<el-option v-for="item in teachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="主讲教师">
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
<el-option v-for="item in selectedTeachers" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="关联行政班">
<el-select v-model="form.classIds" multiple filterable>
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="save">保存草稿</el-button></template>
</el-dialog>
</div>
</template>