Files
Academic-Affairs-System/web/src/views/TeachingTasksView.vue
T
biss a081273ac6 deepseek
2 处改动:

  1. ValidateRequestAsync — 当 classIds 为空时跳过行政班验证(选修课可不选班)
  2. ValidatePublishingTasksAsync — 移除了"发布前至少需要关联一个行政班"的强制校验,允许无行政班的教学任务直接发布
2026-07-25 13:10:19 +08:00

902 lines
39 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 { 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>