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;
}
}
}