diff --git a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs new file mode 100644 index 0000000..61c7a53 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs @@ -0,0 +1,587 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Common; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = Administrators)] +[Route("api/base-data")] +public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase +{ + private const string Administrators = + $"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}"; + + private static readonly IReadOnlyDictionary Headers = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["campuses"] = ["编码", "名称", "地址", "排序", "状态"], + ["colleges"] = ["编码", "名称", "简称", "所属校区编码", "排序", "状态"], + ["majors"] = ["编码", "名称", "所属学院编码", "学位类型", "学制", "排序", "状态"], + ["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"], + ["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"], + ["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"], + ["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"] + }; + + [HttpGet("{kind}/template")] + public IActionResult DownloadTemplate(string kind) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + var bytes = ExcelWorkbookHelper.Create( + "数据导入", + headers, + [], + [ + "请勿修改第一行列名,不要合并单元格。", + "编码是唯一标识;编码已存在时更新,不存在时新增。", + "关联字段填写对应数据的编码,请先导入上级基础数据。", + "状态填写“启用”或“停用”;是/否字段填写“是”或“否”。", + "整批数据会先校验,任一行有误时均不会写入。" + ]); + return File(bytes, ExcelWorkbookHelper.ContentType, $"{KindName(kind)}导入模板.xlsx"); + } + + [HttpGet("{kind}/export")] + public async Task Export(string kind, CancellationToken cancellationToken) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + var rows = await GetExportRowsAsync(kind, cancellationToken); + var bytes = ExcelWorkbookHelper.Create(KindName(kind), headers, rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"{KindName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + } + + [HttpPost("{kind}/import")] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task> Import( + string kind, + IFormFile file, + CancellationToken cancellationToken) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + IReadOnlyList rows; + try + { + rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken); + } + catch (InvalidDataException exception) + { + return ValidationProblem(exception.Message); + } + + if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); + var errors = new List(); + ExcelImportResult result; + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + try + { + result = kind.ToLowerInvariant() switch + { + "campuses" => await ImportCampusesAsync(rows, errors, cancellationToken), + "colleges" => await ImportCollegesAsync(rows, errors, cancellationToken), + "majors" => await ImportMajorsAsync(rows, errors, cancellationToken), + "classes" => await ImportClassesAsync(rows, errors, cancellationToken), + "terms" => await ImportTermsAsync(rows, errors, cancellationToken), + "buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken), + "classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken), + _ => throw new InvalidOperationException() + }; + + if (errors.Count > 0) + { + await transaction.RollbackAsync(cancellationToken); + return ImportValidationProblem(errors); + } + + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(result); + } + catch (DbUpdateException) + { + await transaction.RollbackAsync(cancellationToken); + return Conflict(new ProblemDetails + { + Title = "导入失败", + Detail = "存在重复编码或无效关联,未写入任何数据。", + Status = StatusCodes.Status409Conflict + }); + } + } + + private async Task>> GetExportRowsAsync( + string kind, + CancellationToken cancellationToken) => + kind.ToLowerInvariant() switch + { + "campuses" => (await db.Campuses.AsNoTracking().OrderBy(x => x.Code) + .ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.Address, x.SortOrder, Status(x.IsEnabled))).ToList(), + "colleges" => (await db.Colleges.AsNoTracking().Include(x => x.Campus) + .OrderBy(x => x.Code).ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.ShortName, x.Campus?.Code, x.SortOrder, + Status(x.IsEnabled))).ToList(), + "majors" => (await db.Majors.AsNoTracking().Include(x => x.College) + .OrderBy(x => x.Code).ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.College!.Code, x.DegreeType, + x.SchoolingYears, x.SortOrder, Status(x.IsEnabled))).ToList(), + "classes" => (await db.AdministrativeClasses.AsNoTracking().Include(x => x.Major) + .Include(x => x.CounselorUser).OrderBy(x => x.Code).ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.Major!.Code, x.Grade, + x.CounselorUser?.StaffNumber, x.SortOrder, Status(x.IsEnabled))).ToList(), + "terms" => (await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate) + .ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.AcademicYear, SeasonName(x.Season), + x.StartDate, x.EndDate, YesNo(x.IsCurrent), Status(x.IsEnabled))).ToList(), + "buildings" => (await db.Buildings.AsNoTracking().Include(x => x.Campus) + .OrderBy(x => x.Code).ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.Campus!.Code, x.SortOrder, + Status(x.IsEnabled))).ToList(), + "classrooms" => (await db.Classrooms.AsNoTracking().Include(x => x.Building) + .OrderBy(x => x.Code).ToListAsync(cancellationToken)) + .Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType, + x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(), + _ => [] + }; + + private async Task ImportCampusesAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Campuses.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + if (code is null || name is null) continue; + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new Campus { Code = code, Name = name }; + db.Campuses.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.Address = Optional(row, "地址"); + } + return new(created, updated, rows.Count); + } + + private async Task ImportCollegesAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Colleges.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var campuses = await db.Campuses.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + if (code is null || name is null) continue; + Guid? campusId = null; + var campusCode = Optional(row, "所属校区编码"); + if (campusCode is not null) + { + if (!campuses.TryGetValue(campusCode, out var campus)) + errors.Add($"第 {row.RowNumber} 行:所属校区编码“{campusCode}”不存在。"); + else campusId = campus.Id; + } + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new College { Code = code, Name = name }; + db.Colleges.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.ShortName = Optional(row, "简称"); + entity.CampusId = campusId; + } + return new(created, updated, rows.Count); + } + + private async Task ImportMajorsAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Majors.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + var collegeCode = Required(row, "所属学院编码", errors); + var degreeType = Required(row, "学位类型", errors); + var years = ParseInt(row, "学制", 1, 8, errors); + if (code is null || name is null || collegeCode is null || + degreeType is null || years is null) continue; + if (!colleges.TryGetValue(collegeCode, out var college)) + { + errors.Add($"第 {row.RowNumber} 行:所属学院编码“{collegeCode}”不存在。"); + continue; + } + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new Major + { + Code = code, Name = name, CollegeId = college.Id, + DegreeType = degreeType + }; + db.Majors.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.CollegeId = college.Id; + entity.DegreeType = degreeType; + entity.SchoolingYears = years.Value; + } + return new(created, updated, rows.Count); + } + + private async Task ImportClassesAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.AdministrativeClasses.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var majors = await db.Majors.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var counselors = await db.Users.AsNoTracking() + .Where(user => + user.StaffNumber != null && + user.IsEnabled && + db.UserRoles.Any(userRole => + userRole.UserId == user.Id && + db.Roles.Any(role => + role.Id == userRole.RoleId && + role.Name == SystemRoles.Counselor))) + .ToDictionaryAsync(x => x.StaffNumber!, StringComparer.OrdinalIgnoreCase, + cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + var majorCode = Required(row, "所属专业编码", errors); + var grade = ParseInt(row, "年级", 2000, 2200, errors); + if (code is null || name is null || majorCode is null || grade is null) continue; + if (!majors.TryGetValue(majorCode, out var major)) + { + errors.Add($"第 {row.RowNumber} 行:所属专业编码“{majorCode}”不存在。"); + continue; + } + ApplicationUser? counselor = null; + var counselorNumber = Optional(row, "辅导员工号"); + if (counselorNumber is not null && + !counselors.TryGetValue(counselorNumber, out counselor)) + errors.Add($"第 {row.RowNumber} 行:辅导员工号“{counselorNumber}”不存在或已停用。"); + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new AdministrativeClass + { + Code = code, Name = name, MajorId = major.Id, Grade = grade.Value + }; + db.AdministrativeClasses.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.MajorId = major.Id; + entity.Grade = grade.Value; + entity.CounselorUserId = counselor?.Id; + entity.CounselorName = counselor?.DisplayName; + } + return new(created, updated, rows.Count); + } + + private async Task ImportTermsAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.AcademicTerms.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + AcademicTerm? requestedCurrent = null; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + var academicYear = Required(row, "学年", errors); + var season = ParseSeason(row, errors); + var startDate = ParseDate(row, "开始日期", true, errors); + var endDate = ParseDate(row, "结束日期", true, errors); + if (code is null || name is null || academicYear is null || season is null || + startDate is null || endDate is null) continue; + if (endDate <= startDate) + { + errors.Add($"第 {row.RowNumber} 行:结束日期必须晚于开始日期。"); + continue; + } + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new AcademicTerm + { + Code = code, Name = name, AcademicYear = academicYear, + Season = season.Value, StartDate = startDate.Value, + EndDate = endDate.Value + }; + db.AcademicTerms.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + entity.Name = name; + entity.AcademicYear = academicYear; + entity.Season = season.Value; + entity.StartDate = startDate.Value; + entity.EndDate = endDate.Value; + entity.IsEnabled = ParseEnabled(row, errors); + entity.IsCurrent = ParseBoolean(row, "当前学期", false, errors); + if (entity.IsCurrent) + { + if (requestedCurrent is not null) + errors.Add($"第 {row.RowNumber} 行:同一次导入只能设置一个当前学期。"); + requestedCurrent = entity; + } + } + if (requestedCurrent is not null) + { + foreach (var term in await db.AcademicTerms.Where(x => x.Id != requestedCurrent.Id) + .ToListAsync(cancellationToken)) + term.IsCurrent = false; + } + return new(created, updated, rows.Count); + } + + private async Task ImportBuildingsAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Buildings.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var campuses = await db.Campuses.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + var campusCode = Required(row, "所属校区编码", errors); + if (code is null || name is null || campusCode is null) continue; + if (!campuses.TryGetValue(campusCode, out var campus)) + { + errors.Add($"第 {row.RowNumber} 行:所属校区编码“{campusCode}”不存在。"); + continue; + } + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new Building { Code = code, Name = name, CampusId = campus.Id }; + db.Buildings.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.CampusId = campus.Id; + } + return new(created, updated, rows.Count); + } + + private async Task ImportClassroomsAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Classrooms.ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var buildings = await db.Buildings.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var code = Required(row, "编码", errors); + var name = Required(row, "名称", errors); + var buildingCode = Required(row, "所属教学楼编码", errors); + var capacity = ParseInt(row, "容量", 1, 1000, errors); + var roomType = Required(row, "教室类型", errors); + if (code is null || name is null || buildingCode is null || + capacity is null || roomType is null) continue; + if (!buildings.TryGetValue(buildingCode, out var building)) + { + errors.Add($"第 {row.RowNumber} 行:所属教学楼编码“{buildingCode}”不存在。"); + continue; + } + var entity = existing.GetValueOrDefault(code); + if (entity is null) + { + entity = new Classroom + { + Code = code, Name = name, BuildingId = building.Id, + RoomType = roomType + }; + db.Classrooms.Add(entity); + existing[code] = entity; + created++; + } + else updated++; + ApplyCatalog(entity, row, name, errors); + entity.BuildingId = building.Id; + entity.Capacity = capacity.Value; + entity.RoomType = roomType; + entity.Equipment = Optional(row, "设备"); + } + return new(created, updated, rows.Count); + } + + private ActionResult ImportValidationProblem(IReadOnlyList errors) + { + foreach (var error in errors.Take(50)) + ModelState.AddModelError("file", error); + if (errors.Count > 50) + ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。"); + return ValidationProblem(ModelState); + } + + private static void ApplyCatalog( + CatalogEntity entity, + ExcelRow row, + string name, + List errors) + { + entity.Name = name; + entity.SortOrder = string.IsNullOrWhiteSpace(row["排序"]) + ? 0 + : ParseInt(row, "排序", 0, int.MaxValue, errors) ?? 0; + entity.IsEnabled = ParseEnabled(row, errors); + } + + private static string? Required(ExcelRow row, string header, List errors) + { + var value = Optional(row, header); + if (value is not null) return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。"); + return null; + } + + private static string? Optional(ExcelRow row, string header) => + string.IsNullOrWhiteSpace(row[header]) ? null : row[header].Trim(); + + private static int? ParseInt( + ExcelRow row, string header, int minimum, int maximum, List errors) + { + if (int.TryParse(row[header], out var value) && value >= minimum && value <= maximum) + return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。"); + return null; + } + + private static DateOnly? ParseDate( + ExcelRow row, string header, bool required, List errors) + { + var text = row[header]; + if (string.IsNullOrWhiteSpace(text) && !required) return null; + if (DateOnly.TryParse(text, out var value)) return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”必须是有效日期(如 2026-09-01)。"); + return null; + } + + private static bool ParseEnabled(ExcelRow row, List errors) + { + var value = row["状态"]; + if (value is "启用" or "是" or "1" || + value.Equals("true", StringComparison.OrdinalIgnoreCase)) return true; + if (value is "停用" or "否" or "0" || + value.Equals("false", StringComparison.OrdinalIgnoreCase)) return false; + errors.Add($"第 {row.RowNumber} 行:“状态”请填写“启用”或“停用”。"); + return true; + } + + private static bool ParseBoolean( + ExcelRow row, string header, bool defaultValue, List errors) + { + var value = row[header]; + if (string.IsNullOrWhiteSpace(value)) return defaultValue; + if (value is "是" or "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase)) + return true; + if (value is "否" or "0" || value.Equals("false", StringComparison.OrdinalIgnoreCase)) + return false; + errors.Add($"第 {row.RowNumber} 行:“{header}”请填写“是”或“否”。"); + return defaultValue; + } + + private static TermSeason? ParseSeason(ExcelRow row, List errors) + { + var value = row["学期季"]; + if (value is "秋季" or "秋季学期" || + value.Equals("Autumn", StringComparison.OrdinalIgnoreCase)) return TermSeason.Autumn; + if (value is "春季" or "春季学期" || + value.Equals("Spring", StringComparison.OrdinalIgnoreCase)) return TermSeason.Spring; + if (value is "夏季" or "夏季学期" || + value.Equals("Summer", StringComparison.OrdinalIgnoreCase)) return TermSeason.Summer; + errors.Add($"第 {row.RowNumber} 行:“学期季”请填写秋季、春季或夏季。"); + return null; + } + + private static bool TryGetHeaders(string kind, out string[] headers) => + Headers.TryGetValue(kind, out headers!); + + private static IReadOnlyList Row(params object?[] values) => values; + private static string Status(bool enabled) => enabled ? "启用" : "停用"; + private static string YesNo(bool value) => value ? "是" : "否"; + private static string SeasonName(TermSeason season) => season switch + { + TermSeason.Autumn => "秋季", + TermSeason.Spring => "春季", + _ => "夏季" + }; + + private static string KindName(string kind) => kind.ToLowerInvariant() switch + { + "campuses" => "校区", + "colleges" => "学院", + "majors" => "专业", + "classes" => "行政班", + "terms" => "学期", + "buildings" => "教学楼", + "classrooms" => "教室", + _ => "基础数据" + }; +} diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs new file mode 100644 index 0000000..69f8aff --- /dev/null +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -0,0 +1,503 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = ReadRoles)] +[Route("api/personnel")] +public sealed class PersonnelExcelController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string ReadRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Counselor + "," + + SystemRoles.Teacher + "," + + SystemRoles.Student; + + private const string WriteRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + private static readonly string[] TeacherHeaders = + [ + "工号", "姓名", "性别", "学院编码", "职称", "任职状态", + "入职日期", "教师类别", "联系电话", "电子邮箱", "备注" + ]; + + private static readonly string[] StudentHeaders = + [ + "学号", "姓名", "性别", "行政班编码", "入学年级", "入学日期", + "学籍状态", "出生日期", "联系电话", "电子邮箱", "备注" + ]; + + [HttpGet("{kind}/template")] + [Authorize(Roles = WriteRoles)] + public IActionResult DownloadTemplate(string kind) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + var bytes = ExcelWorkbookHelper.Create( + "档案导入", + headers, + [], + [ + "请勿修改第一行列名;每行填写一位人员。", + kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) + ? "工号是唯一标识;学院编码必须已存在。" + : "学号是唯一标识;行政班编码必须已存在。", + "编号已存在时更新档案,不存在时新增档案。", + "日期填写为 yyyy-MM-dd;不适用的可选字段可以留空。", + "整批数据会先校验,任一行有误时均不会写入。" + ]); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"{RegistryName(kind)}导入模板.xlsx"); + } + + [HttpGet("{kind}/export")] + public async Task Export( + string kind, + [FromQuery] PersonnelQuery query, + CancellationToken cancellationToken) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + IReadOnlyList> rows; + if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)) + { + var source = ApplyTeacherFilters( + ApplyTeacherScope(db.Teachers.AsNoTracking()), + query); + rows = (await source.Include(x => x.College).OrderBy(x => x.TeacherNumber) + .ToListAsync(cancellationToken)) + .Select(x => Row( + x.TeacherNumber, x.Name, GenderName(x.Gender), x.College!.Code, + x.Title, TeacherStatusName(x.Status), x.HireDate, + x.IsExternal ? "外聘" : "校内", x.Phone, x.Email, x.Notes)) + .ToList(); + } + else + { + var source = ApplyStudentFilters( + ApplyStudentScope(db.Students.AsNoTracking()), + query); + rows = (await source.Include(x => x.AdministrativeClass) + .OrderBy(x => x.StudentNumber).ToListAsync(cancellationToken)) + .Select(x => Row( + x.StudentNumber, x.Name, GenderName(x.Gender), + x.AdministrativeClass!.Code, x.EnrollmentYear, x.EnrollmentDate, + StudentStatusName(x.Status), x.DateOfBirth, x.Phone, x.Email, x.Notes)) + .ToList(); + } + + var bytes = ExcelWorkbookHelper.Create(RegistryName(kind), headers, rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"{RegistryName(kind)}-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + } + + [HttpPost("{kind}/import")] + [Authorize(Roles = WriteRoles)] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task> Import( + string kind, + IFormFile file, + CancellationToken cancellationToken) + { + if (!TryGetHeaders(kind, out var headers)) return NotFound(); + IReadOnlyList rows; + try + { + rows = await ExcelWorkbookHelper.ReadAsync(file, headers, cancellationToken); + } + catch (InvalidDataException exception) + { + return ValidationProblem(exception.Message); + } + if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); + + var errors = new List(); + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + try + { + var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) + ? await ImportTeachersAsync(rows, errors, cancellationToken) + : await ImportStudentsAsync(rows, errors, cancellationToken); + if (errors.Count > 0) + { + await transaction.RollbackAsync(cancellationToken); + return ImportValidationProblem(errors); + } + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(result); + } + catch (DbUpdateException) + { + await transaction.RollbackAsync(cancellationToken); + return Conflict(new ProblemDetails + { + Title = "导入失败", + Detail = "存在重复编号或无效关联,未写入任何档案。", + Status = StatusCodes.Status409Conflict + }); + } + } + + private async Task ImportTeachersAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Teachers.ToDictionaryAsync(x => x.TeacherNumber, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(x => x.Code, + StringComparer.OrdinalIgnoreCase, cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var number = Required(row, "工号", errors); + var name = Required(row, "姓名", errors); + var collegeCode = Required(row, "学院编码", errors); + var gender = ParseGender(row, errors); + var status = ParseTeacherStatus(row, errors); + var hireDate = ParseDate(row, "入职日期", false, errors); + var isExternal = ParseTeacherType(row, errors); + if (number is null || name is null || collegeCode is null || + gender is null || status is null || isExternal is null) continue; + if (!colleges.TryGetValue(collegeCode, out var college)) + { + errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。"); + continue; + } + if (!CanAccessCollege(college.Id)) + { + errors.Add($"第 {row.RowNumber} 行:无权维护学院“{college.Name}”的教师。"); + continue; + } + var entity = existing.GetValueOrDefault(number); + if (entity is not null && !CanAccessCollege(entity.CollegeId)) + { + errors.Add($"第 {row.RowNumber} 行:工号“{number}”已属于无权维护的学院。"); + continue; + } + if (entity is null) + { + entity = new Teacher + { + TeacherNumber = number, + Name = name, + CollegeId = college.Id + }; + db.Teachers.Add(entity); + existing[number] = entity; + created++; + } + else updated++; + entity.Name = name; + entity.Gender = gender.Value; + entity.CollegeId = college.Id; + entity.Title = Optional(row, "职称"); + entity.Status = status.Value; + entity.HireDate = hireDate; + entity.IsExternal = isExternal.Value; + entity.Phone = Optional(row, "联系电话"); + entity.Email = Optional(row, "电子邮箱"); + entity.Notes = Optional(row, "备注"); + } + return new(created, updated, rows.Count); + } + + private async Task ImportStudentsAsync( + IReadOnlyList rows, + List errors, + CancellationToken cancellationToken) + { + var existing = await db.Students.Include(x => x.AdministrativeClass) + .ThenInclude(x => x!.Major) + .ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, + cancellationToken); + var classes = await db.AdministrativeClasses.AsNoTracking().Include(x => x.Major) + .ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, + cancellationToken); + var created = 0; + var updated = 0; + foreach (var row in rows) + { + var number = Required(row, "学号", errors); + var name = Required(row, "姓名", errors); + var classCode = Required(row, "行政班编码", errors); + var gender = ParseGender(row, errors); + var enrollmentYear = ParseInt(row, "入学年级", 2000, 2200, errors); + var enrollmentDate = ParseDate(row, "入学日期", true, errors); + var status = ParseStudentStatus(row, errors); + var dateOfBirth = ParseDate(row, "出生日期", false, errors); + if (number is null || name is null || classCode is null || gender is null || + enrollmentYear is null || enrollmentDate is null || status is null) continue; + if (!classes.TryGetValue(classCode, out var targetClass)) + { + errors.Add($"第 {row.RowNumber} 行:行政班编码“{classCode}”不存在。"); + continue; + } + var collegeId = targetClass.Major!.CollegeId; + if (!CanAccessCollege(collegeId)) + { + errors.Add($"第 {row.RowNumber} 行:无权维护行政班“{targetClass.Name}”的学生。"); + continue; + } + var entity = existing.GetValueOrDefault(number); + if (entity is not null && + !CanAccessCollege(entity.AdministrativeClass!.Major!.CollegeId)) + { + errors.Add($"第 {row.RowNumber} 行:学号“{number}”已属于无权维护的学院。"); + continue; + } + if (entity is null) + { + entity = new Student + { + StudentNumber = number, + Name = name, + AdministrativeClassId = targetClass.Id, + EnrollmentDate = enrollmentDate.Value + }; + db.Students.Add(entity); + existing[number] = entity; + created++; + } + else updated++; + entity.Name = name; + entity.Gender = gender.Value; + entity.AdministrativeClassId = targetClass.Id; + entity.EnrollmentYear = enrollmentYear.Value; + entity.EnrollmentDate = enrollmentDate.Value; + entity.Status = status.Value; + entity.DateOfBirth = dateOfBirth; + entity.Phone = Optional(row, "联系电话"); + entity.Email = Optional(row, "电子邮箱"); + entity.Notes = Optional(row, "备注"); + } + return new(created, updated, rows.Count); + } + + private IQueryable ApplyTeacherFilters( + IQueryable source, + PersonnelQuery query) + { + if (query.CollegeId.HasValue) + source = source.Where(x => x.CollegeId == query.CollegeId); + if (query.TeacherStatus.HasValue) + source = source.Where(x => x.Status == query.TeacherStatus); + if (!string.IsNullOrWhiteSpace(query.Keyword)) + { + var keyword = query.Keyword.Trim(); + source = source.Where(x => + x.TeacherNumber.Contains(keyword) || x.Name.Contains(keyword) || + (x.Title != null && x.Title.Contains(keyword))); + } + return source; + } + + private IQueryable ApplyStudentFilters( + IQueryable source, + PersonnelQuery query) + { + if (query.CollegeId.HasValue) + source = source.Where(x => + x.AdministrativeClass!.Major!.CollegeId == query.CollegeId); + if (query.MajorId.HasValue) + source = source.Where(x => x.AdministrativeClass!.MajorId == query.MajorId); + if (query.ClassId.HasValue) + source = source.Where(x => x.AdministrativeClassId == query.ClassId); + if (query.EnrollmentYear.HasValue) + source = source.Where(x => x.EnrollmentYear == query.EnrollmentYear); + if (query.StudentStatus.HasValue) + source = source.Where(x => x.Status == query.StudentStatus); + if (!string.IsNullOrWhiteSpace(query.Keyword)) + { + var keyword = query.Keyword.Trim(); + source = source.Where(x => + x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword)); + } + return source; + } + + private IQueryable ApplyTeacherScope(IQueryable source) + { + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); + if (scope.Scope == DataScope.Class) + return source.Where(teacher => db.TeachingTasks.Any(task => + task.Teachers.Any(item => item.TeacherId == teacher.Id) && + task.Classes.Any(item => + item.AdministrativeClass!.CounselorUserId == scope.UserId))); + var userId = scope.UserId; + return scope.IsInRole(SystemRoles.Teacher) + ? source.Where(x => x.UserId == userId) + : source.Where(_ => false); + } + + private IQueryable ApplyStudentScope(IQueryable source) + { + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.All) return source; + if (scope.Scope == DataScope.College) + return source.Where(x => + x.AdministrativeClass!.Major!.CollegeId == scope.RestrictedCollegeId); + if (scope.Scope == DataScope.Class) + return source.Where(x => x.AdministrativeClass!.CounselorUserId == scope.UserId); + var userId = scope.UserId; + var isTeacher = scope.IsInRole(SystemRoles.Teacher); + var isStudent = scope.IsInRole(SystemRoles.Student); + return source.Where(student => + isStudent && student.UserId == userId || + isTeacher && db.TeachingTasks.Any(task => + task.Teachers.Any(item => item.Teacher!.UserId == userId) && + task.Classes.Any(item => + item.AdministrativeClassId == student.AdministrativeClassId))); + } + + private bool CanAccessCollege(Guid collegeId) => + currentUserDataScope.Current.CanAccessCollege(collegeId); + + private ActionResult ImportValidationProblem(IReadOnlyList errors) + { + foreach (var error in errors.Take(50)) + ModelState.AddModelError("file", error); + if (errors.Count > 50) + ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。"); + return ValidationProblem(ModelState); + } + + private static bool TryGetHeaders(string kind, out string[] headers) + { + if (kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)) + { + headers = TeacherHeaders; + return true; + } + if (kind.Equals("students", StringComparison.OrdinalIgnoreCase)) + { + headers = StudentHeaders; + return true; + } + headers = []; + return false; + } + + private static string? Required(ExcelRow row, string header, List errors) + { + var value = Optional(row, header); + if (value is not null) return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。"); + return null; + } + + private static string? Optional(ExcelRow row, string header) => + string.IsNullOrWhiteSpace(row[header]) ? null : row[header].Trim(); + + private static int? ParseInt( + ExcelRow row, string header, int minimum, int maximum, List errors) + { + if (int.TryParse(row[header], out var value) && value >= minimum && value <= maximum) + return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。"); + return null; + } + + private static DateOnly? ParseDate( + ExcelRow row, string header, bool required, List errors) + { + var value = row[header]; + if (string.IsNullOrWhiteSpace(value) && !required) return null; + if (DateOnly.TryParse(value, out var date)) return date; + errors.Add($"第 {row.RowNumber} 行:“{header}”必须是有效日期(如 2026-09-01)。"); + return null; + } + + private static Gender? ParseGender(ExcelRow row, List errors) + { + var value = row["性别"]; + if (string.IsNullOrWhiteSpace(value) || value is "未设置" or "未知" || + value.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) return Gender.Unknown; + if (value == "男" || value.Equals("Male", StringComparison.OrdinalIgnoreCase)) + return Gender.Male; + if (value == "女" || value.Equals("Female", StringComparison.OrdinalIgnoreCase)) + return Gender.Female; + errors.Add($"第 {row.RowNumber} 行:“性别”请填写男、女或未设置。"); + return null; + } + + private static TeacherStatus? ParseTeacherStatus(ExcelRow row, List errors) + { + var value = row["任职状态"]; + if (value == "在职" || value.Equals("Active", StringComparison.OrdinalIgnoreCase)) + return TeacherStatus.Active; + if (value == "离岗" || value.Equals("OnLeave", StringComparison.OrdinalIgnoreCase)) + return TeacherStatus.OnLeave; + if (value == "退休" || value.Equals("Retired", StringComparison.OrdinalIgnoreCase)) + return TeacherStatus.Retired; + if (value == "离职" || value.Equals("Departed", StringComparison.OrdinalIgnoreCase)) + return TeacherStatus.Departed; + errors.Add($"第 {row.RowNumber} 行:“任职状态”请填写在职、离岗、退休或离职。"); + return null; + } + + private static StudentStatus? ParseStudentStatus(ExcelRow row, List errors) + { + var value = row["学籍状态"]; + if (value == "在籍" || value.Equals("Active", StringComparison.OrdinalIgnoreCase)) + return StudentStatus.Active; + if (value == "休学" || value.Equals("Suspended", StringComparison.OrdinalIgnoreCase)) + return StudentStatus.Suspended; + if (value == "毕业" || value.Equals("Graduated", StringComparison.OrdinalIgnoreCase)) + return StudentStatus.Graduated; + if (value == "退学" || value.Equals("Withdrawn", StringComparison.OrdinalIgnoreCase)) + return StudentStatus.Withdrawn; + errors.Add($"第 {row.RowNumber} 行:“学籍状态”请填写在籍、休学、毕业或退学。"); + return null; + } + + private static bool? ParseTeacherType(ExcelRow row, List errors) + { + var value = row["教师类别"]; + if (value == "校内" || value == "否" || value == "0") return false; + if (value == "外聘" || value == "是" || value == "1") return true; + errors.Add($"第 {row.RowNumber} 行:“教师类别”请填写校内或外聘。"); + return null; + } + + private static IReadOnlyList Row(params object?[] values) => values; + private static string RegistryName(string kind) => + kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) ? "教师档案" : "学生档案"; + private static string GenderName(Gender value) => value switch + { + Gender.Male => "男", + Gender.Female => "女", + _ => "未设置" + }; + private static string TeacherStatusName(TeacherStatus value) => value switch + { + TeacherStatus.Active => "在职", + TeacherStatus.OnLeave => "离岗", + TeacherStatus.Retired => "退休", + _ => "离职" + }; + private static string StudentStatusName(StudentStatus value) => value switch + { + StudentStatus.Active => "在籍", + StudentStatus.Suspended => "休学", + StudentStatus.Graduated => "毕业", + _ => "退学" + }; +} diff --git a/src/Jiaowu.Api/Controllers/UsersExcelController.cs b/src/Jiaowu.Api/Controllers/UsersExcelController.cs new file mode 100644 index 0000000..ec85e5c --- /dev/null +++ b/src/Jiaowu.Api/Controllers/UsersExcelController.cs @@ -0,0 +1,340 @@ +using System.Security.Claims; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Excel; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = SystemRoles.SuperAdmin)] +[Route("api/users")] +public sealed class UsersExcelController( + AppDbContext db, + UserManager userManager) : ControllerBase +{ + private static readonly string[] Headers = + [ + "登录账号", "姓名", "初始密码", "工号/学号", "学院编码", "角色", "状态" + ]; + + [HttpGet("template")] + public IActionResult DownloadTemplate() + { + var bytes = ExcelWorkbookHelper.Create( + "账号导入", + Headers, + [], + [ + "登录账号是唯一标识;已存在时更新账号,不存在时创建账号。", + $"角色可填写:{string.Join("、", SystemRoles.All)};多个角色用顿号或逗号分隔。", + "新账号必须填写不少于 8 位的初始密码;更新账号时密码留空表示不修改。", + "教师或学生角色必须填写已存在且未关联其他账号的工号/学号。", + "状态填写“启用”或“停用”;整批校验失败时不会写入。" + ]); + return File(bytes, ExcelWorkbookHelper.ContentType, "用户与权限导入模板.xlsx"); + } + + [HttpGet("export")] + public async Task Export(CancellationToken cancellationToken) + { + var colleges = await db.Colleges.AsNoTracking() + .ToDictionaryAsync(x => x.Id, x => x.Code, cancellationToken); + var users = await userManager.Users.AsNoTracking() + .OrderBy(x => x.UserName).ToListAsync(cancellationToken); + var rows = new List>(); + foreach (var user in users) + { + var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); + var roles = identityUser is null + ? [] + : await userManager.GetRolesAsync(identityUser); + rows.Add(Row( + user.UserName, user.DisplayName, string.Empty, user.StaffNumber, + user.CollegeId.HasValue + ? colleges.GetValueOrDefault(user.CollegeId.Value) + : null, + string.Join("、", roles), + user.IsEnabled ? "启用" : "停用")); + } + var bytes = ExcelWorkbookHelper.Create("用户与权限", Headers, rows); + return File(bytes, ExcelWorkbookHelper.ContentType, + $"用户与权限-{DateTime.Now:yyyyMMdd-HHmm}.xlsx"); + } + + [HttpPost("import")] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task> Import( + IFormFile file, + CancellationToken cancellationToken) + { + IReadOnlyList rows; + try + { + rows = await ExcelWorkbookHelper.ReadAsync(file, Headers, cancellationToken); + } + catch (InvalidDataException exception) + { + return ValidationProblem(exception.Message); + } + if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。"); + + var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync( + x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); + var users = await userManager.Users.ToDictionaryAsync( + x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken); + var teachers = await db.Teachers.ToDictionaryAsync( + x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken); + var students = await db.Students.ToDictionaryAsync( + x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken); + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var errors = new List(); + var plans = new List(); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedTeachers = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedStudents = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + var userName = Required(row, "登录账号", errors); + var displayName = Required(row, "姓名", errors); + if (userName is null || displayName is null) continue; + if (!seenNames.Add(userName)) + { + errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。"); + continue; + } + + var existing = users.GetValueOrDefault(userName); + var password = Optional(row, "初始密码"); + if (existing is null && (password?.Length ?? 0) < 8) + errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。"); + if (password is { Length: > 0 and < 8 }) + errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。"); + + var roleNames = ParseRoles(row, errors); + var enabled = ParseEnabled(row, errors); + var staffNumber = Optional(row, "工号/学号"); + Guid? collegeId = null; + var collegeCode = Optional(row, "学院编码"); + if (collegeCode is not null) + { + if (!colleges.TryGetValue(collegeCode, out var college)) + errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。"); + else collegeId = college.Id; + } + + Teacher? teacher = null; + Student? student = null; + if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) + { + if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher)) + errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。"); + else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id) + errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。"); + else if (!plannedTeachers.Add(staffNumber)) + errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。"); + } + if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase)) + { + if (staffNumber is null || !students.TryGetValue(staffNumber, out student)) + errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。"); + else if (student.UserId.HasValue && student.UserId != existing?.Id) + errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。"); + else if (!plannedStudents.Add(staffNumber)) + errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。"); + } + + if (existing?.Id.ToString() == currentUserId) + { + if (!enabled) + errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。"); + if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) + errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。"); + } + + plans.Add(new UserImportPlan( + row.RowNumber, existing, userName, displayName, password, + staffNumber, collegeId, roleNames, enabled, teacher, student)); + } + + if (errors.Count > 0) return ImportValidationProblem(errors); + + var created = 0; + var updated = 0; + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + foreach (var plan in plans) + { + var user = plan.Existing; + if (user is null) + { + user = new ApplicationUser + { + UserName = plan.UserName, + DisplayName = plan.DisplayName, + StaffNumber = plan.StaffNumber, + CollegeId = plan.CollegeId, + IsEnabled = plan.IsEnabled, + LockoutEnabled = true + }; + var createResult = await userManager.CreateAsync(user, plan.Password!); + if (!createResult.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(createResult, plan.RowNumber); + } + created++; + } + else + { + user.DisplayName = plan.DisplayName; + user.StaffNumber = plan.StaffNumber; + user.CollegeId = plan.CollegeId; + user.IsEnabled = plan.IsEnabled; + var updateResult = await userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(updateResult, plan.RowNumber); + } + if (plan.Password is not null) + { + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var passwordResult = await userManager.ResetPasswordAsync( + user, token, plan.Password); + if (!passwordResult.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(passwordResult, plan.RowNumber); + } + } + updated++; + } + + var existingRoles = await userManager.GetRolesAsync(user); + var removeResult = await userManager.RemoveFromRolesAsync( + user, + existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase)); + if (!removeResult.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(removeResult, plan.RowNumber); + } + var addResult = await userManager.AddToRolesAsync( + user, + plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase)); + if (!addResult.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(addResult, plan.RowNumber); + } + + foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id) + .ToListAsync(cancellationToken)) + if (linked != plan.Teacher) linked.UserId = null; + foreach (var linked in await db.Students.Where(x => x.UserId == user.Id) + .ToListAsync(cancellationToken)) + if (linked != plan.Student) linked.UserId = null; + if (plan.Teacher is not null) plan.Teacher.UserId = user.Id; + if (plan.Student is not null) plan.Student.UserId = user.Id; + await db.SaveChangesAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + return Ok(new ExcelImportResult(created, updated, plans.Count)); + } + + [HttpPut("{id:guid}/password")] + public async Task ResetPassword(Guid id, ResetUserPasswordRequest request) + { + var user = await userManager.FindByIdAsync(id.ToString()); + if (user is null) return NotFound(); + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, request.NewPassword); + return result.Succeeded ? NoContent() : IdentityValidationProblem(result); + } + + private ActionResult ImportValidationProblem(IReadOnlyList errors) + { + foreach (var error in errors.Take(50)) + ModelState.AddModelError("file", error); + if (errors.Count > 50) + ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。"); + return ValidationProblem(ModelState); + } + + private ActionResult IdentityValidationProblem( + IdentityResult result, + int? rowNumber = null) + { + foreach (var error in result.Errors) + ModelState.AddModelError( + "identity", + rowNumber.HasValue + ? $"第 {rowNumber} 行:{error.Description}" + : error.Description); + return ValidationProblem(ModelState); + } + + private static string[] ParseRoles(ExcelRow row, List errors) + { + var roles = row["角色"] + .Split([',', ',', ';', ';', '、'], StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (roles.Length == 0) + errors.Add($"第 {row.RowNumber} 行:“角色”至少填写一个角色。"); + var invalid = roles.Except(SystemRoles.All, StringComparer.OrdinalIgnoreCase).ToArray(); + if (invalid.Length > 0) + errors.Add($"第 {row.RowNumber} 行:无效角色“{string.Join("、", invalid)}”。"); + return roles; + } + + private static bool ParseEnabled(ExcelRow row, List errors) + { + var value = row["状态"]; + if (value is "启用" or "是" or "1" || + value.Equals("true", StringComparison.OrdinalIgnoreCase)) return true; + if (value is "停用" or "否" or "0" || + value.Equals("false", StringComparison.OrdinalIgnoreCase)) return false; + errors.Add($"第 {row.RowNumber} 行:“状态”请填写“启用”或“停用”。"); + return true; + } + + private static string? Required(ExcelRow row, string header, List errors) + { + var value = Optional(row, header); + if (value is not null) return value; + errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。"); + return null; + } + + private static string? Optional(ExcelRow row, string header) => + string.IsNullOrWhiteSpace(row[header]) ? null : row[header].Trim(); + + private static IReadOnlyList Row(params object?[] values) => values; + + private sealed record UserImportPlan( + int RowNumber, + ApplicationUser? Existing, + string UserName, + string DisplayName, + string? Password, + string? StaffNumber, + Guid? CollegeId, + string[] Roles, + bool IsEnabled, + Teacher? Teacher, + Student? Student); +} + +public sealed record ResetUserPasswordRequest( + [System.ComponentModel.DataAnnotations.Required, + System.ComponentModel.DataAnnotations.MinLength(8), + System.ComponentModel.DataAnnotations.MaxLength(100)] + string NewPassword); diff --git a/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs b/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs new file mode 100644 index 0000000..6a85626 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs @@ -0,0 +1,187 @@ +using ClosedXML.Excel; + +namespace Jiaowu.Api.Infrastructure.Excel; + +public static class ExcelWorkbookHelper +{ + public const string ContentType = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + public static byte[] Create( + string sheetName, + IReadOnlyList headers, + IEnumerable> rows, + IReadOnlyList? instructions = null) + { + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add(sheetName); + + for (var column = 0; column < headers.Count; column++) + { + var cell = sheet.Cell(1, column + 1); + cell.Value = headers[column]; + cell.Style.Font.Bold = true; + cell.Style.Font.FontColor = XLColor.White; + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D"); + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + } + + var rowNumber = 2; + foreach (var row in rows) + { + for (var column = 0; column < headers.Count && column < row.Count; column++) + { + SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]); + } + rowNumber++; + } + + sheet.SheetView.FreezeRows(1); + sheet.RangeUsed()?.SetAutoFilter(); + sheet.Columns().AdjustToContents(10, 38); + sheet.Rows(1, Math.Max(1, rowNumber - 1)).Style.Alignment.Vertical = + XLAlignmentVerticalValues.Center; + + if (instructions is { Count: > 0 }) + { + var guide = workbook.Worksheets.Add("填写说明", 1); + guide.Cell("A1").Value = $"{sheetName} Excel 导入说明"; + guide.Cell("A1").Style.Font.Bold = true; + guide.Cell("A1").Style.Font.FontSize = 16; + guide.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D"); + for (var index = 0; index < instructions.Count; index++) + { + guide.Cell(index + 3, 1).Value = $"{index + 1}. {instructions[index]}"; + } + guide.Column(1).Width = 92; + guide.Style.Alignment.WrapText = true; + } + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + public static async Task> ReadAsync( + IFormFile file, + IReadOnlyCollection requiredHeaders, + CancellationToken cancellationToken) + { + if (file.Length == 0) + throw new InvalidDataException("请选择包含数据的 Excel 文件。"); + if (file.Length > 10 * 1024 * 1024) + throw new InvalidDataException("Excel 文件不能超过 10 MB。"); + if (!string.Equals(Path.GetExtension(file.FileName), ".xlsx", + StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("仅支持 .xlsx 格式,请先下载模板填写。"); + + await using var stream = new MemoryStream(); + await file.CopyToAsync(stream, cancellationToken); + stream.Position = 0; + + try + { + using var workbook = new XLWorkbook(stream); + var sheet = workbook.Worksheets + .FirstOrDefault(x => !string.Equals( + x.Name, + "填写说明", + StringComparison.OrdinalIgnoreCase)); + if (sheet is null) + throw new InvalidDataException("Excel 中没有可导入的数据工作表。"); + + var lastColumn = sheet.LastColumnUsed()?.ColumnNumber() ?? 0; + var headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var column = 1; column <= lastColumn; column++) + { + var header = sheet.Cell(1, column).GetString().Trim(); + if (header.Length > 0) headerMap.TryAdd(header, column); + } + + var missing = requiredHeaders.Where(x => !headerMap.ContainsKey(x)).ToArray(); + if (missing.Length > 0) + throw new InvalidDataException($"缺少必需列:{string.Join("、", missing)}。"); + + var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; + if (lastRow > 2001) + throw new InvalidDataException("单次最多导入 2000 条数据。"); + + var result = new List(); + for (var rowNumber = 2; rowNumber <= lastRow; rowNumber++) + { + var values = headerMap.ToDictionary( + pair => pair.Key, + pair => GetCellText(sheet.Cell(rowNumber, pair.Value)), + StringComparer.OrdinalIgnoreCase); + if (values.Values.All(string.IsNullOrWhiteSpace)) continue; + result.Add(new ExcelRow(rowNumber, values)); + } + return result; + } + catch (InvalidDataException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidDataException( + "无法读取 Excel 文件,请确认文件未损坏且使用系统模板填写。", + exception); + } + } + + private static string GetCellText(IXLCell cell) + { + if (cell.DataType == XLDataType.DateTime) + return cell.GetDateTime().ToString("yyyy-MM-dd"); + if (cell.DataType == XLDataType.Number) + return cell.GetDouble().ToString("0.################"); + return cell.GetFormattedString().Trim(); + } + + private static void SetCellValue(IXLCell cell, object? value) + { + switch (value) + { + case null: + cell.Value = string.Empty; + break; + case DateOnly date: + cell.Value = date.ToDateTime(TimeOnly.MinValue); + cell.Style.DateFormat.Format = "yyyy-mm-dd"; + break; + case DateTime dateTime: + cell.Value = dateTime; + cell.Style.DateFormat.Format = "yyyy-mm-dd hh:mm"; + break; + case bool boolean: + cell.Value = boolean ? "是" : "否"; + break; + case int integer: + cell.Value = integer; + break; + case long integer: + cell.Value = integer; + break; + case decimal number: + cell.Value = number; + break; + case double number: + cell.Value = number; + break; + default: + cell.Value = value.ToString() ?? string.Empty; + break; + } + } +} + +public sealed record ExcelRow( + int RowNumber, + IReadOnlyDictionary Values) +{ + public string this[string header] => + Values.TryGetValue(header, out var value) ? value.Trim() : string.Empty; +} + +public sealed record ExcelImportResult(int Created, int Updated, int Total); diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index c2d4ec0..5578891 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Jiaowu.Api.Tests/ExcelWorkbookHelperTests.cs b/tests/Jiaowu.Api.Tests/ExcelWorkbookHelperTests.cs new file mode 100644 index 0000000..0eda5e2 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExcelWorkbookHelperTests.cs @@ -0,0 +1,56 @@ +using Jiaowu.Api.Infrastructure.Excel; +using Microsoft.AspNetCore.Http; + +namespace Jiaowu.Api.Tests; + +public sealed class ExcelWorkbookHelperTests +{ + [Fact] + public async Task Workbook_round_trip_preserves_headers_and_values() + { + var bytes = ExcelWorkbookHelper.Create( + "教师档案", + ["工号", "姓名", "入职日期"], + [ + new object?[] + { + "T001", + "陈老师", + new DateOnly(2026, 9, 1) + } + ], + ["测试说明"]); + await using var stream = new MemoryStream(bytes); + var file = new FormFile(stream, 0, bytes.Length, "file", "teachers.xlsx"); + + var rows = await ExcelWorkbookHelper.ReadAsync( + file, + ["工号", "姓名", "入职日期"], + CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(2, row.RowNumber); + Assert.Equal("T001", row["工号"]); + Assert.Equal("陈老师", row["姓名"]); + Assert.Equal("2026-09-01", row["入职日期"]); + } + + [Fact] + public async Task Workbook_reader_rejects_missing_required_headers() + { + var bytes = ExcelWorkbookHelper.Create( + "账号导入", + ["登录账号"], + [new object?[] { "admin" }]); + await using var stream = new MemoryStream(bytes); + var file = new FormFile(stream, 0, bytes.Length, "file", "users.xlsx"); + + var exception = await Assert.ThrowsAsync(() => + ExcelWorkbookHelper.ReadAsync( + file, + ["登录账号", "角色"], + CancellationToken.None)); + + Assert.Contains("角色", exception.Message); + } +} diff --git a/web/src/api/excel.ts b/web/src/api/excel.ts new file mode 100644 index 0000000..5a253dd --- /dev/null +++ b/web/src/api/excel.ts @@ -0,0 +1,38 @@ +import type { AxiosRequestConfig } from 'axios' +import http from './http' + +function responseFileName(header: string | undefined, fallback: string) { + if (!header) return fallback + const encoded = header.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) return decodeURIComponent(encoded) + const plain = header.match(/filename="?([^";]+)"?/i)?.[1] + return plain || fallback +} + +export async function downloadApiFile( + path: string, + fallbackName: string, + config?: AxiosRequestConfig, +) { + const response = await http.get(path, { + ...config, + responseType: 'blob', + }) + const url = URL.createObjectURL(response.data) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = responseFileName(response.headers['content-disposition'], fallbackName) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} + +export async function importExcel(path: string, file: File) { + const form = new FormData() + form.append('file', file) + return http.post(path, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }) +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 321b22f..135c44a 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -49,11 +49,23 @@ const navigationGroups = computed(() => [ items: [ ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin']), - { path: '/base-data', label: '基础数据' }, + { path: '/base-data/organization', label: '组织机构' }, + ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin']), + { path: '/base-data/terms', label: '学年学期' }, + ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin']), + { path: '/base-data/facilities', label: '教学场所' }, ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor']), - { path: '/personnel', label: '人员档案' }, + { path: '/teachers', label: '教师档案' }, + ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor']), + { path: '/students', label: '学生档案' }, ), ...whenVisible(auth.isSuperAdmin, { path: '/users', label: '用户与权限' }), ], diff --git a/web/src/router/index.ts b/web/src/router/index.ts index b3c7a72..ef0e363 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -23,16 +23,55 @@ const router = createRouter({ }, { path: 'base-data', - name: 'base-data', + redirect: '/base-data/organization', + }, + { + path: 'base-data/organization', + name: 'organization-data', component: () => import('../views/BaseDataView.vue'), - meta: { roles: ['SuperAdmin', 'AcademicAdmin'] }, + meta: { + roles: ['SuperAdmin', 'AcademicAdmin'], + baseGroup: 'organization', + }, + }, + { + path: 'base-data/terms', + name: 'term-data', + component: () => import('../views/BaseDataView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin'], + baseGroup: 'terms', + }, + }, + { + path: 'base-data/facilities', + name: 'facility-data', + component: () => import('../views/BaseDataView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin'], + baseGroup: 'facilities', + }, }, { path: 'personnel', - name: 'personnel', + redirect: '/teachers', + }, + { + path: 'teachers', + name: 'teachers', component: () => import('../views/PersonnelView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'], + registry: 'teachers', + }, + }, + { + path: 'students', + name: 'students', + component: () => import('../views/PersonnelView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'], + registry: 'students', }, }, { diff --git a/web/src/style.css b/web/src/style.css index ac88ef0..905492b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -169,15 +169,16 @@ button { cursor: pointer; } .page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } .page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; } .page-intro p { margin: 0; color: var(--muted); font-size: 13px; } +.page-actions { display: flex; align-items: center; gap: 8px; } .data-card { border: 1px solid var(--line); background: white; overflow: hidden; } -.data-tabs { display: grid; grid-template-columns: repeat(7, minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; } +.data-tabs { display: grid; grid-template-columns: repeat(var(--tab-count, 7), minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; } .data-tabs button { min-width: 110px; padding: 16px 15px 14px; text-align: left; border: none; border-right: 1px solid #eaedf1; border-bottom: 3px solid transparent; color: var(--ink); background: #fafbfc; } .data-tabs button:hover { background: white; } .data-tabs button.active { border-bottom-color: var(--teal); background: white; } .data-tabs b, .data-tabs span { display: block; } .data-tabs b { font-size: 13px; } .data-tabs span { margin-top: 4px; color: #9299a7; font-size: 10px; white-space: nowrap; } -.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); } +.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); } .table-toolbar .el-input { width: min(340px, 60vw); } .table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; } .data-table { min-height: 360px; } @@ -198,6 +199,8 @@ button { cursor: pointer; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid.compact { align-items: center; } .form-grid.three { grid-template-columns: repeat(3, 1fr); } +.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.password-reset-form { margin-top: 18px; } .registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; } .registry-switch button { padding: 22px 26px; display: grid; gap: 4px; text-align: left; border: none; border-right: 1px solid var(--line); border-bottom: 3px solid transparent; background: white; } @@ -209,6 +212,14 @@ button { cursor: pointer; } .registry-total { padding: 20px; display: grid; place-content: center; text-align: center; background: var(--indigo-deep); color: white; } .registry-total span, .registry-total small { color: #aeb8d6; font-size: 10px; } .registry-total strong { margin: 3px 0; font: 700 30px/1 Consolas, monospace; color: #59d3c1; } +.registry-banner { min-height: 104px; display: grid; grid-template-columns: 1fr 180px; color: white; border: 1px solid #243d70; background: linear-gradient(105deg, #172858, #29417c 68%, #19736f); overflow: hidden; } +.registry-banner.student { background: linear-gradient(105deg, #172858, #344877 60%, #6d5b91); } +.registry-banner > div:first-child { padding: 22px 27px; display: grid; align-content: center; gap: 6px; position: relative; } +.registry-banner > div:first-child::after { content: ""; position: absolute; right: 26px; top: 18px; width: 70px; height: 70px; opacity: .16; background-image: linear-gradient(rgba(255,255,255,.8) 1px, transparent 1px), linear-gradient(90deg,rgba(255,255,255,.8) 1px,transparent 1px); background-size: 14px 14px; } +.registry-banner > div > span { color: #65dac8; font: 700 9px/1.2 Consolas, monospace; letter-spacing: .14em; } +.registry-banner > div > b { font-family: "STZhongsong", "Songti SC", serif; font-size: 18px; font-weight: 600; } +.registry-banner > div > small { color: #bdc8e1; font-size: 10px; } +.registry-banner .registry-total { border-left: 1px solid rgba(255,255,255,.14); } .filter-bar { min-height: 72px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); background: #fbfcfd; } .filter-bar > .el-input { width: 280px; } .filter-bar > .el-select { width: 150px; } @@ -888,6 +899,9 @@ button { cursor: pointer; } .registry-switch button { padding: 17px; } .registry-total { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; min-height: 54px; } .registry-total strong { margin-left: auto; font-size: 22px; } + .registry-banner { grid-template-columns: 1fr; } + .registry-banner > div:first-child { min-height: 98px; padding: 19px; } + .registry-banner .registry-total { grid-column: auto; min-height: 54px; border-left: none; border-top: 1px solid rgba(255,255,255,.14); } .filter-bar > .el-input { width: 100%; } .filter-bar > .el-select { flex: 1 1 140px; width: auto; } .course-ledger { min-height: auto; display: block; } diff --git a/web/src/views/BaseDataView.vue b/web/src/views/BaseDataView.vue index c8ecaf2..7fe7a00 100644 --- a/web/src/views/BaseDataView.vue +++ b/web/src/views/BaseDataView.vue @@ -1,13 +1,15 @@