已完成“组织与权限”优化:
基础数据拆分为:组织机构、学年学期、教学场所。 人员档案拆分为:教师档案、学生档案。 基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。 导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。 用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。 导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。 保留旧地址重定向,原有入口不会失效。
This commit is contained in:
@@ -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<string, string[]> Headers =
|
||||
new Dictionary<string, string[]>(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<IActionResult> 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<ActionResult<ExcelImportResult>> Import(
|
||||
string kind,
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
||||
IReadOnlyList<ExcelRow> 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<string>();
|
||||
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<IReadOnlyList<IReadOnlyList<object?>>> 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<ExcelImportResult> ImportCampusesAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportCollegesAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportMajorsAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportClassesAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportTermsAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportBuildingsAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportClassroomsAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<object?> 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" => "教室",
|
||||
_ => "基础数据"
|
||||
};
|
||||
}
|
||||
@@ -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<IActionResult> Export(
|
||||
string kind,
|
||||
[FromQuery] PersonnelQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
||||
IReadOnlyList<IReadOnlyList<object?>> 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<ActionResult<ExcelImportResult>> Import(
|
||||
string kind,
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetHeaders(kind, out var headers)) return NotFound();
|
||||
IReadOnlyList<ExcelRow> 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<string>();
|
||||
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<ExcelImportResult> ImportTeachersAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<ExcelImportResult> ImportStudentsAsync(
|
||||
IReadOnlyList<ExcelRow> rows,
|
||||
List<string> 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<Teacher> ApplyTeacherFilters(
|
||||
IQueryable<Teacher> 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<Student> ApplyStudentFilters(
|
||||
IQueryable<Student> 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<Teacher> ApplyTeacherScope(IQueryable<Teacher> 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<Student> ApplyStudentScope(IQueryable<Student> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<object?> 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 => "毕业",
|
||||
_ => "退学"
|
||||
};
|
||||
}
|
||||
@@ -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<ApplicationUser> 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<IActionResult> 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<IReadOnlyList<object?>>();
|
||||
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<ActionResult<ExcelImportResult>> Import(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<ExcelRow> 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<string>();
|
||||
var plans = new List<UserImportPlan>();
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plannedStudents = new HashSet<string>(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<IActionResult> 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<string> 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<string> 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<string> 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<string> 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<object?> 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);
|
||||
@@ -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<string> headers,
|
||||
IEnumerable<IReadOnlyList<object?>> rows,
|
||||
IReadOnlyList<string>? 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<IReadOnlyList<ExcelRow>> ReadAsync(
|
||||
IFormFile file,
|
||||
IReadOnlyCollection<string> 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<string, int>(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<ExcelRow>();
|
||||
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<string, string> 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);
|
||||
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
|
||||
@@ -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<InvalidDataException>(() =>
|
||||
ExcelWorkbookHelper.ReadAsync(
|
||||
file,
|
||||
["登录账号", "角色"],
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("角色", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -49,11 +49,23 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
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: '用户与权限' }),
|
||||
],
|
||||
|
||||
+42
-3
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+16
-2
@@ -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; }
|
||||
|
||||
+116
-10
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Document, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
|
||||
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
|
||||
|
||||
const tabs: { key: Kind; label: string; hint: string }[] = [
|
||||
const allTabs: { key: Kind; label: string; hint: string }[] = [
|
||||
{ key: 'campuses', label: '校区', hint: '学校的物理校区' },
|
||||
{ key: 'colleges', label: '学院', hint: '教学组织单位' },
|
||||
{ key: 'majors', label: '专业', hint: '专业与授予学位' },
|
||||
@@ -18,9 +20,39 @@ const tabs: { key: Kind; label: string; hint: string }[] = [
|
||||
]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const active = ref<Kind>('campuses')
|
||||
const route = useRoute()
|
||||
const groups = {
|
||||
organization: {
|
||||
title: '组织机构',
|
||||
kicker: 'ORGANIZATION DIRECTORY',
|
||||
description: '维护校区、学院、专业与行政班的稳定组织层级。',
|
||||
kinds: ['campuses', 'colleges', 'majors', 'classes'] as Kind[],
|
||||
},
|
||||
terms: {
|
||||
title: '学年学期',
|
||||
kicker: 'ACADEMIC CALENDAR',
|
||||
description: '集中维护教学年度、学期边界与当前运行学期。',
|
||||
kinds: ['terms'] as Kind[],
|
||||
},
|
||||
facilities: {
|
||||
title: '教学场所',
|
||||
kicker: 'TEACHING FACILITIES',
|
||||
description: '按校区和教学楼维护可用于排课的教室资源。',
|
||||
kinds: ['buildings', 'classrooms'] as Kind[],
|
||||
},
|
||||
}
|
||||
const group = computed(() => {
|
||||
const key = String(route.meta.baseGroup ?? 'organization') as keyof typeof groups
|
||||
return groups[key] ?? groups.organization
|
||||
})
|
||||
const tabs = computed(() =>
|
||||
allTabs.filter((tab) => group.value.kinds.includes(tab.key)),
|
||||
)
|
||||
const active = ref<Kind>(group.value.kinds[0])
|
||||
const rows = ref<Row[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const keyword = ref('')
|
||||
@@ -29,7 +61,7 @@ const references = reactive<Record<string, any[]>>({
|
||||
})
|
||||
const form = reactive<Record<string, any>>({})
|
||||
|
||||
const title = computed(() => tabs.find((x) => x.key === active.value)?.label ?? '')
|
||||
const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '')
|
||||
const canManage = computed(() =>
|
||||
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
|
||||
)
|
||||
@@ -81,6 +113,50 @@ async function changeTab() {
|
||||
await load()
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/base-data/${active.value}/template`,
|
||||
`${title.value}导入模板.xlsx`,
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/base-data/${active.value}/export`,
|
||||
`${title.value}.xlsx`,
|
||||
)
|
||||
ElMessage.success(`${title.value}已导出`)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImportFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await importExcel(`/base-data/${active.value}/import`, file)
|
||||
ElMessage.success(`导入完成:新增 ${data.created} 条,更新 ${data.updated} 条`)
|
||||
await Promise.all([load(), loadReferences()])
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = ''
|
||||
resetForm()
|
||||
@@ -130,21 +206,39 @@ async function remove(row: any) {
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadReferences()])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.meta.baseGroup,
|
||||
async () => {
|
||||
active.value = group.value.kinds[0]
|
||||
keyword.value = ''
|
||||
await load()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">MASTER DATA</span>
|
||||
<h2>基础数据</h2>
|
||||
<p>先建立稳定的组织与教学资源编码,后续业务均从这里引用。</p>
|
||||
<span class="section-kicker">{{ group.kicker }}</span>
|
||||
<h2>{{ group.title }}</h2>
|
||||
<p>{{ group.description }}</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">
|
||||
新增{{ title }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增{{ title }}</el-button>
|
||||
</section>
|
||||
|
||||
<section class="data-card">
|
||||
<nav class="data-tabs" aria-label="基础数据分类">
|
||||
<nav
|
||||
v-if="tabs.length > 1"
|
||||
class="data-tabs"
|
||||
:style="{ '--tab-count': tabs.length }"
|
||||
aria-label="基础数据分类"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@@ -160,6 +254,18 @@ onMounted(async () => {
|
||||
<div class="table-toolbar">
|
||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<template v-if="canManage">
|
||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
</template>
|
||||
<span>共 {{ filteredRows.length }} 条</span>
|
||||
</div>
|
||||
|
||||
|
||||
+102
-38
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Document, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
type Registry = 'teachers' | 'students'
|
||||
@@ -13,8 +15,13 @@ interface PageResult {
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const active = ref<Registry>('teachers')
|
||||
const route = useRoute()
|
||||
const active = computed<Registry>(() =>
|
||||
route.meta.registry === 'students' ? 'students' : 'teachers',
|
||||
)
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
@@ -166,8 +173,7 @@ async function loadReferences() {
|
||||
if (scopedCollegeId.value) query.collegeId = scopedCollegeId.value
|
||||
}
|
||||
|
||||
function switchRegistry(kind: Registry) {
|
||||
active.value = kind
|
||||
function resetFilters() {
|
||||
Object.assign(query, {
|
||||
page: 1,
|
||||
keyword: '',
|
||||
@@ -180,17 +186,66 @@ function switchRegistry(kind: Registry) {
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(query, {
|
||||
page: 1,
|
||||
keyword: '',
|
||||
collegeId: scopedCollegeId.value,
|
||||
majorId: undefined,
|
||||
classId: undefined,
|
||||
enrollmentYear: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
load()
|
||||
function exportParams() {
|
||||
const params: Record<string, any> = {
|
||||
keyword: query.keyword || undefined,
|
||||
collegeId: query.collegeId,
|
||||
}
|
||||
if (active.value === 'teachers') params.teacherStatus = query.status
|
||||
else {
|
||||
Object.assign(params, {
|
||||
majorId: query.majorId,
|
||||
classId: query.classId,
|
||||
enrollmentYear: query.enrollmentYear,
|
||||
studentStatus: query.status,
|
||||
})
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/personnel/${active.value}/template`,
|
||||
`${pageTitle.value}导入模板.xlsx`,
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/personnel/${active.value}/export`,
|
||||
`${pageTitle.value}.xlsx`,
|
||||
{ params: exportParams() },
|
||||
)
|
||||
ElMessage.success(`${pageTitle.value}已导出`)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImportFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await importExcel(`/personnel/${active.value}/import`, file)
|
||||
ElMessage.success(`导入完成:新增 ${data.created} 条,更新 ${data.updated} 条`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -241,40 +296,37 @@ async function remove(row: any) {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadReferences(), load()])
|
||||
})
|
||||
|
||||
watch(active, async () => {
|
||||
resetFilters()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-intro registry-intro">
|
||||
<div>
|
||||
<span class="section-kicker">ACADEMIC REGISTRY</span>
|
||||
<h2>人员档案</h2>
|
||||
<p>以学校统一编号管理教师和学生的归属、状态与联系信息。</p>
|
||||
<span class="section-kicker">
|
||||
{{ active === 'teachers' ? 'TEACHING STAFF' : 'STUDENT ROLL' }}
|
||||
</span>
|
||||
<h2>{{ pageTitle }}</h2>
|
||||
<p>
|
||||
{{ active === 'teachers'
|
||||
? '以工号维护教师归属、职称、任职状态与联系信息。'
|
||||
: '以学号维护学生班级归属、入学信息与学籍状态。' }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">
|
||||
新增{{ pageTitle }}
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section class="registry-switch">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: active === 'teachers' }"
|
||||
@click="switchRegistry('teachers')"
|
||||
>
|
||||
<span>TEACHING STAFF</span>
|
||||
<b>教师档案</b>
|
||||
<small>工号、职称与任职状态</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: active === 'students' }"
|
||||
@click="switchRegistry('students')"
|
||||
>
|
||||
<span>STUDENT ROLL</span>
|
||||
<b>学生档案</b>
|
||||
<small>学号、班级与学籍状态</small>
|
||||
</button>
|
||||
<section class="registry-banner" :class="{ student: active === 'students' }">
|
||||
<div>
|
||||
<span>{{ active === 'teachers' ? 'STAFF DIRECTORY' : 'STUDENT DIRECTORY' }}</span>
|
||||
<b>{{ active === 'teachers' ? '工号是教师身份与教学任务的唯一索引' : '学号贯穿学籍、选课、成绩与毕业业务' }}</b>
|
||||
<small>当前筛选结果会原样用于 Excel 导出</small>
|
||||
</div>
|
||||
<div class="registry-total">
|
||||
<span>当前结果</span>
|
||||
<strong>{{ total }}</strong>
|
||||
@@ -312,6 +364,18 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
<template v-if="canManage">
|
||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
</template>
|
||||
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" class="data-table registry-table">
|
||||
|
||||
+131
-4
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { EditPen, Plus, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Document, Download, EditPen, Key, Plus, Search, Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
|
||||
interface UserRow {
|
||||
id: string; userName: string; displayName: string; staffNumber?: string
|
||||
@@ -14,13 +17,18 @@ const users = ref<UserRow[]>([])
|
||||
const roles = ref<Role[]>([])
|
||||
const colleges = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const dialogVisible = ref(false)
|
||||
const roleDialogVisible = ref(false)
|
||||
const passwordDialogVisible = ref(false)
|
||||
const editingUser = ref<UserRow | null>(null)
|
||||
const passwordUser = ref<UserRow | null>(null)
|
||||
const editingRoles = ref<string[]>([])
|
||||
const editingStaffNumber = ref('')
|
||||
const editingCollegeId = ref<string>()
|
||||
const keyword = ref('')
|
||||
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
||||
const form = reactive({
|
||||
userName: '', displayName: '', password: '', staffNumber: '',
|
||||
collegeId: undefined as string | undefined, roles: [] as string[],
|
||||
@@ -48,6 +56,15 @@ const editingScope = computed(() => {
|
||||
)
|
||||
return scopeNames[effective]
|
||||
})
|
||||
const filteredUsers = computed(() => {
|
||||
const value = keyword.value.trim().toLowerCase()
|
||||
if (!value) return users.value
|
||||
return users.value.filter((user) =>
|
||||
[user.userName, user.displayName, user.staffNumber, ...user.roles]
|
||||
.filter(Boolean)
|
||||
.some((item) => String(item).toLowerCase().includes(value)),
|
||||
)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -58,11 +75,51 @@ async function load() {
|
||||
users.value = userRes.data
|
||||
roles.value = roleRes.data
|
||||
colleges.value = collegeRes.data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await downloadApiFile('/users/template', '用户与权限导入模板.xlsx')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
try {
|
||||
await downloadApiFile('/users/export', '用户与权限.xlsx')
|
||||
ElMessage.success('用户与权限已导出')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImportFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImport(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await importExcel('/users/import', file)
|
||||
ElMessage.success(`导入完成:新增 ${data.created} 个,更新 ${data.updated} 个`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
userName: '', displayName: '', password: '', staffNumber: '',
|
||||
@@ -116,6 +173,33 @@ async function saveRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordReset(row: any) {
|
||||
passwordUser.value = row
|
||||
Object.assign(passwordForm, { newPassword: '', confirmPassword: '' })
|
||||
passwordDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
if (!passwordUser.value) return
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
ElMessage.warning('新密码至少需要 8 位。')
|
||||
return
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.put(`/users/${passwordUser.value.id}/password`, {
|
||||
newPassword: passwordForm.newPassword,
|
||||
})
|
||||
ElMessage.success(`已重置 ${passwordUser.value.displayName} 的密码`)
|
||||
passwordDialogVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -133,11 +217,21 @@ onMounted(load)
|
||||
<section class="data-card">
|
||||
<div class="table-toolbar">
|
||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
|
||||
<span>共 {{ users.length }} 个账号</span>
|
||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
<span>共 {{ filteredUsers.length }} / {{ users.length }} 个账号</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="users.filter((x) => !keyword || `${x.userName}${x.displayName}${x.staffNumber}`.includes(keyword))"
|
||||
:data="filteredUsers"
|
||||
>
|
||||
<el-table-column prop="userName" label="账号" min-width="130" />
|
||||
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
||||
@@ -155,11 +249,14 @@ onMounted(load)
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="175">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="EditPen" @click="openRoleEditor(row)">
|
||||
调整角色
|
||||
</el-button>
|
||||
<el-button link type="primary" :icon="Key" @click="openPasswordReset(row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
|
||||
{{ row.isEnabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
@@ -195,6 +292,36 @@ onMounted(load)
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogVisible" title="重置密码" width="460px">
|
||||
<div v-if="passwordUser" class="role-editor-summary">
|
||||
<strong>{{ passwordUser.displayName }}</strong>
|
||||
<span>{{ passwordUser.userName }}</span>
|
||||
</div>
|
||||
<el-alert
|
||||
:closable="false"
|
||||
type="warning"
|
||||
show-icon
|
||||
title="保存后旧密码立即失效,请通过安全渠道通知本人。"
|
||||
/>
|
||||
<el-form label-position="top" class="entity-form password-reset-form">
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="再次输入新密码" required>
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
@keyup.enter="resetPassword"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="resetPassword">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="roleDialogVisible" title="调整角色与数据范围" width="540px">
|
||||
<div v-if="editingUser" class="role-editor-summary">
|
||||
<strong>{{ editingUser.displayName }}</strong>
|
||||
|
||||
Reference in New Issue
Block a user