From 49b550560a2532e4d28d81705af9d0bc3c5803d3 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 21:10:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=99=E5=AD=A6=E4=BB=BB=E5=8A=A1=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/TeachingTasksController.cs | 172 ++++++++-- web/src/style.css | 30 ++ web/src/views/TeachingTasksView.vue | 313 +++++++++++++++++- 3 files changed, 486 insertions(+), 29 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 3d05431..ecfb301 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -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 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 GeneratePublicCourseTasks( @@ -511,6 +556,82 @@ public sealed class TeachingTasksController( } } + private async Task ValidatePublishingTasksAsync( + IReadOnlyCollection 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 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 ClassIds); + +public sealed record TeachingTaskBatchRequest( + TeachingTaskBatchOperation Operation, + [MinLength(1), MaxLength(100)] IReadOnlyCollection Ids); + +public enum TeachingTaskBatchOperation +{ + Publish = 1, + Delete = 2, + Archive = 3 +} diff --git a/web/src/style.css b/web/src/style.css index 2e33429..888d693 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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); } diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index 2ede0e0..b4e1aa8 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -16,6 +16,7 @@ const dialogVisible = ref(false) const generationDialog = ref(false) const generating = ref(false) const editingId = ref('') +const selectedRows = ref([]) const terms = ref([]) const courses = ref([]) const teachers = ref([]) @@ -30,6 +31,27 @@ const query = reactive({ }) const form = reactive>({}) const generationForm = reactive>({}) +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([]) const manualEligibleTeachers = ref([]) const statusLabels: Record = { @@ -37,12 +59,20 @@ const statusLabels: Record = { Published: '已发布', Closed: '已结课', } +const courseNatureLabels: Record = { + 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() + 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() + 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) { + 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) { + 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) { + Object.keys(filters).forEach((key) => { + filters[key] = key === 'keyword' ? '' : undefined + }) +} + +function onClassCollegeChanged(filters: Record) { + if (filters.majorId && + majorById.value.get(filters.majorId)?.collegeId !== filters.collegeId) { + filters.majorId = undefined + } +} + +function selectFilteredClasses(target: Record, filtered: any[]) { + const selected = new Set(target.classIds ?? []) + filtered.forEach((item) => selected.add(item.id)) + target.classIds = [...selected] +} + +function clearFilteredClasses(target: Record, 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 () => { 重置 - +
+
+ 已选择 {{ selectedRows.length }} 个教学任务 + 可操作:{{ selectedDraftRows.length }} 个草稿,{{ selectedPublishedRows.length }} 个已发布 +
+
+ + 发布草稿({{ selectedDraftRows.length }}) + + + 删除草稿({{ selectedDraftRows.length }}) + + + 归档已发布({{ selectedPublishedRows.length }}) + +
+
+ + + @@ -390,7 +616,24 @@ onMounted(async () => {
- + + + + + +
+
+ 筛选课程 +
+ + + + + + + +
+ 当前显示 {{ filteredManageableCourses.length }} 门可维护课程
@@ -410,8 +653,29 @@ onMounted(async () => { - - +
+
+ + + + + + + + + + +
+
+ 筛选到 {{ filteredManualClasses.length }} 个班,已选择 {{ form.classIds?.length ?? 0 }} 个 +
+ 选择筛选结果 + 取消筛选结果 +
+
+
+ +
@@ -432,10 +696,20 @@ onMounted(async () => { - +
+
+ 筛选公共课程 +
+ + + + +
+ 当前显示 {{ filteredPublicCourses.length }} 门公共课程 +
{ +
+
+ + + + + + + + + + +
+
+ 筛选到 {{ filteredGenerationClasses.length }} 个班,已选择 {{ generationForm.classIds?.length ?? 0 }} 个 +
+ 选择筛选结果 + 取消筛选结果 +
+
+
- +