From d02b0c56a18fef38d1c08efed57247383d19ed36 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 16:16:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=99=E5=AD=A6=E7=82=B9=E5=90=8D=E5=BC=BA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AttendanceController.cs | 231 ++++++++++++++++++ .../Controllers/GradesController.cs | 10 + .../Domain/Academic/AttendanceEntities.cs | 13 + .../Persistence/AppDbContext.cs | 3 + .../Persistence/DevelopmentSqliteMigrator.cs | 25 ++ .../MySql/20260725170000_AttendanceAppeal.cs | 66 +++++ web/src/api/http.ts | 8 +- web/src/layouts/AdminLayout.vue | 7 +- web/src/router/index.ts | 10 +- web/src/views/StudentAttendanceView.vue | 119 +++++++++ 10 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725170000_AttendanceAppeal.cs create mode 100644 web/src/views/StudentAttendanceView.vue diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index fae2480..a7b667e 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -23,6 +23,7 @@ public sealed class AttendanceController( SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + + SystemRoles.Counselor + "," + SystemRoles.Teacher; [HttpGet("my-tasks")] @@ -299,6 +300,220 @@ public sealed class AttendanceController( return NoContent(); } + // ═══════════════ Student endpoints ═══════════════ + + [HttpGet("my-records")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetMyRecords( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var studentId = await db.Students + .Where(s => s.UserId == userId) + .Select(s => (Guid?)s.Id) + .FirstOrDefaultAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + + var source = db.AttendanceRecords.AsNoTracking() + .Where(r => r.StudentId == studentId.Value && + r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted); + if (academicTermId.HasValue) + source = source.Where(r => + r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); + + return Ok(await source + .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) + .Select(r => new + { + r.AttendanceSheetId, + SheetName = r.AttendanceSheet!.Name, + r.AttendanceSheet.AttendanceDate, + TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, + CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, + CourseName = r.AttendanceSheet.TeachingTask.Course.Name, + TeacherNames = r.AttendanceSheet.TeachingTask.Teachers + .OrderByDescending(t => t.IsPrimary) + .Select(t => t.Teacher!.Name), + r.Status, + r.Notes, + r.AppealStatus, + r.AppealReason, + r.AppealSubmittedAt, + r.AppealReviewComment, + r.AppealReviewedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("records/appeal")] + [Authorize(Roles = SystemRoles.Student)] + public async Task SubmitAppeal( + AttendanceAppealRequest request, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var studentId = await db.Students + .Where(s => s.UserId == userId) + .Select(s => (Guid?)s.Id) + .FirstOrDefaultAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); + + var record = await db.AttendanceRecords + .FirstOrDefaultAsync(r => + r.AttendanceSheetId == request.AttendanceSheetId && + r.StudentId == studentId.Value, + cancellationToken); + if (record is null) return NotFound(); + if (record.AppealStatus == AttendanceAppealStatus.Pending) + return ConflictProblem("已有申诉正在处理中。"); + if (record.AppealStatus == AttendanceAppealStatus.Approved) + return ConflictProblem("该考勤记录申诉已通过。"); + + record.AppealStatus = AttendanceAppealStatus.Pending; + record.AppealReason = request.Reason.Trim(); + record.AppealSubmittedAt = DateTime.UtcNow; + record.AppealReviewComment = null; + record.AppealReviewedAt = null; + await db.SaveChangesAsync(cancellationToken); + + // Notify the course teacher + var teacherUserIds = await db.TeachingTaskTeachers + .Where(x => x.TeachingTaskId == record.AttendanceSheet!.TeachingTaskId) + .Select(x => x.Teacher!.UserId) + .Where(id => id != null) + .Select(id => id!.Value) + .ToListAsync(cancellationToken); + if (teacherUserIds.Count > 0) + { + var studentName = await db.Students + .Where(s => s.Id == studentId.Value) + .Select(s => s.Name) + .FirstOrDefaultAsync(cancellationToken); + var courseName = record.AttendanceSheet!.TeachingTask?.Course?.Name ?? ""; + await NotificationService.SendToUserIdsAsync(db, teacherUserIds, + "考勤申诉待处理", + $"学生 {studentName} 对《{courseName}》考勤记录提出申诉。", + "/teacher-attendance", cancellationToken); + } + + return NoContent(); + } + + // ═══════════════ Counselor endpoints ═══════════════ + + [HttpGet("counselor-records")] + [Authorize(Roles = SystemRoles.Counselor)] + public async Task GetCounselorRecords( + Guid? academicTermId, + Guid? classId, + bool? withAppeal, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var classIds = await db.AdministrativeClasses + .Where(c => c.CounselorUserId == userId) + .Select(c => c.Id) + .ToListAsync(cancellationToken); + if (classIds.Count == 0) + return ConflictProblem("当前账号未关联任何班级。"); + + if (classId.HasValue && !classIds.Contains(classId.Value)) + return ConflictProblem("您不是该班级的辅导员。"); + + var targetClassIds = classId.HasValue + ? [classId.Value] + : classIds; + + var source = db.AttendanceRecords.AsNoTracking() + .Where(r => + r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && + r.Student!.AdministrativeClassId != null && + targetClassIds.Contains(r.Student.AdministrativeClassId)); + if (academicTermId.HasValue) + source = source.Where(r => + r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); + if (withAppeal == true) + source = source.Where(r => r.AppealStatus == AttendanceAppealStatus.Pending); + + return Ok(await source + .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate) + .Select(r => new + { + r.AttendanceSheetId, + SheetName = r.AttendanceSheet!.Name, + r.AttendanceSheet.AttendanceDate, + TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber, + CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code, + CourseName = r.AttendanceSheet.TeachingTask.Course.Name, + StudentNumber = r.Student!.StudentNumber, + StudentName = r.Student.Name, + ClassName = r.Student.AdministrativeClass!.Name, + r.Status, + r.Notes, + r.AppealStatus, + r.AppealReason, + r.AppealSubmittedAt, + r.AppealReviewComment + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("appeals/{attendanceSheetId:guid}/{studentId:guid}/review")] + [Authorize(Roles = AttendanceRoles)] + public async Task ReviewAppeal( + Guid attendanceSheetId, + Guid studentId, + AttendanceAppealReviewRequest request, + CancellationToken cancellationToken) + { + var record = await db.AttendanceRecords + .Include(r => r.AttendanceSheet) + .ThenInclude(s => s!.TeachingTask) + .ThenInclude(t => t!.Teachers) + .FirstOrDefaultAsync(r => + r.AttendanceSheetId == attendanceSheetId && + r.StudentId == studentId, + cancellationToken); + if (record is null) return NotFound(); + if (!CanManageSheet(record.AttendanceSheet!)) + return Forbid(); + if (record.AppealStatus != AttendanceAppealStatus.Pending) + return ConflictProblem("该申诉不在待处理状态。"); + + record.AppealStatus = request.Approve + ? AttendanceAppealStatus.Approved + : AttendanceAppealStatus.Rejected; + record.AppealReviewComment = request.Comment?.Trim(); + record.AppealReviewedAt = DateTime.UtcNow; + + // Update record status on approved appeal + if (request.Approve) + record.Status = AttendanceStatus.Excused; + + await db.SaveChangesAsync(cancellationToken); + + // Notify student + var studentUserId = await db.Students + .Where(s => s.Id == studentId) + .Select(s => s.UserId) + .FirstOrDefaultAsync(cancellationToken); + if (studentUserId.HasValue) + { + var result = request.Approve ? "已通过" : "已驳回"; + await NotificationService.SendAsync(db, studentUserId.Value, + $"考勤申诉{result}", + request.Comment is not null + ? $"您的考勤申诉{result}。意见:{request.Comment}" + : $"您的考勤申诉{result}。", + null, cancellationToken); + } + + return NoContent(); + } + [HttpDelete("sheets/{id:guid}")] public async Task Delete(Guid id, CancellationToken cancellationToken) { @@ -320,6 +535,14 @@ public sealed class AttendanceController( 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.Counselor)) + { + var collegeIds = db.AdministrativeClasses + .Where(c => c.CounselorUserId == scope.UserId) + .Select(c => c.Major!.CollegeId) + .Distinct(); + return source.Where(x => collegeIds.Contains(x.Course!.CollegeId)); + } if (scope.IsInRole(SystemRoles.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); @@ -357,3 +580,11 @@ public sealed record AttendanceRecordRequest( Guid StudentId, AttendanceStatus Status, [MaxLength(300)] string? Notes); + +public sealed record AttendanceAppealRequest( + Guid AttendanceSheetId, + [Required, MaxLength(500)] string Reason); + +public sealed record AttendanceAppealReviewRequest( + bool Approve, + [MaxLength(300)] string? Comment); diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index 39837c1..b13c046 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -21,6 +21,7 @@ public sealed class GradesController( SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + + SystemRoles.Counselor + "," + SystemRoles.Teacher; private const string Reviewers = @@ -587,6 +588,15 @@ public sealed class GradesController( 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.Counselor)) + { + // Counselor sees grades for courses in their managed classes' college + var collegeIds = db.AdministrativeClasses + .Where(c => c.CounselorUserId == scope.UserId) + .Select(c => c.Major!.CollegeId) + .Distinct(); + return source.Where(x => collegeIds.Contains(x.Course!.CollegeId)); + } if (scope.IsInRole(SystemRoles.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); diff --git a/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs index 5d17135..49c8490 100644 --- a/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/AttendanceEntities.cs @@ -22,6 +22,11 @@ public sealed class AttendanceRecord public Student? Student { get; set; } public AttendanceStatus Status { get; set; } = AttendanceStatus.Present; public string? Notes { get; set; } + public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None; + public string? AppealReason { get; set; } + public DateTime? AppealSubmittedAt { get; set; } + public string? AppealReviewComment { get; set; } + public DateTime? AppealReviewedAt { get; set; } } public enum AttendanceSheetStatus @@ -38,3 +43,11 @@ public enum AttendanceStatus Leave = 4, Excused = 5 } + +public enum AttendanceAppealStatus +{ + None = 0, + Pending = 1, + Approved = 2, + Rejected = 3 +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 44b77f4..9395641 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -543,6 +543,9 @@ public sealed class AppDbContext(DbContextOptions options) { entity.HasKey(x => new { x.AttendanceSheetId, x.StudentId }); entity.Property(x => x.Notes).HasMaxLength(300); + entity.Property(x => x.AppealReason).HasMaxLength(500); + entity.Property(x => x.AppealReviewComment).HasMaxLength(300); + entity.HasIndex(x => x.AppealStatus); entity.HasOne(x => x.AttendanceSheet) .WithMany(x => x.Records) .HasForeignKey(x => x.AttendanceSheetId) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 6b46f6c..6eeb40d 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -38,6 +38,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_21_retake_enrollment"; private const string CourseAdjustmentsMigration = "20260725_22_course_adjustments"; + private const string AttendanceAppealMigration = + "20260725_23_attendance_appeal"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -255,6 +257,19 @@ public sealed class DevelopmentSqliteMigrator( CourseAdjustmentsMigration, courseAdjustmentsExist ? [] : CourseAdjustmentsStatements, cancellationToken); + + var attendanceAppealExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('AttendanceRecords') + WHERE name = 'AppealStatus' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + AttendanceAppealMigration, + attendanceAppealExists ? [] : AttendanceAppealStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1553,4 +1568,14 @@ public sealed class DevelopmentSqliteMigrator( """CREATE INDEX "IX_Notifications_UserId_IsRead" ON "Notifications" ("UserId", "IsRead");""", """CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");""" ]; + + private static readonly string[] AttendanceAppealStatements = + [ + """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealReason" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealSubmittedAt" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealReviewComment" TEXT NULL;""", + """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealReviewedAt" TEXT NULL;""", + """CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");""" + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725170000_AttendanceAppeal.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725170000_AttendanceAppeal.cs new file mode 100644 index 0000000..c7f7a35 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725170000_AttendanceAppeal.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class AttendanceAppeal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AppealStatus", + table: "AttendanceRecords", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "AppealReason", + table: "AttendanceRecords", + type: "varchar(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "AppealSubmittedAt", + table: "AttendanceRecords", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "AppealReviewComment", + table: "AttendanceRecords", + type: "varchar(300)", + maxLength: 300, + nullable: true); + + migrationBuilder.AddColumn( + name: "AppealReviewedAt", + table: "AttendanceRecords", + type: "datetime(6)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AttendanceRecords_AppealStatus", + table: "AttendanceRecords", + column: "AppealStatus"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AttendanceRecords_AppealStatus", + table: "AttendanceRecords"); + + migrationBuilder.DropColumn(name: "AppealReviewedAt", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "AppealReviewComment", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "AppealSubmittedAt", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "AppealReason", table: "AttendanceRecords"); + migrationBuilder.DropColumn(name: "AppealStatus", table: "AttendanceRecords"); + } + } +} diff --git a/web/src/api/http.ts b/web/src/api/http.ts index 5399ef9..1dc5d3c 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -25,6 +25,7 @@ http.interceptors.response.use( export function apiErrorMessage(error: unknown) { if (!axios.isAxiosError(error)) return '操作失败,请稍后重试。' + const status = error.response?.status const data = error.response?.data if (data?.errors && typeof data.errors === 'object') { const validationMessage = Object.values(data.errors) @@ -37,7 +38,12 @@ export function apiErrorMessage(error: unknown) { const detail = typeof data?.detail === 'string' ? data.detail.trim() : '' if (detail) return detail const title = typeof data?.title === 'string' ? data.title.trim() : '' - return title || '操作失败,请检查网络连接。' + if (title) return title + if (status === 403) return '您没有权限执行此操作,请联系管理员。' + if (status === 404) return '请求的资源不存在。' + if (status === 401) return '登录已过期,请重新登录。' + if (status && status >= 500) return '服务器繁忙,请稍后重试。' + return '操作失败,请检查网络连接。' } export default http diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 6a18326..268d07a 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -116,7 +116,8 @@ const navigationGroups = computed(() => [ ), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), - ...whenVisible(isTeacher.value || isTeachingAdmin.value, { path: '/teacher-attendance', label: '教学点名' }), + ...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }), + ...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }), ...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }), ...whenVisible(!isStudent.value, { path: '/class-timetable', @@ -130,8 +131,8 @@ const navigationGroups = computed(() => [ }, ), ...whenVisible( - hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student']), - { path: '/grades', label: isStudent.value ? '学业成绩' : '成绩管理' }, + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']), + { path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' }, ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']), diff --git a/web/src/router/index.ts b/web/src/router/index.ts index f5e7c24..89101ad 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -159,7 +159,13 @@ const router = createRouter({ path: 'teacher-attendance', name: 'teacher-attendance', component: () => import('../views/TeacherAttendanceView.vue'), - meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'] }, + meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Counselor'] }, + }, + { + path: 'my-attendance', + name: 'my-attendance', + component: () => import('../views/StudentAttendanceView.vue'), + meta: { roles: ['Student'] }, }, { path: 'free-classrooms', @@ -180,7 +186,7 @@ const router = createRouter({ name: 'grades', component: () => import('../views/GradesView.vue'), meta: { - roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'], + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor'], }, }, { diff --git a/web/src/views/StudentAttendanceView.vue b/web/src/views/StudentAttendanceView.vue new file mode 100644 index 0000000..6c68459 --- /dev/null +++ b/web/src/views/StudentAttendanceView.vue @@ -0,0 +1,119 @@ + + + + +