已完成“组织与权限”优化:
基础数据拆分为:组织机构、学年学期、教学场所。 人员档案拆分为:教师档案、学生档案。 基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。 导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。 用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。 导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。 保留旧地址重定向,原有入口不会失效。
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user