已完成“组织与权限”优化:

基础数据拆分为:组织机构、学年学期、教学场所。
人员档案拆分为:教师档案、学生档案。
基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。
导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。
用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。
导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。
保留旧地址重定向,原有入口不会失效。
This commit is contained in:
2026-07-24 18:12:56 +08:00 Unverified
parent 662aa1051f
commit 022e79c48d
13 changed files with 2133 additions and 59 deletions
@@ -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);