教学任务页面优化

This commit is contained in:
2026-07-24 21:10:59 +08:00 Unverified
parent 6619179acc
commit 49b550560a
3 changed files with 486 additions and 29 deletions
@@ -240,26 +240,8 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound();
if (!CanManage(task.Course!)) return Forbid();
if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("只有草稿教学任务可以发布。");
if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1)
return ConflictProblem("发布前必须指定且只能指定一名主讲教师。");
if (task.Classes.Count == 0)
return ConflictProblem("发布前至少需要关联一个行政班。");
var teacherIds = task.Teachers.Select(x => x.TeacherId).ToArray();
if (await db.TeacherCourseApplications.CountAsync(
x =>
x.AcademicTermId == task.AcademicTermId &&
x.CourseId == task.CourseId &&
teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved,
cancellationToken) != teacherIds.Length)
return ConflictProblem("授课教师尚未完成该课程申报,或学院审核尚未通过。");
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
if (studentCount > task.Capacity)
return ConflictProblem($"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
var validation = await ValidatePublishingTasksAsync([task], cancellationToken);
if (validation is not null) return ConflictProblem(validation);
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = DateTime.UtcNow;
@@ -280,6 +262,69 @@ public sealed class TeachingTasksController(
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("batch")]
public async Task<ActionResult> Batch(
TeachingTaskBatchRequest request,
CancellationToken cancellationToken)
{
var ids = request.Ids.Distinct().ToArray();
if (ids.Length == 0) return ValidationProblem("请至少选择一个教学任务。");
if (ids.Length > 100) return ValidationProblem("每次最多处理 100 个教学任务。");
var tasks = await ScopedTasks()
.Include(x => x.Course)
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.Where(x => ids.Contains(x.Id))
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != ids.Length)
return ConflictProblem("部分教学任务不存在,或已超出当前账号的数据范围。");
if (tasks.Any(x => !CanManage(x.Course!))) return Forbid();
switch (request.Operation)
{
case TeachingTaskBatchOperation.Publish:
{
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = publishedAt;
}
break;
}
case TeachingTaskBatchOperation.Delete:
if (tasks.Any(x => x.Status != TeachingTaskStatus.Draft))
return ConflictProblem(
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Draft).Name}”不是草稿,整批未删除。");
db.TeachingTasks.RemoveRange(tasks);
break;
case TeachingTaskBatchOperation.Archive:
if (tasks.Any(x => x.Status != TeachingTaskStatus.Published))
return ConflictProblem(
$"教学任务“{tasks.First(x => x.Status != TeachingTaskStatus.Published).Name}”不是已发布状态,整批未归档。");
foreach (var task in tasks) task.Status = TeachingTaskStatus.Closed;
break;
default:
return ValidationProblem("不支持的批量操作。");
}
try
{
await db.SaveChangesAsync(cancellationToken);
return Ok(new { AffectedCount = tasks.Count });
}
catch (DbUpdateException)
{
return ConflictProblem("批量操作未执行:教学任务仍被其他业务引用,或关联数据已失效。");
}
}
[HttpPost("generate-public-course")]
[Authorize(Roles = SchoolManagementRoles)]
public async Task<ActionResult> GeneratePublicCourseTasks(
@@ -511,6 +556,82 @@ public sealed class TeachingTasksController(
}
}
private async Task<string?> ValidatePublishingTasksAsync(
IReadOnlyCollection<TeachingTask> tasks,
CancellationToken cancellationToken)
{
var invalidStatus = tasks.FirstOrDefault(x => x.Status != TeachingTaskStatus.Draft);
if (invalidStatus is not null)
return PublishingProblem(
tasks,
invalidStatus,
"只有草稿教学任务可以发布。");
var invalidPrimaryTeacher = tasks.FirstOrDefault(x =>
x.Teachers.Count == 0 || x.Teachers.Count(item => item.IsPrimary) != 1);
if (invalidPrimaryTeacher is not null)
return PublishingProblem(
tasks,
invalidPrimaryTeacher,
"发布前必须指定且只能指定一名主讲教师。");
var missingClass = tasks.FirstOrDefault(x => x.Classes.Count == 0);
if (missingClass is not null)
return PublishingProblem(
tasks,
missingClass,
"发布前至少需要关联一个行政班。");
var termIds = tasks.Select(x => x.AcademicTermId).Distinct().ToArray();
var courseIds = tasks.Select(x => x.CourseId).Distinct().ToArray();
var teacherIds = tasks
.SelectMany(x => x.Teachers)
.Select(x => x.TeacherId)
.Distinct()
.ToArray();
var approvedApplications = await db.TeacherCourseApplications
.AsNoTracking()
.Where(x =>
termIds.Contains(x.AcademicTermId) &&
courseIds.Contains(x.CourseId) &&
teacherIds.Contains(x.TeacherId) &&
x.Status == TeacherCourseApplicationStatus.Approved)
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
.ToListAsync(cancellationToken);
var approvedAssignments = approvedApplications
.Select(x => (x.AcademicTermId, x.CourseId, x.TeacherId))
.ToHashSet();
var unapprovedTeacher = tasks.FirstOrDefault(task =>
task.Teachers.Any(teacher =>
!approvedAssignments.Contains(
(task.AcademicTermId, task.CourseId, teacher.TeacherId))));
if (unapprovedTeacher is not null)
return PublishingProblem(
tasks,
unapprovedTeacher,
"授课教师尚未完成该课程申报,或学院审核尚未通过。");
foreach (var task in tasks)
{
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
if (studentCount > task.Capacity)
return PublishingProblem(
tasks,
task,
$"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
}
return null;
}
private static string PublishingProblem(
IReadOnlyCollection<TeachingTask> tasks,
TeachingTask task,
string detail) =>
tasks.Count == 1 ? detail : $"教学任务“{task.Name}”:{detail}整批未发布。";
private Guid? ScopedCollegeId()
=> currentUserDataScope.Current.RestrictedCollegeId;
@@ -588,3 +709,14 @@ public sealed record PublicCourseTaskGenerationRequest(
[Range(1, 30)] int EndWeek,
[Range(1, 40)] int WeeklyHours,
IReadOnlyCollection<Guid> ClassIds);
public sealed record TeachingTaskBatchRequest(
TeachingTaskBatchOperation Operation,
[MinLength(1), MaxLength(100)] IReadOnlyCollection<Guid> Ids);
public enum TeachingTaskBatchOperation
{
Publish = 1,
Delete = 2,
Archive = 3
}
+30
View File
@@ -300,6 +300,12 @@ button { cursor: pointer; }
.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; }
.batch-action-bar { min-height: 64px; padding: 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid #b9dcd7; background: #f1f9f7; }
.batch-action-bar > div:first-child { display: grid; gap: 4px; }
.batch-action-bar b { color: #244a49; font-size: 12px; }
.batch-action-bar span { color: #66807f; font-size: 10px; }
.batch-action-bar > div:last-child { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.batch-action-bar .el-button + .el-button { margin-left: 0; }
.muted-action { color: #9aa1ae; font-size: 11px; }
.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; }
.schedule-toolbar > .el-select { width: 245px; align-self: center; }
@@ -362,6 +368,18 @@ button { cursor: pointer; }
.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
.generation-preview span, .generation-preview small { font-size: 10px; }
.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
.option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; }
.option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; }
.option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; }
.option-filter-grid { min-width: 0; display: grid; gap: 8px; }
.option-filter-grid.course { grid-template-columns: minmax(150px, 1fr) minmax(145px, .8fr) minmax(130px, .65fr); }
.option-filter-grid.course.compact { grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr); }
.option-filter-grid.classes { grid-template-columns: minmax(190px, 1.25fr) repeat(3, minmax(115px, .75fr)); }
.class-picker { width: 100%; margin-bottom: 8px; padding: 11px 12px 8px; border: 1px solid #dce4eb; background: #f8fafb; }
.class-picker-actions { min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.class-picker-actions > span { color: var(--muted); font-size: 9px; }
.class-picker-actions > div { display: flex; align-items: center; }
.class-picker-actions .el-button + .el-button { margin-left: 4px; }
.selection-page { min-width: 0; }
.selection-round-strip {
@@ -982,6 +1000,13 @@ button { cursor: pointer; }
.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; }
.batch-action-bar { align-items: flex-start; flex-direction: column; }
.batch-action-bar > div:last-child { justify-content: flex-start; }
.option-filter-panel { grid-template-columns: 1fr; }
.option-filter-grid.course,
.option-filter-grid.course.compact,
.option-filter-grid.classes { grid-template-columns: 1fr 1fr; }
.class-picker-actions { align-items: flex-start; flex-direction: column; gap: 2px; }
.schedule-toolbar { display: block; }
.schedule-toolbar > .el-select { width: 100%; }
.schedule-version-strip { margin-top: 10px; }
@@ -1025,6 +1050,11 @@ button { cursor: pointer; }
}
@media (max-width: 600px) {
.option-filter-grid.course,
.option-filter-grid.course.compact,
.option-filter-grid.classes { grid-template-columns: 1fr; }
.batch-action-bar > div:last-child { width: 100%; }
.batch-action-bar > div:last-child .el-button { flex: 1 1 auto; }
.selection-round-strip > button { flex-basis: 240px; }
.selection-window { display: block; }
.window-seal { min-height: 54px; padding: 10px; display: flex; border-right: none; border-bottom: 1px solid rgba(255,255,255,.13); }
+304 -9
View File
@@ -16,6 +16,7 @@ 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 teachers = ref<any[]>([])
@@ -30,6 +31,27 @@ const query = reactive({
})
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> = {
@@ -37,12 +59,20 @@ const statusLabels: Record<string, string> = {
Published: '已发布',
Closed: '已结课',
}
const courseNatureLabels: Record<string, string> = {
GeneralRequired: '公共必修',
GeneralElective: '公共选修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
Practice: '实践教学',
}
const availableClasses = computed(() => {
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return classes.value
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 classes.value.filter((item) => ids.has(item.majorId))
return enabled.filter((item) => ids.has(item.majorId))
})
const selectedCourse = computed(() =>
courses.value.find((item) => item.id === form.courseId),
@@ -72,6 +102,46 @@ const manageableCourses = computed(() => {
['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 responsibilityText = computed(() => {
if (isSuperAdmin.value) return '超级管理员可处理全部教学任务,并保留跨层级纠错权限。'
if (isAcademicAdmin.value && isCollegeAdmin.value) {
@@ -91,6 +161,70 @@ function canManageRow(row: any) {
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'].includes(row.status)
}
function onTaskSelectionChanged(selection: any[]) {
selectedRows.value = selection
}
async function load() {
loading.value = true
try {
@@ -102,6 +236,7 @@ async function load() {
})
rows.value = data.items
total.value = data.total
selectedRows.value = []
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -141,6 +276,8 @@ function resetForm(detail?: any) {
function openCreate() {
editingId.value = ''
resetForm()
resetOptionFilters(manualCourseFilters)
resetOptionFilters(manualClassFilters)
dialogVisible.value = true
}
@@ -149,6 +286,8 @@ async function openEdit(row: any) {
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) {
@@ -238,6 +377,52 @@ async function closeTask(row: any) {
}
}
async function batchAction(
operation: 'Publish' | 'Delete' | 'Archive',
targets: any[],
) {
if (!targets.length) return
const descriptions = {
Publish: {
title: '批量发布教学任务',
message: `将发布选中的 ${targets.length} 个草稿。发布前会逐项校验教师资格、行政班和容量,确定继续吗?`,
confirmButtonText: '批量发布',
success: '教学任务已批量发布',
type: 'warning',
},
Delete: {
title: '批量删除教学任务',
message: `将永久删除选中的 ${targets.length} 个草稿,此操作不可撤销。确定继续吗?`,
confirmButtonText: '批量删除',
success: '教学任务草稿已批量删除',
type: 'error',
},
Archive: {
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, {
@@ -250,6 +435,8 @@ function openGeneration() {
classIds: [],
})
eligibleTeachers.value = []
resetOptionFilters(generationCourseFilters)
resetOptionFilters(generationClassFilters)
generationDialog.value = true
}
@@ -347,7 +534,46 @@ onMounted(async () => {
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table task-table">
<div v-if="selectedRows.length" class="batch-action-bar">
<div>
<b>已选择 {{ selectedRows.length }} 个教学任务</b>
<span>可操作{{ selectedDraftRows.length }} 个草稿{{ selectedPublishedRows.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>
</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 }">
@@ -371,7 +597,7 @@ onMounted(async () => {
<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>
<span v-if="!canManageRow(row) && row.status !== 'Closed'" class="muted-action">只读</span>
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
<span v-if="row.status === 'Closed'" class="muted-action">结课归档</span>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
@@ -390,7 +616,24 @@ onMounted(async () => {
</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 manageableCourses" :key="item.id" :label="`${item.code} · ${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}`" :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>
@@ -410,8 +653,29 @@ onMounted(async () => {
</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" />
<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>
@@ -432,10 +696,20 @@ onMounted(async () => {
</el-form-item>
<el-form-item label="公共课程" required>
<el-select v-model="generationForm.courseId" filterable @change="loadEligibleTeachers">
<el-option v-for="item in publicCourses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<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'"
@@ -447,8 +721,29 @@ onMounted(async () => {
</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 availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
<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">