using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using ClosedXML.Excel; namespace TfsExcelImporter { /// 一行待导入的工作项数据(列顺序与 Excel 模板一致)。 public class WorkItemRow { public string Title { get; set; } public string State { get; set; } public string AssignedTo { get; set; } public string FinancialProject { get; set; } public string Completed { get; set; } public string LogDate { get; set; } public string PbiId { get; set; } public string Description { get; set; } } /// /// Excel 读取与默认模板。模板内容(列宽、示例行、样式)只在 /// templates\TFS导入模板.xlsx 里维护,构建时作为嵌入资源打进 exe; /// 若 exe 同目录存在同名文件则优先用它(现场可替换模板而不用重编译)。 /// 列: Title | State | Assigned To | FinancialProject | Completed | LogDate | PBI Id | Description /// public static class ExcelService { public const string TemplateFileName = "TFS导入模板.xlsx"; public static readonly string[] Headers = { "Title", "State", "Assigned To", "FinancialProject", "Completed", "LogDate", "PBI Id", "Description" }; private static readonly string[] DateFormats = { "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm", "yyyy-MM-dd", "yyyy/MM/dd" }; // --------------------------------------------------------- 默认模板 /// /// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换), /// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。 /// public static void CopyTemplate(string path) { var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); if (File.Exists(src)) { File.Copy(src, path, true); return; } var asm = typeof(ExcelService).Assembly; // 嵌入名取决于 Link 名:默认 .<文件名> var name = asm.GetManifestResourceNames() .FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase)); if (name == null) throw new FileNotFoundException( "未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src); using (var input = asm.GetManifestResourceStream(name)) { if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src); using (var output = File.Create(path)) input.CopyTo(output); } } // --------------------------------------------------------- 读取数据 /// 读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。 public static List ReadRows(string path) { var rows = new List(); using (var wb = new XLWorkbook(path)) { var ws = wb.Worksheets.First(); var range = ws.RangeUsed(); if (range == null) return rows; // 按表头名定位列(顺序不限),找不到的列按 Headers 顺序兜底 var headerMap = MapHeaders(ws, range); for (int r = range.FirstRow().RowNumber() + 1; r <= range.LastRow().RowNumber(); r++) { var title = CellText(ws, r, headerMap, "Title"); if (string.IsNullOrWhiteSpace(title)) continue; rows.Add(new WorkItemRow { Title = title.Trim(), State = CellText(ws, r, headerMap, "State"), AssignedTo = CellText(ws, r, headerMap, "Assigned To"), FinancialProject = CellText(ws, r, headerMap, "FinancialProject"), Completed = CellText(ws, r, headerMap, "Completed"), LogDate = CellText(ws, r, headerMap, "LogDate"), PbiId = CellText(ws, r, headerMap, "PBI Id"), Description = CellText(ws, r, headerMap, "Description") }); } } return rows; } private static Dictionary MapHeaders(IXLWorksheet ws, IXLRange range) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var firstRow = range.FirstRow().RowNumber(); int lastCol = range.LastColumn().ColumnNumber(); for (int c = 1; c <= lastCol; c++) { var text = ws.Cell(firstRow, c).GetString().Trim(); if (text.Length == 0) continue; // 归一化:去掉空格/下划线,兼容 "Assigned To" / "AssignedTo" / "PBI Id" 等 var key = text.Replace(" ", "").Replace("_", ""); if (!map.ContainsKey(key)) map[key] = c; } // 兜底:模板标准列名 foreach (var h in Headers) { var key = h.Replace(" ", ""); if (map.ContainsKey(key)) continue; int idx = Array.IndexOf(Headers, h) + 1; if (idx > lastCol) continue; // 该列已有别的表头占用(如旧版模板的 Created Date 列)时不套用位置,避免读错列 bool taken = map.Values.Any(c => c == idx); if (!taken) map[key] = idx; } return map; } private static string CellText(IXLWorksheet ws, int row, Dictionary map, string header) { int col; if (!map.TryGetValue(header.Replace(" ", ""), out col)) return ""; var cell = ws.Cell(row, col); if (cell.IsEmpty()) return ""; if (cell.DataType == XLDataType.DateTime) return cell.GetDateTime().ToString("yyyy-MM-dd HH:mm:ss"); if (cell.DataType == XLDataType.Number) { double d; if (double.TryParse(cell.GetString(), out d)) { // 处理 Excel 日期序列号(如 46300.0) if (d > 20000 && d < 80000 && Math.Abs(d - Math.Floor(d)) < 0.001) { try { return DateTime.FromOADate(d).ToString("yyyy-MM-dd HH:mm:ss"); } catch { } } return d.ToString("0.####"); } } return cell.GetString().Trim(); } // --------------------------------------------------------- 时间转换 /// 把单元格文本转成 TFS 接受的 ISO 时间;空值返回 null;解析失败抛异常。 public static string ToIsoDateTime(string value) { if (string.IsNullOrWhiteSpace(value)) return null; value = value.Trim(); foreach (var fmt in DateFormats) { DateTime dt; if (DateTime.TryParseExact(value, fmt, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out dt)) return dt.ToString("yyyy-MM-ddTHH:mm:ss"); } DateTime any; if (DateTime.TryParse(value, out any)) return any.ToString("yyyy-MM-ddTHH:mm:ss"); throw new FormatException("无法解析时间: " + value); } /// Completed 数值:整数去小数点,非法值返回 null。 public static object ToNumber(string value) { if (string.IsNullOrWhiteSpace(value)) return null; double d; if (!double.TryParse(value.Trim(), out d)) throw new FormatException("无法解析数值: " + value); return Math.Abs(d - Math.Floor(d)) < 0.0001 ? (object)(int)Math.Floor(d) : d; } } }