Files
Academic-Affairs-System/web/src/views/CurriculumView.vue
T
biss ce65fe82d9 已发布方案可修改名称、总学分、说明、课程模块及课程配置。
适用专业、入学年级、版本号保持锁定;前端禁用,后端也会返回 409 拒绝篡改。
归档方案仍只读。
修改已发布方案前增加即时生效警示和二次确认。
2026-07-25 09:42:09 +08:00

577 lines
23 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 { 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 colleges = ref<any[]>([])
const majors = ref<any[]>([])
const grades = ref<number[]>([])
const courses = ref<any[]>([])
const planDialog = ref(false)
const moduleDialog = ref(false)
const courseDialog = ref(false)
const cloneDialog = ref(false)
const editingPlanId = ref('')
const editingPlanStatus = ref('')
const editingModuleId = ref('')
const editingCourseId = ref('')
const activeModuleId = ref('')
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
collegeId: undefined as string | undefined,
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 isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
const availableMajors = computed(() => majors.value)
const filteredMajors = computed(() =>
query.collegeId
? majors.value.filter((item) => item.collegeId === query.collegeId)
: majors.value,
)
const availableGrades = computed(() =>
[...new Set([
...grades.value,
...plans.value.map((item) => Number(item.effectiveGrade)),
])].sort((first, second) => second - first),
)
const maintenanceScope = computed(() =>
isCollegeAdmin.value
? colleges.value[0]?.name ?? '所属学院'
: '全校各学院',
)
const isDraft = computed(() => selected.value?.status === 'Draft')
const isPublished = computed(() => selected.value?.status === 'Published')
const canEdit = computed(() => isDraft.value || isPublished.value)
const isEditingPublished = computed(() => editingPlanStatus.value === 'Published')
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: '',
collegeId: isCollegeAdmin.value ? auth.user?.collegeId : undefined,
majorId: undefined,
effectiveGrade: undefined,
status: undefined,
})
loadPlans(false)
}
function changeCollege() {
if (query.majorId &&
!filteredMajors.value.some((item) => item.id === query.majorId)) {
query.majorId = undefined
}
}
function openPlan(row?: any) {
editingPlanId.value = row?.id ?? ''
editingPlanStatus.value = row?.status ?? ''
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 confirmPublishedChange(action: string) {
if (!isPublished.value) return true
try {
await ElMessageBox.confirm(
`${action}保存后会立即影响学生端培养方案展示及毕业审核依据。确定继续吗?`,
'修改已发布方案',
{
type: 'warning',
confirmButtonText: '确认修改',
cancelButtonText: '取消',
},
)
return true
} catch {
return false
}
}
async function savePlan() {
if (!planForm.majorId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写专业、方案名称和版本号。')
return
}
if (editingPlanId.value && !await confirmPublishedChange('方案信息')) 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
}
if (!await confirmPublishedChange('课程模块')) 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 {
const impact = isPublished.value
? '删除后会立即影响学生端展示及毕业审核。'
: ''
await ElMessageBox.confirm(`删除“${module.name}”及其中课程?${impact}`, '删除课程模块', {
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
}
if (!await confirmPublishedChange('方案课程')) 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 {
const impact = isPublished.value
? '移除后会立即影响学生端展示及毕业审核。'
: ''
await ElMessageBox.confirm(`从方案中移除“${item.courseName}”?${impact}`, '移除课程', {
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 [optionRes, courseRes] = await Promise.all([
http.get('/curriculum-plans/filter-options'),
http.get('/courses/options'),
])
colleges.value = optionRes.data.colleges
majors.value = optionRes.data.majors
grades.value = optionRes.data.grades
courses.value = courseRes.data
if (isCollegeAdmin.value && auth.user?.collegeId) {
query.collegeId = auth.user.collegeId
}
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="maintenance-scope" aria-label="培养方案维护范围">
<div>
<span>维护主体</span>
<b>{{ isCollegeAdmin ? '学院教务' : '校级统筹' }}</b>
</div>
<div>
<span>当前范围</span>
<b>{{ maintenanceScope }}</b>
</div>
<p>
{{ isCollegeAdmin
? '可新建、编制、发布并维护本学院各专业培养方案'
: '可跨学院查看、发布并统筹维护培养方案版本' }}
</p>
</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.effectiveGrade"
clearable
placeholder="全部年级"
>
<el-option v-for="grade in availableGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
</el-select>
<el-select
v-model="query.collegeId"
clearable
:disabled="isCollegeAdmin"
placeholder="全部学院"
@change="changeCollege"
>
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
<el-option v-for="item in filteredMajors" :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; 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="canEdit" @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>
<el-alert
v-if="isPublished"
class="published-edit-notice"
type="warning"
show-icon
:closable="false"
title="当前方案已发布,修改保存后会立即影响学生端展示及毕业审核依据;适用专业、入学年级和版本号不可更改。"
/>
<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="canEdit" :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 }} 学分
<template v-if="module.courses.some((course: any) => course.type === 'Elective')">
· 课程组选项
</template>
</p>
<div v-if="canEdit">
<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 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">
<template #default="{ row }"> {{ row.recommendedSemester }} 学期</template>
</el-table-column>
<el-table-column v-if="canEdit" 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-alert
v-if="isEditingPublished"
type="warning"
show-icon
:closable="false"
title="已发布方案的修改会立即生效。适用专业、入学年级和版本号已锁定,如需调整请复制为新版本。"
/>
<el-form label-position="top">
<el-form-item label="适用专业" required>
<el-select v-model="planForm.majorId" filterable :disabled="isEditingPublished"><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" :disabled="isEditingPublished" /></el-form-item>
<el-form-item label="适用入学年级" required><el-input-number v-model="planForm.effectiveGrade" :min="2000" :max="2200" :disabled="isEditingPublished" /></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" />
<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>
<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}${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-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>
</div>
</template>