点名、成绩册统一读取“行政班在籍学生 ∪ 已选课学生”,不再错误显示 0 人。

教师选课名单改为展示全部本人教学班及完整学生名单,支持 Excel 导出。
调停课改用教师专用教学班接口,并在服务端限制只能操作本人教学班。
This commit is contained in:
2026-07-27 08:05:42 +08:00 Unverified
parent bc6ffa2ada
commit 22321abe23
9 changed files with 491 additions and 69 deletions
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -48,9 +49,15 @@ public sealed class AttendanceController(
TermName = x.AcademicTerm!.Name, TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course!.Name, CourseName = x.Course!.Name,
StudentCount = db.CourseEnrollments.Count(enrollment => StudentCount = db.Students.Count(student =>
enrollment.Status == CourseEnrollmentStatus.Enrolled && (student.Status == StudentStatus.Active &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id), x.Classes.Any(assignment =>
assignment.AdministrativeClassId ==
student.AdministrativeClassId)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)),
SheetCount = db.AttendanceSheets.Count(sheet => SheetCount = db.AttendanceSheets.Count(sheet =>
sheet.TeachingTaskId == x.Id) sheet.TeachingTaskId == x.Id)
}) })
@@ -104,15 +111,13 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
var studentIds = await db.CourseEnrollments.AsNoTracking() var studentIds = await TeachingTaskRosterQuery
.Where(x => .ForTask(db, request.TeachingTaskId)
x.Status == CourseEnrollmentStatus.Enrolled && .AsNoTracking()
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId) .Select(x => x.Id)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (studentIds.Count == 0) if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。"); return ConflictProblem("该教学班没有有效学生。");
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual; var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
if (!Enum.IsDefined(checkInMethod)) if (!Enum.IsDefined(checkInMethod))
@@ -1032,16 +1037,14 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (course is null) return null; if (course is null) return null;
var enrolledStudents = await db.CourseEnrollments.AsNoTracking() var enrolledStudents = await TeachingTaskRosterQuery
.Where(x => .ForTask(db, teachingTaskId)
x.Status == CourseEnrollmentStatus.Enrolled && .AsNoTracking()
x.CourseSelectionOffering!.TeachingTaskId == teachingTaskId)
.Select(x => new AttendanceStatisticsStudentIdentity( .Select(x => new AttendanceStatisticsStudentIdentity(
x.StudentId, x.Id,
x.Student!.StudentNumber, x.StudentNumber,
x.Student.Name, x.Name,
x.Student.AdministrativeClass!.Name)) x.AdministrativeClass!.Name))
.Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var submittedSheets = await db.AttendanceSheets.AsNoTracking() var submittedSheets = await db.AttendanceSheets.AsNoTracking()
@@ -27,6 +27,37 @@ public sealed class CourseAdjustmentsController(
SystemRoles.AcademicAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin; SystemRoles.CollegeAdmin;
[HttpGet("task-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetTaskOptions(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
ClassNames = x.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.ToListAsync(cancellationToken));
}
// ═══════════════ My adjustments ═══════════════ // ═══════════════ My adjustments ═══════════════
[HttpGet("mine")] [HttpGet("mine")]
@@ -399,7 +430,7 @@ public sealed class CourseAdjustmentsController(
CourseAdjustmentRequest request, CourseAdjustmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var task = await db.TeachingTasks.AsNoTracking() var task = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.Course) .Include(x => x.Course)
.FirstOrDefaultAsync(x => .FirstOrDefaultAsync(x =>
x.Id == request.TeachingTaskId && x.Id == request.TeachingTaskId &&
@@ -438,6 +469,20 @@ public sealed class CourseAdjustmentsController(
return null; return null;
} }
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x =>
x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private static System.Linq.Expressions.Expression< private static System.Linq.Expressions.Expression<
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
{ {
@@ -6,6 +6,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection; using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -1561,6 +1562,99 @@ public sealed class CourseSelectionsController(
return Ok(offerings); return Ok(offerings);
} }
[HttpGet("my-teaching-tasks")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyTeachingTasks(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var source = db.TeachingTasks.AsNoTracking()
.Where(x =>
(x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed) &&
x.Teachers.Any(item => item.Teacher!.UserId == userId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
return Ok(await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
TaskName = x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
x.Capacity,
StudentCount = db.Students.Count(student =>
(student.Status == StudentStatus.Active &&
x.Classes.Any(assignment =>
assignment.AdministrativeClassId ==
student.AdministrativeClassId)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id))
})
.ToListAsync(cancellationToken));
}
[HttpGet("teaching-tasks/{id:guid}/roster")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetTeachingTaskRoster(
Guid id,
CancellationToken cancellationToken)
{
var task = await GetAssignedTeachingTaskAsync(id, cancellationToken);
if (task is null) return NotFound();
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
return Ok(new
{
task.Id,
task.TaskNumber,
task.TaskName,
task.CourseCode,
task.CourseName,
task.Capacity,
StudentCount = students.Count,
Students = students
});
}
[HttpGet("teaching-tasks/{id:guid}/roster/export.xlsx")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> ExportTeachingTaskRoster(
Guid id,
CancellationToken cancellationToken)
{
var task = await GetAssignedTeachingTaskAsync(id, cancellationToken);
if (task is null) return NotFound();
var students = await LoadTeachingTaskRosterAsync(id, cancellationToken);
var bytes = ExcelWorkbookHelper.Create(
"教学班名单",
["学号", "姓名", "班级", "专业", "进入方式", "选课时间"],
students.Select(student => new List<object?>
{
student.StudentNumber,
student.Name,
student.ClassName,
student.MajorName,
student.EnrolledAt.HasValue ? "选课" : "行政班关联",
student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-"
}).ToList<IReadOnlyList<object?>>());
return File(
bytes,
ExcelWorkbookHelper.ContentType,
$"教学班名单-{task.TaskNumber}.xlsx");
}
[HttpGet("offerings/{id:guid}/roster/export.xlsx")] [HttpGet("offerings/{id:guid}/roster/export.xlsx")]
[Authorize(Roles = RosterReaders)] [Authorize(Roles = RosterReaders)]
public async Task<ActionResult> ExportRoster( public async Task<ActionResult> ExportRoster(
@@ -1627,6 +1721,50 @@ public sealed class CourseSelectionsController(
x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId);
} }
private Task<TeacherTeachingTaskInfo?> GetAssignedTeachingTaskAsync(
Guid id,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
return db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Id == id &&
(x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed) &&
x.Teachers.Any(item => item.Teacher!.UserId == userId))
.Select(x => new TeacherTeachingTaskInfo(
x.Id,
x.TaskNumber,
x.Name,
x.Course!.Code,
x.Course.Name,
x.Capacity))
.FirstOrDefaultAsync(cancellationToken);
}
private Task<List<TeachingTaskRosterStudent>> LoadTeachingTaskRosterAsync(
Guid teachingTaskId,
CancellationToken cancellationToken) =>
TeachingTaskRosterQuery.ForTask(db, teachingTaskId)
.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.Select(x => new TeachingTaskRosterStudent(
x.Id,
x.StudentNumber,
x.Name,
x.AdministrativeClass!.Name,
x.AdministrativeClass.Major!.Name,
db.CourseEnrollments
.Where(enrollment =>
enrollment.StudentId == x.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
teachingTaskId)
.OrderByDescending(enrollment => enrollment.EnrolledAt)
.Select(enrollment => (DateTime?)enrollment.EnrolledAt)
.FirstOrDefault()))
.ToListAsync(cancellationToken);
private async Task<TeachingTask?> FindAccessibleTaskAsync( private async Task<TeachingTask?> FindAccessibleTaskAsync(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -2001,6 +2139,22 @@ public sealed class CourseSelectionsController(
values?.Distinct().OrderBy(x => x).ToArray() ?? []; values?.Distinct().OrderBy(x => x).ToArray() ?? [];
private sealed record EnrollmentEligibility(bool IsRetake, string? Error); private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
private sealed record TeacherTeachingTaskInfo(
Guid Id,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
int Capacity);
private sealed record TeachingTaskRosterStudent(
Guid StudentId,
string StudentNumber,
string Name,
string ClassName,
string MajorName,
DateTime? EnrolledAt);
} }
public sealed record CourseSelectionRoundRequest( public sealed record CourseSelectionRoundRequest(
@@ -6,6 +6,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -134,15 +135,13 @@ public sealed class GradesController(
cancellationToken)) cancellationToken))
return ConflictProblem("该教学班已经建立成绩单。"); return ConflictProblem("该教学班已经建立成绩单。");
var studentIds = await db.CourseEnrollments.AsNoTracking() var studentIds = await TeachingTaskRosterQuery
.Where(x => .ForTask(db, task.Id)
x.Status == CourseEnrollmentStatus.Enrolled && .AsNoTracking()
x.CourseSelectionOffering!.TeachingTaskId == task.Id) .Select(x => x.Id)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (studentIds.Count == 0) if (studentIds.Count == 0)
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。"); return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
var sheet = new GradeSheet var sheet = new GradeSheet
{ {
@@ -0,0 +1,18 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
namespace Jiaowu.Api.Infrastructure.Teaching;
public static class TeachingTaskRosterQuery
{
public static IQueryable<Student> ForTask(AppDbContext db, Guid teachingTaskId) =>
db.Students.Where(student =>
(student.Status == StudentStatus.Active &&
db.TeachingTaskClasses.Any(assignment =>
assignment.TeachingTaskId == teachingTaskId &&
assignment.AdministrativeClassId == student.AdministrativeClassId)) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
}
@@ -1,4 +1,5 @@
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -98,6 +99,21 @@ public sealed class MySqlMigrationTests
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void MySql_teaching_task_roster_query_is_translatable()
{
using var db = new AppDbContext(CreateMySqlOptions());
var sql = TeachingTaskRosterQuery
.ForTask(
db,
Guid.Parse("11111111-1111-1111-1111-111111111111"))
.ToQueryString();
Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public void MySql_index_names_fit_the_server_identifier_limit() public void MySql_index_names_fit_the_server_identifier_limit()
{ {
@@ -0,0 +1,178 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class TeachingWorkflowRosterTests
{
[Fact]
public async Task ClassAssignedStudents_AppearAcrossTeacherWorkflows()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var teacherUserId = Guid.NewGuid();
var teacherUser = new ApplicationUser
{
Id = teacherUserId,
UserName = "T001",
NormalizedUserName = "T001",
DisplayName = "张老师"
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var student = new Student
{
StudentNumber = "202601001",
Name = "周同学",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
};
var teacher = new Teacher
{
TeacherNumber = "T001",
Name = "张老师",
CollegeId = college.Id,
UserId = teacherUserId
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var task = new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
teacherUser,
college,
major,
administrativeClass,
student,
teacher,
course,
term,
task);
await db.SaveChangesAsync();
var scope = new TeacherDataScope(teacherUserId);
var attendance = new AttendanceController(db, scope);
var attendanceResult = await attendance.CreateSheet(
new AttendanceSheetRequest(
task.Id,
"第1周点名",
new DateTime(2026, 9, 3),
null,
AttendanceCheckInMethod.Manual,
null,
null,
null,
null),
CancellationToken.None);
Assert.IsType<CreatedResult>(attendanceResult);
Assert.Equal(1, await db.AttendanceRecords.CountAsync());
var grades = new GradesController(db, scope);
var gradeResult = await grades.CreateSheet(
new GradeSheetRequest(task.Id, 40, 60, []),
CancellationToken.None);
Assert.IsType<CreatedResult>(gradeResult);
Assert.Equal(1, await db.GradeRecords.CountAsync());
var selections = new CourseSelectionsController(db, scope);
Assert.Single(ReadItems(await selections.GetMyTeachingTasks(
term.Id,
CancellationToken.None)));
var roster = Assert.IsType<OkObjectResult>(
await selections.GetTeachingTaskRoster(task.Id, CancellationToken.None));
Assert.Equal(1, ReadIntProperty(roster.Value!, "StudentCount"));
var adjustments = new CourseAdjustmentsController(db, scope);
Assert.Single(ReadItems(await adjustments.GetTaskOptions(
term.Id,
CancellationToken.None)));
}
private static List<object> ReadItems(ActionResult result)
{
var ok = Assert.IsType<OkObjectResult>(result);
return Assert.IsAssignableFrom<System.Collections.IEnumerable>(ok.Value)
.Cast<object>()
.ToList();
}
private static int ReadIntProperty(object value, string name) =>
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"张老师",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Teacher]));
}
}
+18 -9
View File
@@ -101,6 +101,18 @@ async function submit() {
} catch (e) { ElMessage.error(apiErrorMessage(e)) } } catch (e) { ElMessage.error(apiErrorMessage(e)) }
} }
async function loadTaskOptions() {
if (!isTeacher.value && isManager.value) return
try {
tasks.value = (await http.get('/course-adjustments/task-options', {
params: { academicTermId: termId.value || undefined },
})).data
} catch (error) {
tasks.value = []
ElMessage.error(apiErrorMessage(error))
}
}
async function doSubmit(adj: any) { async function doSubmit(adj: any) {
try { try {
await http.post(`/course-adjustments/${adj.id}/submit`) await http.post(`/course-adjustments/${adj.id}/submit`)
@@ -137,16 +149,13 @@ async function doReject() {
onMounted(async () => { onMounted(async () => {
try { try {
const [termRes, taskRes] = await Promise.all([ const termRes = await http.get('/base-data/terms')
http.get('/base-data/terms'),
isTeacher.value || !isManager.value
? http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } })
: Promise.resolve({ data: { items: [] } }),
])
terms.value = termRes.data terms.value = termRes.data
tasks.value = taskRes.data.items
termId.value = defaultAcademicTermId(terms.value) termId.value = defaultAcademicTermId(terms.value)
} catch (_) {} await loadTaskOptions()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
await load() await load()
}) })
@@ -173,7 +182,7 @@ function showCancel(type: string) { return type === 'Cancel' }
</section> </section>
<section class="adj-toolbar"> <section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load(); loadTaskOptions()">
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /> <el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
</el-select> </el-select>
<el-segmented v-model="tab" :options="[ <el-segmented v-model="tab" :options="[
+33 -33
View File
@@ -8,22 +8,22 @@ import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } fro
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const terms = ref<any[]>([]) const terms = ref<any[]>([])
const offerings = ref<any[]>([]) const tasks = ref<any[]>([])
const selectedOffering = ref<any>(null) const selectedTask = ref<any>(null)
const roster = ref<any>(null) const roster = ref<any>(null)
const termId = ref<string>() const termId = ref<string>()
async function loadOfferings() { async function loadTasks() {
loading.value = true loading.value = true
try { try {
offerings.value = (await http.get('/course-selections/my-offerings', { tasks.value = (await http.get('/course-selections/my-teaching-tasks', {
params: { academicTermId: termId.value || undefined }, params: { academicTermId: termId.value || undefined },
})).data })).data
const preferred = offerings.value.find((item: any) => item.id === selectedOffering.value?.id) const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
?? offerings.value[0] ?? tasks.value[0]
if (preferred) await selectOffering(preferred) if (preferred) await selectTask(preferred)
else { else {
selectedOffering.value = null selectedTask.value = null
roster.value = null roster.value = null
} }
} catch (error) { } catch (error) {
@@ -33,11 +33,11 @@ async function loadOfferings() {
} }
} }
async function selectOffering(offering: any) { async function selectTask(task: any) {
selectedOffering.value = offering selectedTask.value = task
detailLoading.value = true detailLoading.value = true
try { try {
roster.value = (await http.get(`/course-selections/offerings/${offering.id}/roster`)).data roster.value = (await http.get(`/course-selections/teaching-tasks/${task.id}/roster`)).data
} catch (error) { } catch (error) {
roster.value = null roster.value = null
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -47,11 +47,11 @@ async function selectOffering(offering: any) {
} }
async function exportRoster() { async function exportRoster() {
if (!selectedOffering.value) return if (!selectedTask.value) return
try { try {
await downloadApiFile( await downloadApiFile(
`/course-selections/offerings/${selectedOffering.value.id}/roster/export.xlsx`, `/course-selections/teaching-tasks/${selectedTask.value.id}/roster/export.xlsx`,
`选课名单-${selectedOffering.value.taskNumber}.xlsx`, `教学班名单-${selectedTask.value.taskNumber}.xlsx`,
) )
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -61,7 +61,7 @@ async function exportRoster() {
onMounted(async () => { onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value) termId.value = defaultAcademicTermId(terms.value)
await loadOfferings() await loadTasks()
}) })
</script> </script>
@@ -71,43 +71,43 @@ onMounted(async () => {
<div> <div>
<span class="section-kicker">ENROLLMENT ROSTER</span> <span class="section-kicker">ENROLLMENT ROSTER</span>
<h2>选课名单</h2> <h2>选课名单</h2>
<p>查看本人授课班级的选课学生名单支持 Excel 导出</p> <p>查看本人授课教学班的完整学生名单支持 Excel 导出</p>
</div> </div>
<el-button :icon="Refresh" @click="loadOfferings">刷新</el-button> <el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
</section> </section>
<section class="roster-toolbar"> <section class="roster-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings"> <el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<span> {{ offerings.length }} 个教学班</span> <span> {{ tasks.length }} 个教学班</span>
</section> </section>
<section class="roster-workspace" v-loading="loading"> <section class="roster-workspace" v-loading="loading">
<aside class="roster-task-list"> <aside class="roster-task-list">
<button <button
v-for="offering in offerings" v-for="task in tasks"
:key="offering.id" :key="task.id"
type="button" type="button"
:class="{ active: selectedOffering?.id === offering.id }" :class="{ active: selectedTask?.id === task.id }"
@click="selectOffering(offering)" @click="selectTask(task)"
> >
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span> <span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
<b>{{ offering.courseName }}</b> <b>{{ task.courseName }}</b>
<small>{{ offering.termName }} · {{ offering.roundName }}</small> <small>{{ task.termName }}</small>
<i>{{ offering.enrolledCount }} / {{ offering.capacity }} </i> <i>{{ task.studentCount }} / {{ task.capacity }} </i>
</button> </button>
<el-empty v-if="!offerings.length" description="当前学期没有课教学班" /> <el-empty v-if="!tasks.length" description="当前学期没有课教学班" />
</aside> </aside>
<main class="roster-detail" v-loading="detailLoading"> <main class="roster-detail" v-loading="detailLoading">
<el-empty v-if="!selectedOffering" description="请选择一个教学班" /> <el-empty v-if="!selectedTask" description="请选择一个教学班" />
<template v-else-if="roster"> <template v-else-if="roster">
<header class="roster-head"> <header class="roster-head">
<div> <div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span> <span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<h3>{{ roster.courseName }}选课名单</h3> <h3>{{ roster.courseName }}教学班名单</h3>
<p>已选 {{ roster.enrolledCount }} / 容量 {{ roster.capacity }} </p> <p> {{ roster.studentCount }} / 容量 {{ roster.capacity }} </p>
</div> </div>
<el-button :icon="Download" @click="exportRoster">导出 Excel</el-button> <el-button :icon="Download" @click="exportRoster">导出 Excel</el-button>
</header> </header>
@@ -118,8 +118,8 @@ onMounted(async () => {
<el-table-column prop="name" label="姓名" min-width="100" /> <el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="班级" min-width="130" /> <el-table-column prop="className" label="班级" min-width="130" />
<el-table-column prop="majorName" label="专业" min-width="150" /> <el-table-column prop="majorName" label="专业" min-width="150" />
<el-table-column label="选课时间" width="170"> <el-table-column label="名单来源" width="210">
<template #default="{ row }">{{ new Date(row.enrolledAt).toLocaleString('zh-CN') }}</template> <template #default="{ row }">{{ row.enrolledAt ? `选课 · ${new Date(row.enrolledAt).toLocaleString('zh-CN')}` : '行政班关联' }}</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</template> </template>