取消新增/Excel 导入档案时自动创建账号。
取消修改人员档案时同步登录账号。 Excel 人员模板恢复为不含“初始密码”。 登录页新增“学生首次登录?自助激活账号”入口。 公开激活地址:http://127.0.0.1:5255/activate
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||||
|
|
||||||
学生和教师档案是登录账号的人员主数据:新增或 Excel 导入档案时,系统以学号/工号自动创建同名登录账号并分配学生/教师角色,后续修改姓名、编号、学院或在籍/在职状态时同步账号。`AspNetUsers` 仅作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌,不需要再手工重复建立学生、教师用户。
|
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,117 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Controllers;
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public sealed class AuthController(
|
public sealed class AuthController(
|
||||||
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService) : ControllerBase
|
ITokenService tokenService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("activation-options")]
|
||||||
|
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var colleges = await db.Colleges.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.Select(x => new { x.Id, x.Code, x.Name })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var majors = await db.Majors.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||||
|
.OrderByDescending(x => x.Grade)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Colleges = colleges,
|
||||||
|
Majors = majors,
|
||||||
|
Classes = classes,
|
||||||
|
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpPost("activate-student")]
|
||||||
|
public async Task<ActionResult> ActivateStudent(
|
||||||
|
StudentActivationRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
var studentNumber = request.StudentNumber.Trim();
|
||||||
|
var student = await db.Students
|
||||||
|
.Include(x => x.AdministrativeClass)
|
||||||
|
.ThenInclude(x => x!.Major)
|
||||||
|
.FirstOrDefaultAsync(x =>
|
||||||
|
x.Status == StudentStatus.Active &&
|
||||||
|
x.Name == name &&
|
||||||
|
x.StudentNumber == studentNumber &&
|
||||||
|
x.EnrollmentYear == request.Grade &&
|
||||||
|
x.AdministrativeClassId == request.AdministrativeClassId &&
|
||||||
|
x.AdministrativeClass!.Grade == request.Grade &&
|
||||||
|
x.AdministrativeClass.MajorId == request.MajorId &&
|
||||||
|
x.AdministrativeClass.Major!.CollegeId == request.CollegeId,
|
||||||
|
cancellationToken);
|
||||||
|
if (student is null)
|
||||||
|
return ActivationProblem(
|
||||||
|
"填写的信息与在籍学生档案不完全一致,请核对后重试。",
|
||||||
|
StatusCodes.Status400BadRequest);
|
||||||
|
if (student.UserId.HasValue)
|
||||||
|
return ActivationProblem(
|
||||||
|
"该学号已经激活,请直接登录;如忘记密码请联系管理员重置。",
|
||||||
|
StatusCodes.Status409Conflict);
|
||||||
|
if (await userManager.FindByNameAsync(studentNumber) is not null)
|
||||||
|
return ActivationProblem(
|
||||||
|
"该学号已有登录账号但未正确关联,请联系管理员处理。",
|
||||||
|
StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = studentNumber,
|
||||||
|
DisplayName = student.Name,
|
||||||
|
StaffNumber = studentNumber,
|
||||||
|
CollegeId = request.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.Student);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return IdentityValidationProblem(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
student.UserId = user.Id;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Ok(new { UserName = studentNumber });
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
|
||||||
@@ -77,12 +175,36 @@ public sealed class AuthController(
|
|||||||
user.CollegeId,
|
user.CollegeId,
|
||||||
EffectiveDataScopeResolver.Resolve(roles).ToString());
|
EffectiveDataScopeResolver.Resolve(roles).ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult IdentityValidationProblem(IdentityResult result)
|
||||||
|
{
|
||||||
|
foreach (var error in result.Errors)
|
||||||
|
ModelState.AddModelError(error.Code, error.Description);
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult ActivationProblem(string detail, int status) =>
|
||||||
|
StatusCode(status, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "账号激活失败",
|
||||||
|
Detail = detail,
|
||||||
|
Status = status
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LoginRequest(
|
public sealed record LoginRequest(
|
||||||
[Required, MaxLength(100)] string UserName,
|
[Required, MaxLength(100)] string UserName,
|
||||||
[Required, MaxLength(100)] string Password);
|
[Required, MaxLength(100)] string Password);
|
||||||
|
|
||||||
|
public sealed record StudentActivationRequest(
|
||||||
|
[Required, MaxLength(50)] string Name,
|
||||||
|
[Required, MaxLength(30)] string StudentNumber,
|
||||||
|
Guid CollegeId,
|
||||||
|
Guid MajorId,
|
||||||
|
[Range(2000, 2200)] int Grade,
|
||||||
|
Guid AdministrativeClassId,
|
||||||
|
[Required, MinLength(8), MaxLength(100)] string Password);
|
||||||
|
|
||||||
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
public sealed record LoginResponse(string Token, CurrentUserResponse User);
|
||||||
|
|
||||||
public sealed record CurrentUserResponse(
|
public sealed record CurrentUserResponse(
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelController(
|
public sealed class PersonnelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
PersonnelAccountService personnelAccountService) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -104,14 +103,7 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Teachers.Add(entity);
|
db.Teachers.Add(entity);
|
||||||
return await CreateWithAccountAsync(
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
||||||
entity.Id,
|
|
||||||
request.InitialPassword,
|
|
||||||
() => personnelAccountService.EnsureTeacherAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("teachers/{id:guid}")]
|
[HttpPut("teachers/{id:guid}")]
|
||||||
@@ -138,18 +130,6 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
entity.Notes = Normalize(request.Notes);
|
||||||
if (!entity.UserId.HasValue &&
|
|
||||||
(string.IsNullOrWhiteSpace(request.InitialPassword) ||
|
|
||||||
request.InitialPassword.Length < 8))
|
|
||||||
return ValidationProblem("该教师档案尚未开通账号,请填写至少 8 位初始密码。");
|
|
||||||
if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword))
|
|
||||||
{
|
|
||||||
var account = await personnelAccountService.EnsureTeacherAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken);
|
|
||||||
if (!account.Success) return AccountProblem(account.Error!);
|
|
||||||
}
|
|
||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +239,7 @@ public sealed class PersonnelController(
|
|||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
};
|
};
|
||||||
db.Students.Add(entity);
|
db.Students.Add(entity);
|
||||||
return await CreateWithAccountAsync(
|
return await SaveCreatedAsync(entity.Id, cancellationToken);
|
||||||
entity.Id,
|
|
||||||
request.InitialPassword,
|
|
||||||
() => personnelAccountService.EnsureStudentAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("students/{id:guid}")]
|
[HttpPut("students/{id:guid}")]
|
||||||
@@ -303,18 +276,6 @@ public sealed class PersonnelController(
|
|||||||
entity.Phone = Normalize(request.Phone);
|
entity.Phone = Normalize(request.Phone);
|
||||||
entity.Email = Normalize(request.Email);
|
entity.Email = Normalize(request.Email);
|
||||||
entity.Notes = Normalize(request.Notes);
|
entity.Notes = Normalize(request.Notes);
|
||||||
if (!entity.UserId.HasValue &&
|
|
||||||
(string.IsNullOrWhiteSpace(request.InitialPassword) ||
|
|
||||||
request.InitialPassword.Length < 8))
|
|
||||||
return ValidationProblem("该学生档案尚未开通账号,请填写至少 8 位初始密码。");
|
|
||||||
if (entity.UserId.HasValue || !string.IsNullOrWhiteSpace(request.InitialPassword))
|
|
||||||
{
|
|
||||||
var account = await personnelAccountService.EnsureStudentAccountAsync(
|
|
||||||
entity,
|
|
||||||
request.InitialPassword,
|
|
||||||
cancellationToken);
|
|
||||||
if (!account.Success) return AccountProblem(account.Error!);
|
|
||||||
}
|
|
||||||
return await SaveNoContentAsync(cancellationToken);
|
return await SaveNoContentAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,39 +372,6 @@ public sealed class PersonnelController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ActionResult> CreateWithAccountAsync(
|
|
||||||
Guid id,
|
|
||||||
string? initialPassword,
|
|
||||||
Func<Task<PersonnelAccountResult>> createAccount,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(initialPassword) || initialPassword.Length < 8)
|
|
||||||
return ValidationProblem("新增人员时必须填写至少 8 位初始密码。");
|
|
||||||
|
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var account = await createAccount();
|
|
||||||
if (!account.Success)
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
return AccountProblem(account.Error!);
|
|
||||||
}
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
return Created(string.Empty, new
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
account.UserId,
|
|
||||||
account.UserName
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
|
||||||
return ConflictProblem("编号已存在,或关联数据无效。");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -465,14 +393,6 @@ public sealed class PersonnelController(
|
|||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
|
|
||||||
private ActionResult AccountProblem(string detail) =>
|
|
||||||
Conflict(new ProblemDetails
|
|
||||||
{
|
|
||||||
Title = "登录账号创建失败",
|
|
||||||
Detail = detail,
|
|
||||||
Status = StatusCodes.Status409Conflict
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
@@ -502,8 +422,7 @@ public sealed record TeacherRequest(
|
|||||||
bool IsExternal,
|
bool IsExternal,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes,
|
[MaxLength(500)] string? Notes);
|
||||||
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
|
||||||
|
|
||||||
public sealed record StudentRequest(
|
public sealed record StudentRequest(
|
||||||
[Required, MaxLength(30)] string StudentNumber,
|
[Required, MaxLength(30)] string StudentNumber,
|
||||||
@@ -516,5 +435,4 @@ public sealed record StudentRequest(
|
|||||||
DateOnly? DateOfBirth,
|
DateOnly? DateOfBirth,
|
||||||
[MaxLength(30)] string? Phone,
|
[MaxLength(30)] string? Phone,
|
||||||
[EmailAddress, MaxLength(100)] string? Email,
|
[EmailAddress, MaxLength(100)] string? Email,
|
||||||
[MaxLength(500)] string? Notes,
|
[MaxLength(500)] string? Notes);
|
||||||
[MinLength(8), MaxLength(100)] string? InitialPassword = null);
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/personnel")]
|
[Route("api/personnel")]
|
||||||
public sealed class PersonnelExcelController(
|
public sealed class PersonnelExcelController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
PersonnelAccountService personnelAccountService) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string ReadRoles =
|
private const string ReadRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -33,13 +32,13 @@ public sealed class PersonnelExcelController(
|
|||||||
private static readonly string[] TeacherHeaders =
|
private static readonly string[] TeacherHeaders =
|
||||||
[
|
[
|
||||||
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
"工号", "姓名", "性别", "学院编码", "职称", "任职状态",
|
||||||
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注", "初始密码"
|
"入职日期", "教师类别", "联系电话", "电子邮箱", "备注"
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly string[] StudentHeaders =
|
private static readonly string[] StudentHeaders =
|
||||||
[
|
[
|
||||||
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
"学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期",
|
||||||
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注", "初始密码"
|
"学籍状态", "出生日期", "联系电话", "电子邮箱", "备注"
|
||||||
];
|
];
|
||||||
|
|
||||||
[HttpGet("{kind}/template")]
|
[HttpGet("{kind}/template")]
|
||||||
@@ -57,7 +56,6 @@ public sealed class PersonnelExcelController(
|
|||||||
? "工号是唯一标识;学院编码必须已存在。"
|
? "工号是唯一标识;学院编码必须已存在。"
|
||||||
: "学号是唯一标识;行政班编码必须已存在。",
|
: "学号是唯一标识;行政班编码必须已存在。",
|
||||||
"编号已存在时更新档案,不存在时新增档案。",
|
"编号已存在时更新档案,不存在时新增档案。",
|
||||||
"新增人员或补建账号时必须填写至少 8 位初始密码;已有关联账号时可留空。",
|
|
||||||
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
|
"日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。",
|
||||||
"整批数据会先校验,任一行有误时均不会写入。"
|
"整批数据会先校验,任一行有误时均不会写入。"
|
||||||
]);
|
]);
|
||||||
@@ -83,7 +81,7 @@ public sealed class PersonnelExcelController(
|
|||||||
.Select(x => Row(
|
.Select(x => Row(
|
||||||
x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code,
|
x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code,
|
||||||
x.Title, TeacherStatusName(x.Status), x.HireDate,
|
x.Title, TeacherStatusName(x.Status), x.HireDate,
|
||||||
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes, null))
|
x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -96,7 +94,7 @@ public sealed class PersonnelExcelController(
|
|||||||
.Select(x => Row(
|
.Select(x => Row(
|
||||||
x.StudentNumber, x.Name, GenderName(x.Gender),
|
x.StudentNumber, x.Name, GenderName(x.Gender),
|
||||||
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate,
|
||||||
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes, null))
|
StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +212,6 @@ public sealed class PersonnelExcelController(
|
|||||||
entity.Phone = Optional(row, "联系电话");
|
entity.Phone = Optional(row, "联系电话");
|
||||||
entity.Email = Optional(row, "电子邮箱");
|
entity.Email = Optional(row, "电子邮箱");
|
||||||
entity.Notes = Optional(row, "备注");
|
entity.Notes = Optional(row, "备注");
|
||||||
var account = await personnelAccountService.EnsureTeacherAccountAsync(
|
|
||||||
entity,
|
|
||||||
Optional(row, "初始密码"),
|
|
||||||
cancellationToken);
|
|
||||||
if (!account.Success)
|
|
||||||
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
}
|
}
|
||||||
@@ -292,12 +284,6 @@ public sealed class PersonnelExcelController(
|
|||||||
entity.Phone = Optional(row, "联系电话");
|
entity.Phone = Optional(row, "联系电话");
|
||||||
entity.Email = Optional(row, "电子邮箱");
|
entity.Email = Optional(row, "电子邮箱");
|
||||||
entity.Notes = Optional(row, "备注");
|
entity.Notes = Optional(row, "备注");
|
||||||
var account = await personnelAccountService.EnsureStudentAccountAsync(
|
|
||||||
entity,
|
|
||||||
Optional(row, "初始密码"),
|
|
||||||
cancellationToken);
|
|
||||||
if (!account.Success)
|
|
||||||
errors.Add($"第 {row.RowNumber} 行:{account.Error}");
|
|
||||||
}
|
}
|
||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
|
||||||
using Jiaowu.Api.Domain.Identity;
|
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Auth;
|
|
||||||
|
|
||||||
public sealed class PersonnelAccountService(
|
|
||||||
AppDbContext db,
|
|
||||||
UserManager<ApplicationUser> userManager)
|
|
||||||
{
|
|
||||||
public Task<PersonnelAccountResult> EnsureTeacherAccountAsync(
|
|
||||||
Teacher teacher,
|
|
||||||
string? initialPassword,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
EnsureAccountAsync(
|
|
||||||
teacher.Id,
|
|
||||||
teacher.UserId,
|
|
||||||
teacher.TeacherNumber,
|
|
||||||
teacher.Name,
|
|
||||||
teacher.CollegeId,
|
|
||||||
teacher.Status == TeacherStatus.Active,
|
|
||||||
SystemRoles.Teacher,
|
|
||||||
initialPassword,
|
|
||||||
userId => teacher.UserId = userId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
public async Task<PersonnelAccountResult> EnsureStudentAccountAsync(
|
|
||||||
Student student,
|
|
||||||
string? initialPassword,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var collegeId = await db.AdministrativeClasses.AsNoTracking()
|
|
||||||
.Where(x => x.Id == student.AdministrativeClassId)
|
|
||||||
.Select(x => (Guid?)x.Major!.CollegeId)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (!collegeId.HasValue)
|
|
||||||
return PersonnelAccountResult.Failed("学生所在行政班或学院不存在。");
|
|
||||||
|
|
||||||
return await EnsureAccountAsync(
|
|
||||||
student.Id,
|
|
||||||
student.UserId,
|
|
||||||
student.StudentNumber,
|
|
||||||
student.Name,
|
|
||||||
collegeId.Value,
|
|
||||||
student.Status == StudentStatus.Active,
|
|
||||||
SystemRoles.Student,
|
|
||||||
initialPassword,
|
|
||||||
userId => student.UserId = userId,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<PersonnelAccountResult> EnsureAccountAsync(
|
|
||||||
Guid profileId,
|
|
||||||
Guid? linkedUserId,
|
|
||||||
string number,
|
|
||||||
string displayName,
|
|
||||||
Guid collegeId,
|
|
||||||
bool isEnabled,
|
|
||||||
string role,
|
|
||||||
string? initialPassword,
|
|
||||||
Action<Guid> linkProfile,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var normalizedNumber = number.Trim();
|
|
||||||
ApplicationUser? user = null;
|
|
||||||
if (linkedUserId.HasValue)
|
|
||||||
user = await userManager.FindByIdAsync(linkedUserId.Value.ToString());
|
|
||||||
user ??= await userManager.FindByNameAsync(normalizedNumber);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(initialPassword))
|
|
||||||
return PersonnelAccountResult.Failed("请填写至少 8 位初始密码,以便同时创建登录账号。");
|
|
||||||
|
|
||||||
user = new ApplicationUser
|
|
||||||
{
|
|
||||||
UserName = normalizedNumber,
|
|
||||||
DisplayName = displayName.Trim(),
|
|
||||||
StaffNumber = normalizedNumber,
|
|
||||||
CollegeId = collegeId,
|
|
||||||
IsEnabled = isEnabled,
|
|
||||||
LockoutEnabled = true
|
|
||||||
};
|
|
||||||
var createResult = await userManager.CreateAsync(user, initialPassword);
|
|
||||||
if (!createResult.Succeeded) return Failed(createResult);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var occupied = role == SystemRoles.Teacher
|
|
||||||
? await db.Teachers.AsNoTracking().AnyAsync(
|
|
||||||
x =>
|
|
||||||
x.Id != profileId &&
|
|
||||||
x.UserId == user.Id &&
|
|
||||||
x.TeacherNumber != normalizedNumber,
|
|
||||||
cancellationToken)
|
|
||||||
: await db.Students.AsNoTracking().AnyAsync(
|
|
||||||
x =>
|
|
||||||
x.Id != profileId &&
|
|
||||||
x.UserId == user.Id &&
|
|
||||||
x.StudentNumber != normalizedNumber,
|
|
||||||
cancellationToken);
|
|
||||||
if (occupied)
|
|
||||||
return PersonnelAccountResult.Failed($"登录账号“{normalizedNumber}”已关联其他人员档案。");
|
|
||||||
|
|
||||||
user.UserName = normalizedNumber;
|
|
||||||
user.DisplayName = displayName.Trim();
|
|
||||||
user.StaffNumber = normalizedNumber;
|
|
||||||
user.CollegeId = collegeId;
|
|
||||||
user.IsEnabled = isEnabled;
|
|
||||||
var updateResult = await userManager.UpdateAsync(user);
|
|
||||||
if (!updateResult.Succeeded) return Failed(updateResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await userManager.IsInRoleAsync(user, role))
|
|
||||||
{
|
|
||||||
var roleResult = await userManager.AddToRoleAsync(user, role);
|
|
||||||
if (!roleResult.Succeeded) return Failed(roleResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
linkProfile(user.Id);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
return PersonnelAccountResult.Succeeded(user.Id, normalizedNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PersonnelAccountResult Failed(IdentityResult result) =>
|
|
||||||
PersonnelAccountResult.Failed(string.Join(
|
|
||||||
";",
|
|
||||||
result.Errors.Select(x => x.Description)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record PersonnelAccountResult(
|
|
||||||
bool Success,
|
|
||||||
Guid? UserId,
|
|
||||||
string? UserName,
|
|
||||||
string? Error)
|
|
||||||
{
|
|
||||||
public static PersonnelAccountResult Succeeded(Guid userId, string userName) =>
|
|
||||||
new(true, userId, userName, null);
|
|
||||||
|
|
||||||
public static PersonnelAccountResult Failed(string error) =>
|
|
||||||
new(false, null, null, error);
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,12 @@ using Jiaowu.Api.Infrastructure.Middleware;
|
|||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -85,7 +87,6 @@ builder.Services.Configure<JwtOptions>(
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<PersonnelAccountService>();
|
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||||
@@ -112,6 +113,20 @@ builder.Services
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy("public-auth", context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 10,
|
||||||
|
Window = TimeSpan.FromMinutes(5),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -212,6 +227,7 @@ app.UseStaticFiles(new StaticFileOptions
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.UseCors("Web");
|
app.UseCors("Web");
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseMiddleware<AuditMiddleware>();
|
app.UseMiddleware<AuditMiddleware>();
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/TimetableView.vue'),
|
component: () => import('../views/TimetableView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/activate',
|
||||||
|
name: 'activate-account',
|
||||||
|
component: () => import('../views/AccountActivationView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
|
|||||||
@@ -939,6 +939,7 @@ button { cursor: pointer; }
|
|||||||
.login-form label { display: block; margin-bottom: 20px; }
|
.login-form label { display: block; margin-bottom: 20px; }
|
||||||
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
|
.login-form label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; }
|
||||||
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
||||||
|
.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
|
||||||
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
|
.login-submit { width: 100%; margin-top: 6px; height: 46px; }
|
||||||
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
|
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const colleges = ref<any[]>([])
|
||||||
|
const majors = ref<any[]>([])
|
||||||
|
const classes = ref<any[]>([])
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
studentNumber: '',
|
||||||
|
collegeId: '',
|
||||||
|
majorId: '',
|
||||||
|
grade: undefined as number | undefined,
|
||||||
|
administrativeClassId: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableMajors = computed(() =>
|
||||||
|
majors.value.filter((item) => item.collegeId === form.collegeId),
|
||||||
|
)
|
||||||
|
const availableGrades = computed(() => {
|
||||||
|
const values = classes.value
|
||||||
|
.filter((item) => item.majorId === form.majorId)
|
||||||
|
.map((item) => Number(item.grade))
|
||||||
|
return [...new Set(values)].sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
const availableClasses = computed(() =>
|
||||||
|
classes.value.filter((item) =>
|
||||||
|
item.majorId === form.majorId && Number(item.grade) === form.grade,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => form.collegeId, () => {
|
||||||
|
form.majorId = ''
|
||||||
|
form.grade = undefined
|
||||||
|
form.administrativeClassId = ''
|
||||||
|
})
|
||||||
|
watch(() => form.majorId, () => {
|
||||||
|
form.grade = undefined
|
||||||
|
form.administrativeClassId = ''
|
||||||
|
})
|
||||||
|
watch(() => form.grade, () => {
|
||||||
|
form.administrativeClassId = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.studentNumber.trim() ||
|
||||||
|
!form.collegeId ||
|
||||||
|
!form.majorId ||
|
||||||
|
!form.grade ||
|
||||||
|
!form.administrativeClassId
|
||||||
|
) {
|
||||||
|
ElMessage.warning('请完整填写学生身份信息。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.password.length < 8 ||
|
||||||
|
!/[a-z]/.test(form.password) ||
|
||||||
|
!/[A-Z]/.test(form.password) ||
|
||||||
|
!/\d/.test(form.password) ||
|
||||||
|
!/[^A-Za-z0-9]/.test(form.password)
|
||||||
|
) {
|
||||||
|
ElMessage.warning('密码至少 8 位,并同时包含大小写字母、数字和特殊字符。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
ElMessage.warning('两次输入的密码不一致。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate() {
|
||||||
|
if (!validate()) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/auth/activate-student', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
studentNumber: form.studentNumber.trim(),
|
||||||
|
collegeId: form.collegeId,
|
||||||
|
majorId: form.majorId,
|
||||||
|
grade: form.grade,
|
||||||
|
administrativeClassId: form.administrativeClassId,
|
||||||
|
password: form.password,
|
||||||
|
})
|
||||||
|
ElMessage.success('账号激活成功,请使用学号和新密码登录。')
|
||||||
|
await router.replace({
|
||||||
|
name: 'login',
|
||||||
|
query: { userName: data.userName, activated: '1' },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/auth/activation-options')
|
||||||
|
colleges.value = data.colleges
|
||||||
|
majors.value = data.majors
|
||||||
|
classes.value = data.classes
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="activation-page">
|
||||||
|
<header class="activation-header">
|
||||||
|
<router-link to="/login">明序教务</router-link>
|
||||||
|
<router-link to="/timetable">班级课表查询</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="activation-card" v-loading="loading">
|
||||||
|
<div class="activation-intro">
|
||||||
|
<span>STUDENT ACCOUNT ACTIVATION</span>
|
||||||
|
<h1>学生账号自助激活</h1>
|
||||||
|
<p>请按学籍档案如实填写。全部信息匹配后,学号将成为登录账号。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="每个学号只能激活一次。已激活或忘记密码的账号,请联系教务管理员处理。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form label-position="top" class="activation-form" @submit.prevent="activate">
|
||||||
|
<div class="activation-grid">
|
||||||
|
<el-form-item label="姓名" required>
|
||||||
|
<el-input v-model="form.name" autocomplete="name" placeholder="与学籍档案一致" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="学号" required>
|
||||||
|
<el-input v-model="form.studentNumber" autocomplete="username" placeholder="完整学号" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="activation-grid">
|
||||||
|
<el-form-item label="学院" required>
|
||||||
|
<el-select v-model="form.collegeId" filterable placeholder="选择学院">
|
||||||
|
<el-option
|
||||||
|
v-for="item in colleges"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="专业" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.majorId"
|
||||||
|
filterable
|
||||||
|
:disabled="!form.collegeId"
|
||||||
|
placeholder="选择专业"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in availableMajors"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="activation-grid">
|
||||||
|
<el-form-item label="年级" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.grade"
|
||||||
|
:disabled="!form.majorId"
|
||||||
|
placeholder="选择年级"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="grade in availableGrades"
|
||||||
|
:key="grade"
|
||||||
|
:label="`${grade} 级`"
|
||||||
|
:value="grade"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="行政班" required>
|
||||||
|
<el-select
|
||||||
|
v-model="form.administrativeClassId"
|
||||||
|
filterable
|
||||||
|
:disabled="!form.grade"
|
||||||
|
placeholder="选择行政班"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in availableClasses"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="activation-grid">
|
||||||
|
<el-form-item label="设置密码" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
@keyup.enter="activate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<p class="password-rule">至少 8 位,须包含大写字母、小写字母、数字和特殊字符。</p>
|
||||||
|
<el-button
|
||||||
|
class="activation-submit"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
native-type="submit"
|
||||||
|
:loading="submitting"
|
||||||
|
>
|
||||||
|
核验信息并激活
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
已有账号?
|
||||||
|
<router-link to="/login">返回登录</router-link>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activation-page { min-height: 100vh; padding: 0 24px 48px; background: #eef3f6; color: #17324d; }
|
||||||
|
.activation-header { max-width: 920px; height: 68px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #d7e0e7; }
|
||||||
|
.activation-header a { color: #17324d; font-weight: 700; text-decoration: none; }
|
||||||
|
.activation-header a:last-child { color: #176b87; font-size: 13px; }
|
||||||
|
.activation-card { max-width: 760px; margin: 44px auto 0; padding: 36px 42px; background: #fff; border: 1px solid #d7e0e7; box-shadow: 0 14px 32px rgba(23, 50, 77, .08); }
|
||||||
|
.activation-intro { margin-bottom: 22px; }
|
||||||
|
.activation-intro span { color: #176b87; font-size: 11px; font-weight: 750; letter-spacing: .12em; }
|
||||||
|
.activation-intro h1 { margin: 8px 0; font-size: 28px; }
|
||||||
|
.activation-intro p { margin: 0; color: #687b8c; }
|
||||||
|
.activation-form { margin-top: 24px; }
|
||||||
|
.activation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
.activation-form :deep(.el-select) { width: 100%; }
|
||||||
|
.password-rule { margin: -4px 0 18px; color: #738596; font-size: 12px; }
|
||||||
|
.activation-submit { width: 100%; }
|
||||||
|
footer { margin-top: 22px; color: #718191; font-size: 13px; text-align: center; }
|
||||||
|
footer a { color: #176b87; font-weight: 700; text-decoration: none; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.activation-page { padding: 0 14px 28px; }
|
||||||
|
.activation-card { margin-top: 22px; padding: 26px 20px; }
|
||||||
|
.activation-grid { grid-template-columns: 1fr; gap: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,7 +8,10 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const form = reactive({ userName: 'admin', password: 'Admin@123456' })
|
const form = reactive({
|
||||||
|
userName: String(route.query.userName ?? 'admin'),
|
||||||
|
password: route.query.activated ? '' : 'Admin@123456',
|
||||||
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -80,6 +83,7 @@ async function submit() {
|
|||||||
进入工作台
|
进入工作台
|
||||||
</el-button>
|
</el-button>
|
||||||
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</router-link>
|
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</router-link>
|
||||||
|
<router-link class="account-activation-link" to="/activate">学生首次登录?自助激活账号 →</router-link>
|
||||||
<div class="dev-hint">
|
<div class="dev-hint">
|
||||||
<b>本地开发账号</b>
|
<b>本地开发账号</b>
|
||||||
<span>admin / Admin@123456</span>
|
<span>admin / Admin@123456</span>
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ function resetForm(row?: any) {
|
|||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
initialPassword: '',
|
|
||||||
}, row ?? {})
|
}, row ?? {})
|
||||||
} else {
|
} else {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
@@ -129,7 +128,6 @@ function resetForm(row?: any) {
|
|||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
initialPassword: '',
|
|
||||||
}, row ?? {})
|
}, row ?? {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,22 +266,11 @@ async function save() {
|
|||||||
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
|
ElMessage.warning(`请填写${numberLabel.value}和姓名。`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ((!editingId.value || !form.userId) && String(form.initialPassword ?? '').length < 8) {
|
|
||||||
ElMessage.warning('请设置至少 8 位初始密码,系统会同时创建同号登录账号。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const path = `/personnel/${active.value}`
|
const path = `/personnel/${active.value}`
|
||||||
const payload = { ...form }
|
if (editingId.value) await http.put(`${path}/${editingId.value}`, form)
|
||||||
if (editingId.value && form.userId) {
|
else await http.post(path, form)
|
||||||
delete payload.initialPassword
|
ElMessage.success(editingId.value ? '档案已更新' : '档案已建立')
|
||||||
}
|
|
||||||
if (editingId.value) {
|
|
||||||
await http.put(`${path}/${editingId.value}`, payload)
|
|
||||||
} else {
|
|
||||||
await http.post(path, payload)
|
|
||||||
}
|
|
||||||
ElMessage.success(editingId.value ? '档案与登录账号已同步' : '档案和同号登录账号已建立')
|
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
await load()
|
await load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -423,7 +410,7 @@ watch(active, async () => {
|
|||||||
<el-table-column label="登录账号" width="105">
|
<el-table-column label="登录账号" width="105">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="table-status" :class="{ off: !row.userId }">
|
<span class="table-status" :class="{ off: !row.userId }">
|
||||||
{{ row.userId ? '已开通' : '未开通' }}
|
{{ row.userId ? '已激活' : '未激活' }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -521,23 +508,6 @@ watch(active, async () => {
|
|||||||
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
|
<el-form-item label="电子邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="3" /></el-form-item>
|
||||||
<el-alert
|
|
||||||
v-if="!editingId || !form.userId"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
:title="editingId
|
|
||||||
? '该历史档案尚未开通账号,本次保存会以工号或学号补建登录账号。'
|
|
||||||
: '保存档案后,系统会以工号或学号作为登录账号,并自动分配对应角色。'"
|
|
||||||
/>
|
|
||||||
<el-form-item v-if="!editingId || !form.userId" label="初始密码" required>
|
|
||||||
<el-input
|
|
||||||
v-model="form.initialPassword"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="至少 8 位,仅用于创建密码哈希"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
|||||||
Reference in New Issue
Block a user