using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Security.Cryptography; 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 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。 public static string LocalTemplatePath { get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); } } /// /// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。 /// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。 /// public static bool LocalTemplateIsBuiltIn() { var local = LocalTemplatePath; if (!File.Exists(local)) return true; try { var embed = ReadEmbeddedTemplate(); return embed != null && SameDigest(embed, File.ReadAllBytes(local)); } catch { return false; } } /// /// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。 /// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。 /// /// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。 /// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。 /// public static string CopyTemplate(string path, bool forceEmbedded = false) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("导出路径为空", nameof(path)); var dst = Path.GetFullPath(path); var src = LocalTemplatePath; if (!forceEmbedded && File.Exists(src)) { var srcFull = Path.GetFullPath(src); // 目标就是源本身:File.Copy 会抛"源和目标是同一个文件",先给出可读提示 if (string.Equals(srcFull, dst, StringComparison.OrdinalIgnoreCase)) throw new IOException( "导出路径与模板源文件相同(" + srcFull + "),请换一个文件名或目录。"); File.Copy(srcFull, dst, true); AssertSameContent(srcFull, dst); return "exe 同目录 " + TemplateFileName; } var embed = ReadEmbeddedTemplate(); if (embed == null) throw new FileNotFoundException( "未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src); // 先写临时文件再替换:中途失败不会把目标位置已有的文件写坏 var tmp = dst + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { File.WriteAllBytes(tmp, embed); File.Copy(tmp, dst, true); AssertSameContent(tmp, dst); } finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } } return "exe 内嵌模板"; } private static byte[] ReadEmbeddedTemplate() { var asm = typeof(ExcelService).Assembly; // 嵌入名取决于 Link 名:默认 .<文件名> var name = asm.GetManifestResourceNames() .FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase)); if (name == null) return null; using (var input = asm.GetManifestResourceStream(name)) { if (input == null) return null; using (var buffer = new MemoryStream()) { input.CopyTo(buffer); return buffer.ToArray(); } } } /// 写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。 private static void AssertSameContent(string expected, string actual) { var a = new FileInfo(expected); var b = new FileInfo(actual); if (!b.Exists || b.Length != a.Length || !SameDigest(expected, actual)) throw new IOException("模板写出后校验不一致,目标文件内容与模板不同:" + actual); } private static bool SameDigest(string x, string y) { return SameDigest(File.ReadAllBytes(x), File.ReadAllBytes(y)); } private static bool SameDigest(byte[] x, byte[] y) { if (x == null || y == null || x.Length != y.Length) return false; using (var sha = SHA256.Create()) { byte[] hx = sha.ComputeHash(x); byte[] hy = sha.ComputeHash(y); for (int i = 0; i < hx.Length; i++) if (hx[i] != hy[i]) return false; return true; } } /// 数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。 public static int CountDataRows(string path) { try { return ReadRows(path).Count; } catch { return -1; } } // --------------------------------------------------------- 读取数据 /// 读取 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; } } }