已归档任务新增“撤销归档”和“删除”,支持单条及批量操作。 撤销后恢复为“已发布”状态。 已发布任务仍不可直接删除;被排课、选课、成绩或考试引用的归档任务也会拒绝删除并明确提示。 状态与操作文案统一为“归档”
902 lines
39 KiB
Vue
902 lines
39 KiB
Vue
<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 isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false)
|
||
const isAcademicAdmin = computed(() => auth.user?.roles.includes('AcademicAdmin') ?? false)
|
||
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
|
||
const canGeneratePublic = computed(() => isSuperAdmin.value || isAcademicAdmin.value)
|
||
const loading = ref(false)
|
||
const rows = ref<any[]>([])
|
||
const total = ref(0)
|
||
const dialogVisible = ref(false)
|
||
const generationDialog = ref(false)
|
||
const generating = ref(false)
|
||
const editingId = ref('')
|
||
const selectedRows = ref<any[]>([])
|
||
const terms = ref<any[]>([])
|
||
const courses = 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 generationForm = reactive<Record<string, any>>({})
|
||
const manualCourseFilters = reactive({
|
||
keyword: '',
|
||
collegeId: undefined as string | undefined,
|
||
nature: undefined as string | undefined,
|
||
})
|
||
const manualClassFilters = reactive({
|
||
keyword: '',
|
||
collegeId: undefined as string | undefined,
|
||
majorId: undefined as string | undefined,
|
||
grade: undefined as number | undefined,
|
||
})
|
||
const generationCourseFilters = reactive({
|
||
keyword: '',
|
||
collegeId: undefined as string | undefined,
|
||
})
|
||
const generationClassFilters = reactive({
|
||
keyword: '',
|
||
collegeId: undefined as string | undefined,
|
||
majorId: undefined as string | undefined,
|
||
grade: undefined as number | undefined,
|
||
})
|
||
const eligibleTeachers = ref<any[]>([])
|
||
const manualEligibleTeachers = ref<any[]>([])
|
||
const statusLabels: Record<string, string> = {
|
||
Draft: '草稿',
|
||
Published: '已发布',
|
||
Closed: '已归档',
|
||
}
|
||
const courseNatureLabels: Record<string, string> = {
|
||
GeneralRequired: '公共必修',
|
||
GeneralElective: '公共选修',
|
||
MajorRequired: '专业必修',
|
||
MajorElective: '专业选修',
|
||
Practice: '实践教学',
|
||
}
|
||
const availableClasses = computed(() => {
|
||
const enabled = classes.value.filter((item) => item.isEnabled)
|
||
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return enabled
|
||
const ids = new Set(
|
||
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
|
||
)
|
||
return enabled.filter((item) => ids.has(item.majorId))
|
||
})
|
||
const selectedCourse = computed(() =>
|
||
courses.value.find((item) => item.id === form.courseId),
|
||
)
|
||
const plannedHours = computed(() =>
|
||
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
|
||
? (form.endWeek - form.startWeek + 1) * form.weeklyHours
|
||
: 0,
|
||
)
|
||
const hoursMatch = computed(() =>
|
||
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours,
|
||
)
|
||
const assignableTeachers = computed(() =>
|
||
manualEligibleTeachers.value.map((item) => ({
|
||
id: item.teacherId,
|
||
teacherNumber: item.teacherNumber,
|
||
name: item.name,
|
||
title: item.title,
|
||
collegeName: item.collegeName,
|
||
})),
|
||
)
|
||
const selectedTeachers = computed(() =>
|
||
assignableTeachers.value.filter((item) => form.teacherIds?.includes(item.id)),
|
||
)
|
||
const publicCourses = computed(() =>
|
||
courses.value.filter((item) =>
|
||
['GeneralRequired', 'GeneralElective'].includes(item.nature),
|
||
),
|
||
)
|
||
const selectedGenerationCourse = computed(() =>
|
||
publicCourses.value.find((item) => item.id === generationForm.courseId),
|
||
)
|
||
const generationPlannedHours = computed(() =>
|
||
generationForm.startWeek &&
|
||
generationForm.endWeek &&
|
||
generationForm.weeklyHours &&
|
||
generationForm.endWeek >= generationForm.startWeek
|
||
? (generationForm.endWeek - generationForm.startWeek + 1) * generationForm.weeklyHours
|
||
: 0,
|
||
)
|
||
const generationHoursMatch = computed(() =>
|
||
!selectedGenerationCourse.value ||
|
||
generationPlannedHours.value === selectedGenerationCourse.value.totalHours,
|
||
)
|
||
const manageableCourses = computed(() => {
|
||
if (isSuperAdmin.value) return courses.value
|
||
return courses.value.filter((item) => {
|
||
const isPublic = ['GeneralRequired', 'GeneralElective'].includes(item.nature)
|
||
return isAcademicAdmin.value && isPublic ||
|
||
isCollegeAdmin.value &&
|
||
item.collegeId === auth.user?.collegeId &&
|
||
['MajorRequired', 'MajorElective', 'Practice'].includes(item.nature)
|
||
})
|
||
})
|
||
const courseColleges = computed(() => {
|
||
const result = new Map<string, string>()
|
||
courses.value.forEach((item) => result.set(item.collegeId, item.collegeName))
|
||
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
|
||
a.name.localeCompare(b.name, 'zh-CN'),
|
||
)
|
||
})
|
||
const classColleges = computed(() => {
|
||
const result = new Map<string, string>()
|
||
majors.value.forEach((item) => result.set(item.collegeId, item.collegeName))
|
||
return [...result].map(([id, name]) => ({ id, name })).sort((a, b) =>
|
||
a.name.localeCompare(b.name, 'zh-CN'),
|
||
)
|
||
})
|
||
const majorById = computed(() =>
|
||
new Map(majors.value.map((item) => [item.id, item])),
|
||
)
|
||
const classGrades = computed(() =>
|
||
[...new Set(availableClasses.value.map((item) => item.grade))].sort((a, b) => b - a),
|
||
)
|
||
const manualClassMajors = computed(() => filteredMajors(manualClassFilters.collegeId))
|
||
const generationClassMajors = computed(() => filteredMajors(generationClassFilters.collegeId))
|
||
const filteredManageableCourses = computed(() =>
|
||
manageableCourses.value.filter((item) => courseMatches(item, manualCourseFilters)),
|
||
)
|
||
const filteredPublicCourses = computed(() =>
|
||
publicCourses.value.filter((item) => courseMatches(item, generationCourseFilters)),
|
||
)
|
||
const filteredManualClasses = computed(() =>
|
||
availableClasses.value.filter((item) => classMatches(item, manualClassFilters)),
|
||
)
|
||
const filteredGenerationClasses = computed(() =>
|
||
availableClasses.value.filter((item) => classMatches(item, generationClassFilters)),
|
||
)
|
||
const selectedDraftRows = computed(() =>
|
||
selectedRows.value.filter((item) => item.status === 'Draft'),
|
||
)
|
||
const selectedPublishedRows = computed(() =>
|
||
selectedRows.value.filter((item) => item.status === 'Published'),
|
||
)
|
||
const selectedClosedRows = computed(() =>
|
||
selectedRows.value.filter((item) => item.status === 'Closed'),
|
||
)
|
||
const responsibilityText = computed(() => {
|
||
if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。'
|
||
if (isAcademicAdmin.value && isCollegeAdmin.value) {
|
||
return '兼岗账号负责校级公共课,以及本学院开设的专业必修、专业选修和实践课程。'
|
||
}
|
||
if (isCollegeAdmin.value) return '学院负责本院专业必修、专业选修和实践课程的教学任务。'
|
||
return '校级教务负责公共必修和公共选修课程;专业课教学任务由课程所属学院维护。'
|
||
})
|
||
const generationGroupCount = computed(() =>
|
||
Math.ceil((generationForm.classIds?.length ?? 0) / (generationForm.classesPerTask || 1)),
|
||
)
|
||
|
||
function canManageRow(row: any) {
|
||
if (isSuperAdmin.value) return true
|
||
const isPublic = ['GeneralRequired', 'GeneralElective'].includes(row.courseNature)
|
||
return isAcademicAdmin.value && isPublic ||
|
||
isCollegeAdmin.value && !isPublic
|
||
}
|
||
|
||
function courseMatches(item: any, filters: Record<string, any>) {
|
||
const keyword = filters.keyword.trim().toLocaleLowerCase()
|
||
return (!keyword ||
|
||
item.code.toLocaleLowerCase().includes(keyword) ||
|
||
item.name.toLocaleLowerCase().includes(keyword)) &&
|
||
(!filters.collegeId || item.collegeId === filters.collegeId) &&
|
||
(!filters.nature || item.nature === filters.nature)
|
||
}
|
||
|
||
function filteredMajors(collegeId?: string) {
|
||
return majors.value
|
||
.filter((item) =>
|
||
item.isEnabled &&
|
||
(!collegeId || item.collegeId === collegeId) &&
|
||
availableClasses.value.some((administrativeClass) =>
|
||
administrativeClass.majorId === item.id),
|
||
)
|
||
.sort((a, b) => a.code.localeCompare(b.code))
|
||
}
|
||
|
||
function classMatches(item: any, filters: Record<string, any>) {
|
||
const keyword = filters.keyword.trim().toLocaleLowerCase()
|
||
const major = majorById.value.get(item.majorId)
|
||
return (!keyword ||
|
||
item.code.toLocaleLowerCase().includes(keyword) ||
|
||
item.name.toLocaleLowerCase().includes(keyword) ||
|
||
item.majorName.toLocaleLowerCase().includes(keyword)) &&
|
||
(!filters.collegeId || major?.collegeId === filters.collegeId) &&
|
||
(!filters.majorId || item.majorId === filters.majorId) &&
|
||
(!filters.grade || item.grade === filters.grade)
|
||
}
|
||
|
||
function resetOptionFilters(filters: Record<string, any>) {
|
||
Object.keys(filters).forEach((key) => {
|
||
filters[key] = key === 'keyword' ? '' : undefined
|
||
})
|
||
}
|
||
|
||
function onClassCollegeChanged(filters: Record<string, any>) {
|
||
if (filters.majorId &&
|
||
majorById.value.get(filters.majorId)?.collegeId !== filters.collegeId) {
|
||
filters.majorId = undefined
|
||
}
|
||
}
|
||
|
||
function selectFilteredClasses(target: Record<string, any>, filtered: any[]) {
|
||
const selected = new Set<string>(target.classIds ?? [])
|
||
filtered.forEach((item) => selected.add(item.id))
|
||
target.classIds = [...selected]
|
||
}
|
||
|
||
function clearFilteredClasses(target: Record<string, any>, filtered: any[]) {
|
||
const filteredIds = new Set(filtered.map((item) => item.id))
|
||
target.classIds = (target.classIds ?? []).filter((id: string) => !filteredIds.has(id))
|
||
}
|
||
|
||
function rowSelectable(row: any) {
|
||
return canManageRow(row) && ['Draft', 'Published', 'Closed'].includes(row.status)
|
||
}
|
||
|
||
function onTaskSelectionChanged(selection: any[]) {
|
||
selectedRows.value = selection
|
||
}
|
||
|
||
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
|
||
selectedRows.value = []
|
||
} 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,
|
||
schedulingMode: 'Standard',
|
||
teacherIds: [],
|
||
primaryTeacherId: undefined,
|
||
classIds: [],
|
||
notes: '',
|
||
}, detail ?? {})
|
||
}
|
||
|
||
function openCreate() {
|
||
editingId.value = ''
|
||
resetForm()
|
||
resetOptionFilters(manualCourseFilters)
|
||
resetOptionFilters(manualClassFilters)
|
||
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)
|
||
resetOptionFilters(manualCourseFilters)
|
||
resetOptionFilters(manualClassFilters)
|
||
await loadManualEligibleTeachers()
|
||
dialogVisible.value = true
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function loadManualEligibleTeachers() {
|
||
manualEligibleTeachers.value = []
|
||
if (!form.academicTermId || !form.courseId) return
|
||
try {
|
||
manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
||
params: {
|
||
academicTermId: form.academicTermId,
|
||
courseId: form.courseId,
|
||
},
|
||
})).data
|
||
} 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
|
||
}
|
||
if (!hoursMatch.value) {
|
||
ElMessage.warning(
|
||
`课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`,
|
||
)
|
||
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) {
|
||
const isArchived = row.status === 'Closed'
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
isArchived
|
||
? `将永久删除已归档教学任务“${row.name}”。如任务已被排课、选课、成绩或考试引用,系统会拒绝删除。确定继续吗?`
|
||
: `确定删除教学任务草稿“${row.name}”吗?`,
|
||
isArchived ? '删除已归档任务' : '删除草稿',
|
||
{
|
||
type: 'error',
|
||
confirmButtonText: '确认删除',
|
||
cancelButtonText: '取消',
|
||
},
|
||
)
|
||
await http.delete(`/teaching-tasks/${row.id}`)
|
||
ElMessage.success(isArchived ? '已归档教学任务已删除' : '教学任务草稿已删除')
|
||
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`)
|
||
ElMessage.success('教学任务已归档')
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function unarchive(row: any) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'撤销归档后,教学任务将恢复为已发布状态,并重新进入相关业务流程。确定继续吗?',
|
||
'撤销教学任务归档',
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: '撤销归档',
|
||
cancelButtonText: '取消',
|
||
},
|
||
)
|
||
await http.post(`/teaching-tasks/${row.id}/unarchive`)
|
||
ElMessage.success('教学任务已撤销归档')
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function batchAction(
|
||
operation: 'Publish' | 'Delete' | 'Archive' | 'Unarchive',
|
||
targets: any[],
|
||
) {
|
||
if (!targets.length) return
|
||
const deletingArchived = operation === 'Delete' &&
|
||
targets.every((item) => item.status === 'Closed')
|
||
const descriptions = {
|
||
Publish: {
|
||
title: '批量发布教学任务',
|
||
message: `将发布选中的 ${targets.length} 个草稿。发布前会逐项校验教师资格、行政班和容量,确定继续吗?`,
|
||
confirmButtonText: '批量发布',
|
||
success: '教学任务已批量发布',
|
||
type: 'warning',
|
||
},
|
||
Delete: {
|
||
title: deletingArchived ? '批量删除已归档任务' : '批量删除教学任务草稿',
|
||
message: deletingArchived
|
||
? `将永久删除选中的 ${targets.length} 个已归档任务。已被后续业务引用的任务会拒绝删除,此操作不可撤销。确定继续吗?`
|
||
: `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`,
|
||
confirmButtonText: '批量删除',
|
||
success: deletingArchived ? '已归档教学任务已批量删除' : '教学任务草稿已批量删除',
|
||
type: 'error',
|
||
},
|
||
Archive: {
|
||
title: '批量归档教学任务',
|
||
message: `将选中的 ${targets.length} 个已发布任务结课并归档,后续将停止选课和排课调整。确定继续吗?`,
|
||
confirmButtonText: '批量归档',
|
||
success: '教学任务已批量归档',
|
||
type: 'warning',
|
||
},
|
||
Unarchive: {
|
||
title: '批量撤销教学任务归档',
|
||
message: `将选中的 ${targets.length} 个已归档任务恢复为已发布状态,并重新进入相关业务流程。确定继续吗?`,
|
||
confirmButtonText: '批量撤销归档',
|
||
success: '教学任务已批量撤销归档',
|
||
type: 'warning',
|
||
},
|
||
} as const
|
||
const description = descriptions[operation]
|
||
try {
|
||
await ElMessageBox.confirm(description.message, description.title, {
|
||
type: description.type,
|
||
confirmButtonText: description.confirmButtonText,
|
||
cancelButtonText: '取消',
|
||
})
|
||
const { data } = await http.post('/teaching-tasks/batch', {
|
||
operation,
|
||
ids: targets.map((item) => item.id),
|
||
})
|
||
ElMessage.success(`${description.success},共 ${data.affectedCount} 个`)
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
function openGeneration() {
|
||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||
Object.assign(generationForm, {
|
||
academicTermId: query.academicTermId ?? currentTerm?.id,
|
||
courseId: undefined,
|
||
classesPerTask: 3,
|
||
startWeek: 1,
|
||
endWeek: 16,
|
||
weeklyHours: 2,
|
||
classIds: [],
|
||
})
|
||
eligibleTeachers.value = []
|
||
resetOptionFilters(generationCourseFilters)
|
||
resetOptionFilters(generationClassFilters)
|
||
generationDialog.value = true
|
||
}
|
||
|
||
async function loadEligibleTeachers() {
|
||
eligibleTeachers.value = []
|
||
if (!generationForm.academicTermId || !generationForm.courseId) return
|
||
try {
|
||
eligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', {
|
||
params: {
|
||
academicTermId: generationForm.academicTermId,
|
||
courseId: generationForm.courseId,
|
||
},
|
||
})).data
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
}
|
||
}
|
||
|
||
async function generatePublicTasks() {
|
||
if (!generationForm.academicTermId ||
|
||
!generationForm.courseId ||
|
||
!generationForm.classIds?.length) {
|
||
ElMessage.warning('请选择学期、公共课和需要合班的行政班。')
|
||
return
|
||
}
|
||
if (!eligibleTeachers.value.length) {
|
||
ElMessage.warning('该课程还没有审核通过的可授课教师。')
|
||
return
|
||
}
|
||
if (!generationHoursMatch.value) {
|
||
ElMessage.warning(
|
||
`课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`,
|
||
)
|
||
return
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`将 ${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`,
|
||
'批量生成公共课教学任务',
|
||
{ type: 'warning', confirmButtonText: '生成草稿', cancelButtonText: '取消' },
|
||
)
|
||
generating.value = true
|
||
const { data } = await http.post('/teaching-tasks/generate-public-course', generationForm)
|
||
generationDialog.value = false
|
||
ElMessage.success(`已生成 ${data.createdCount} 个教学任务草稿`)
|
||
await load()
|
||
} catch (error: any) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
generating.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const [termRes, courseRes, classRes, majorRes] = await Promise.all([
|
||
http.get('/base-data/terms'),
|
||
http.get('/courses/options'),
|
||
http.get('/base-data/classes'),
|
||
http.get('/base-data/majors'),
|
||
])
|
||
terms.value = termRes.data
|
||
courses.value = courseRes.data
|
||
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>
|
||
<div class="page-actions">
|
||
<el-button v-if="canGeneratePublic" @click="openGeneration">公共课合班生成</el-button>
|
||
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="task-summary">
|
||
<div><span>当前结果</span><b>{{ total }}</b><small>个教学班</small></div>
|
||
<p>{{ responsibilityText }} 发布时还会校验教师授课资格、行政班、容量和周次。</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>
|
||
|
||
<div v-if="selectedRows.length" class="batch-action-bar">
|
||
<div>
|
||
<b>已选择 {{ selectedRows.length }} 个教学任务</b>
|
||
<span>可操作:{{ selectedDraftRows.length }} 个草稿,{{ selectedPublishedRows.length }} 个已发布,{{ selectedClosedRows.length }} 个已归档</span>
|
||
</div>
|
||
<div>
|
||
<el-button
|
||
type="success"
|
||
plain
|
||
:disabled="!selectedDraftRows.length"
|
||
@click="batchAction('Publish', selectedDraftRows)"
|
||
>
|
||
发布草稿({{ selectedDraftRows.length }})
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
:disabled="!selectedDraftRows.length"
|
||
@click="batchAction('Delete', selectedDraftRows)"
|
||
>
|
||
删除草稿({{ selectedDraftRows.length }})
|
||
</el-button>
|
||
<el-button
|
||
type="warning"
|
||
plain
|
||
:disabled="!selectedPublishedRows.length"
|
||
@click="batchAction('Archive', selectedPublishedRows)"
|
||
>
|
||
归档已发布({{ selectedPublishedRows.length }})
|
||
</el-button>
|
||
<el-button
|
||
type="primary"
|
||
plain
|
||
:disabled="!selectedClosedRows.length"
|
||
@click="batchAction('Unarchive', selectedClosedRows)"
|
||
>
|
||
撤销归档({{ selectedClosedRows.length }})
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
:disabled="!selectedClosedRows.length"
|
||
@click="batchAction('Delete', selectedClosedRows)"
|
||
>
|
||
删除已归档({{ selectedClosedRows.length }})
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="rows"
|
||
class="data-table task-table"
|
||
@selection-change="onTaskSelectionChanged"
|
||
>
|
||
<el-table-column type="selection" width="48" :selectable="rowSelectable" />
|
||
<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>
|
||
<small>{{ ['GeneralRequired', 'GeneralElective'].includes(row.courseNature) ? '校级教务负责' : '开课学院负责' }}</small>
|
||
</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="210" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="success" @click="publish(row)">发布</el-button>
|
||
<el-button v-if="canManageRow(row) && row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
|
||
<el-button v-if="canManageRow(row) && row.status === 'Published'" link type="warning" @click="closeTask(row)">归档</el-button>
|
||
<el-button v-if="canManageRow(row) && row.status === 'Closed'" link type="primary" @click="unarchive(row)">撤销归档</el-button>
|
||
<el-button v-if="canManageRow(row) && row.status === 'Closed'" link type="danger" @click="remove(row)">删除</el-button>
|
||
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
|
||
<span v-if="!canManageRow(row) && 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" @change="loadManualEligibleTeachers"><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 @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
|
||
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}(${item.totalHours} 学时)`" :value="item.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="option-filter-panel">
|
||
<span class="option-filter-title">筛选课程</span>
|
||
<div class="option-filter-grid course">
|
||
<el-input v-model="manualCourseFilters.keyword" clearable placeholder="课程代码或名称" />
|
||
<el-select v-model="manualCourseFilters.collegeId" clearable placeholder="全部开课单位">
|
||
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
<el-select v-model="manualCourseFilters.nature" clearable placeholder="全部课程性质">
|
||
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
|
||
</el-select>
|
||
</div>
|
||
<small>当前显示 {{ filteredManageableCourses.length }} 门可维护课程</small>
|
||
</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>
|
||
<div
|
||
v-if="selectedCourse"
|
||
:class="['hours-validation', { invalid: !hoursMatch }]"
|
||
>
|
||
<b>{{ hoursMatch ? '学时匹配' : '学时不匹配' }}</b>
|
||
<span>
|
||
第 {{ form.startWeek }}—{{ form.endWeek }} 周 × 每周 {{ form.weeklyHours }} 学时
|
||
= {{ plannedHours }} 学时;课程库总学时 {{ selectedCourse.totalHours }}。
|
||
</span>
|
||
</div>
|
||
<el-form-item label="授课方式">
|
||
<el-radio-group v-model="form.schedulingMode">
|
||
<el-radio-button value="Standard">正常排课</el-radio-button>
|
||
<el-radio-button value="Flexible">非排时课程</el-radio-button>
|
||
</el-radio-group>
|
||
<small class="field-hint">
|
||
非排时课程不进入自动排课,不占用星期、节次和场地,并在课表中单独列出。
|
||
</small>
|
||
</el-form-item>
|
||
<el-form-item label="授课教师">
|
||
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
|
||
<el-option v-for="item in assignableTeachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
|
||
</el-select>
|
||
<small v-if="selectedCourse" class="field-hint">仅显示本学期已审核通过或由学院直接分配的授课资格教师。</small>
|
||
</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="关联行政班">
|
||
<div class="class-picker">
|
||
<div class="option-filter-grid classes">
|
||
<el-input v-model="manualClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
|
||
<el-select v-model="manualClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(manualClassFilters)">
|
||
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
<el-select v-model="manualClassFilters.majorId" clearable placeholder="全部专业">
|
||
<el-option v-for="item in manualClassMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
<el-select v-model="manualClassFilters.grade" clearable placeholder="全部年级">
|
||
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||
</el-select>
|
||
</div>
|
||
<div class="class-picker-actions">
|
||
<span>筛选到 {{ filteredManualClasses.length }} 个班,已选择 {{ form.classIds?.length ?? 0 }} 个</span>
|
||
<div>
|
||
<el-button link type="primary" @click="selectFilteredClasses(form, filteredManualClasses)">选择筛选结果</el-button>
|
||
<el-button link @click="clearFilteredClasses(form, filteredManualClasses)">取消筛选结果</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-select v-model="form.classIds" multiple filterable collapse-tags collapse-tags-tooltip>
|
||
<el-option v-for="item in filteredManualClasses" :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>
|
||
|
||
<el-dialog v-model="generationDialog" title="公共课合班生成教学任务" width="760px">
|
||
<div class="generation-notice">
|
||
行政班按编码顺序分组;教师只从学院审核通过的名单中选择,并优先分配给本学期教学任务较少的教师。
|
||
</div>
|
||
<el-form label-position="top">
|
||
<div class="form-grid">
|
||
<el-form-item label="开课学期" required>
|
||
<el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers">
|
||
<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="generationForm.courseId" filterable @change="loadEligibleTeachers">
|
||
<el-option v-for="item in filteredPublicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="option-filter-panel">
|
||
<span class="option-filter-title">筛选公共课程</span>
|
||
<div class="option-filter-grid course compact">
|
||
<el-input v-model="generationCourseFilters.keyword" clearable placeholder="课程代码或名称" />
|
||
<el-select v-model="generationCourseFilters.collegeId" clearable placeholder="全部开课单位">
|
||
<el-option v-for="item in courseColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
</div>
|
||
<small>当前显示 {{ filteredPublicCourses.length }} 门公共课程</small>
|
||
</div>
|
||
<el-alert
|
||
:title="eligibleTeachers.length ? `已有 ${eligibleTeachers.length} 名教师审核通过` : '请选择课程查看可分配教师'"
|
||
:type="eligibleTeachers.length ? 'success' : 'warning'"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #default>
|
||
<span v-if="eligibleTeachers.length">{{ eligibleTeachers.map((item) => item.name).join('、') }}</span>
|
||
</template>
|
||
</el-alert>
|
||
<el-form-item label="参与合班的行政班" required style="margin-top: 16px">
|
||
<div class="class-picker">
|
||
<div class="option-filter-grid classes">
|
||
<el-input v-model="generationClassFilters.keyword" clearable placeholder="班级代码、名称或专业" />
|
||
<el-select v-model="generationClassFilters.collegeId" clearable placeholder="全部学院" @change="onClassCollegeChanged(generationClassFilters)">
|
||
<el-option v-for="item in classColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
<el-select v-model="generationClassFilters.majorId" clearable placeholder="全部专业">
|
||
<el-option v-for="item in generationClassMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||
</el-select>
|
||
<el-select v-model="generationClassFilters.grade" clearable placeholder="全部年级">
|
||
<el-option v-for="grade in classGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||
</el-select>
|
||
</div>
|
||
<div class="class-picker-actions">
|
||
<span>筛选到 {{ filteredGenerationClasses.length }} 个班,已选择 {{ generationForm.classIds?.length ?? 0 }} 个</span>
|
||
<div>
|
||
<el-button link type="primary" @click="selectFilteredClasses(generationForm, filteredGenerationClasses)">选择筛选结果</el-button>
|
||
<el-button link @click="clearFilteredClasses(generationForm, filteredGenerationClasses)">取消筛选结果</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-select v-model="generationForm.classIds" multiple filterable collapse-tags>
|
||
<el-option v-for="item in filteredGenerationClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="form-grid three">
|
||
<el-form-item label="每个教学班合并行政班数">
|
||
<el-input-number v-model="generationForm.classesPerTask" :min="1" :max="10" />
|
||
</el-form-item>
|
||
<el-form-item label="开始周">
|
||
<el-input-number v-model="generationForm.startWeek" :min="1" :max="30" />
|
||
</el-form-item>
|
||
<el-form-item label="结束周">
|
||
<el-input-number v-model="generationForm.endWeek" :min="1" :max="30" />
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="周学时">
|
||
<el-input-number v-model="generationForm.weeklyHours" :min="1" :max="40" />
|
||
</el-form-item>
|
||
<div
|
||
v-if="selectedGenerationCourse"
|
||
:class="['hours-validation', { invalid: !generationHoursMatch }]"
|
||
>
|
||
<b>{{ generationHoursMatch ? '学时匹配' : '学时不匹配' }}</b>
|
||
<span>
|
||
当前合计 {{ generationPlannedHours }} 学时;课程库总学时
|
||
{{ selectedGenerationCourse.totalHours }}。
|
||
</span>
|
||
</div>
|
||
<div class="generation-preview">
|
||
<span>预计生成</span>
|
||
<b>{{ generationGroupCount }}</b>
|
||
<small>个教学任务草稿</small>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="generationDialog = false">取消</el-button>
|
||
<el-button type="primary" :loading="generating" @click="generatePublicTasks">生成教学任务草稿</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|