From db91988264d14f638f2867b29e9ef2e6e3e0c0f3 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 10:09:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=99=E5=B8=88=E6=A1=A3=E6=A1=88=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E2=80=9C=E6=BF=80=E6=B4=BB=E8=B4=A6=E5=8F=B7=E2=80=9D?= =?UTF-8?q?=EF=BC=9A=E7=AE=A1=E7=90=86=E5=91=98=E8=AE=BE=E7=BD=AE=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=AF=86=E7=A0=81=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=AE=A1=E7=90=86=E6=9D=83=E9=99=90=E3=80=81?= =?UTF-8?q?=E5=AD=A6=E9=99=A2=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4=E3=80=81?= =?UTF-8?q?=E5=9C=A8=E8=81=8C=E7=8A=B6=E6=80=81=E3=80=81=E5=B7=A5=E5=8F=B7?= =?UTF-8?q?=E5=86=B2=E7=AA=81=EF=BC=8C=E5=B9=B6=E8=87=AA=E5=8A=A8=E5=85=B3?= =?UTF-8?q?=E8=81=94=E6=95=99=E5=B8=88=E8=A7=92=E8=89=B2=E4=B8=8E=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E3=80=82=20=E5=B7=B2=E5=8F=91=E5=B8=83=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=8A=A0=E2=80=9C=E5=8F=91=E5=B8=83=E5=90=8E?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E2=80=9D=EF=BC=9A=E5=88=9B=E5=BB=BA=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=E8=8D=89=E7=A8=BF=EF=BC=8C=E6=97=A7=E8=AF=BE=E8=A1=A8?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E7=94=9F=E6=95=88=EF=BC=9B=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=E7=89=88=E9=87=8D=E6=96=B0=E5=8F=91=E5=B8=83=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E6=97=A7=E7=89=88=E6=9C=AC=E3=80=82=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20390px=20=E7=AA=84=E5=B1=8F=E4=B8=8B?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E9=A1=B5=E5=A4=B4=E6=8C=89=E9=92=AE=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/PersonnelController.cs | 69 +++++++++- .../PersonnelControllerTests.cs | 90 +++++++++++++ web/src/style.css | 2 + web/src/views/PersonnelView.vue | 125 +++++++++++++++++- web/src/views/SchedulesView.vue | 58 ++++++-- 5 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index e47db5a..f48e211 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -5,6 +5,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/personnel")] public sealed class PersonnelController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + UserManager userManager) : ControllerBase { private const string ReadRoles = SystemRoles.SuperAdmin + "," + @@ -149,6 +151,61 @@ public sealed class PersonnelController( return await SaveNoContentAsync(cancellationToken); } + [HttpPost("teachers/{id:guid}/activate-account")] + [Authorize(Roles = WriteRoles)] + public async Task ActivateTeacherAccount( + Guid id, + TeacherAccountActivationRequest request, + CancellationToken cancellationToken) + { + var teacher = await db.Teachers.FindAsync([id], cancellationToken); + if (teacher is null) return NotFound(); + if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); + if (teacher.Status != TeacherStatus.Active) + { + return ConflictProblem("仅在职教师可以激活登录账号。"); + } + if (teacher.UserId.HasValue) + { + return ConflictProblem("该教师档案已经关联登录账号。"); + } + + var userName = teacher.TeacherNumber.Trim(); + if (await userManager.FindByNameAsync(userName) is not null) + { + return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。"); + } + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + var user = new ApplicationUser + { + UserName = userName, + DisplayName = teacher.Name, + StaffNumber = userName, + CollegeId = teacher.CollegeId, + IsEnabled = true, + LockoutEnabled = true + }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } + + result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } + + teacher.UserId = user.Id; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { user.Id, UserName = userName }); + } + [HttpGet("students")] public async Task>> GetStudents( [FromQuery] PersonnelQuery query, @@ -393,6 +450,13 @@ public sealed class PersonnelController( Status = StatusCodes.Status409Conflict }); + private ActionResult IdentityValidationProblem(IdentityResult result) + { + foreach (var error in result.Errors) + ModelState.AddModelError(error.Code, error.Description); + return ValidationProblem(ModelState); + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); @@ -436,3 +500,6 @@ public sealed record StudentRequest( [MaxLength(30)] string? Phone, [EmailAddress, MaxLength(100)] string? Email, [MaxLength(500)] string? Notes); + +public sealed record TeacherAccountActivationRequest( + [Required, MinLength(8), MaxLength(100)] string Password); diff --git a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs new file mode 100644 index 0000000..777e619 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs @@ -0,0 +1,90 @@ +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.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class PersonnelControllerTests +{ + [Fact] + public async Task ActivateTeacherAccount_CreatesAndLinksTeacherLogin() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services + .AddIdentityCore(options => + { + options.Password.RequiredLength = 8; + options.Password.RequireDigit = true; + options.Password.RequireLowercase = true; + options.Password.RequireUppercase = true; + options.Password.RequireNonAlphanumeric = true; + }) + .AddRoles() + .AddEntityFrameworkStores(); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var roleManager = scope.ServiceProvider + .GetRequiredService>(); + Assert.True((await roleManager.CreateAsync(new ApplicationRole + { + Name = SystemRoles.Teacher, + Description = "教师" + })).Succeeded); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var teacher = new Teacher + { + TeacherNumber = "T2026999", + Name = "测试教师", + CollegeId = college.Id, + Status = TeacherStatus.Active + }; + db.AddRange(college, teacher); + await db.SaveChangesAsync(); + + var userManager = scope.ServiceProvider + .GetRequiredService>(); + var controller = new PersonnelController( + db, + new TestDataScope(college.Id), + userManager); + + var result = await controller.ActivateTeacherAccount( + teacher.Id, + new TeacherAccountActivationRequest("Teacher@123"), + CancellationToken.None); + + Assert.IsType(result); + var user = await userManager.FindByNameAsync(teacher.TeacherNumber); + Assert.NotNull(user); + Assert.Equal(teacher.Name, user.DisplayName); + Assert.Equal(teacher.CollegeId, user.CollegeId); + Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher)); + await db.Entry(teacher).ReloadAsync(); + Assert.Equal(user.Id, teacher.UserId); + } + + private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + } +} diff --git a/web/src/style.css b/web/src/style.css index 4038bde..2c1f7c9 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1070,6 +1070,8 @@ button { cursor: pointer; } } @media (max-width: 600px) { + .page-intro { align-items: flex-start; flex-direction: column; } + .page-actions { width: 100%; flex-wrap: wrap; } .option-filter-grid.course, .option-filter-grid.course.compact, .option-filter-grid.classes { grid-template-columns: 1fr; } diff --git a/web/src/views/PersonnelView.vue b/web/src/views/PersonnelView.vue index b1b3b90..66149a4 100644 --- a/web/src/views/PersonnelView.vue +++ b/web/src/views/PersonnelView.vue @@ -1,6 +1,6 @@