- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。 - 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。 - 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。 - 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。 - 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
290 lines
12 KiB
C#
290 lines
12 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Data;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Security.Cryptography;
|
||
using ClosedXML.Excel;
|
||
|
||
namespace TfsExcelImporter
|
||
{
|
||
/// <summary>一行待导入的工作项数据(列顺序与 Excel 模板一致)。</summary>
|
||
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; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Excel 读取与默认模板。模板内容(列宽、示例行、样式)只在
|
||
/// templates\TFS导入模板.xlsx 里维护,构建时作为嵌入资源打进 exe;
|
||
/// 若 exe 同目录存在同名文件则优先用它(现场可替换模板而不用重编译)。
|
||
/// 列: Title | State | Assigned To | FinancialProject | Completed | LogDate | PBI Id | Description
|
||
/// </summary>
|
||
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"
|
||
};
|
||
|
||
// --------------------------------------------------------- 默认模板
|
||
|
||
/// <summary>exe 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。</summary>
|
||
public static string LocalTemplatePath
|
||
{
|
||
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); }
|
||
}
|
||
|
||
/// <summary>
|
||
/// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。
|
||
/// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。
|
||
/// </summary>
|
||
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; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。
|
||
/// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。
|
||
///
|
||
/// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。
|
||
/// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。
|
||
/// </summary>
|
||
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 名:默认 <RootNamespace>.<文件名>
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。</summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。</summary>
|
||
public static int CountDataRows(string path)
|
||
{
|
||
try { return ReadRows(path).Count; }
|
||
catch { return -1; }
|
||
}
|
||
|
||
// --------------------------------------------------------- 读取数据
|
||
|
||
/// <summary>读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。</summary>
|
||
public static List<WorkItemRow> ReadRows(string path)
|
||
{
|
||
var rows = new List<WorkItemRow>();
|
||
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<string, int> MapHeaders(IXLWorksheet ws, IXLRange range)
|
||
{
|
||
var map = new Dictionary<string, int>(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<string, int> 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();
|
||
}
|
||
|
||
// --------------------------------------------------------- 时间转换
|
||
|
||
/// <summary>把单元格文本转成 TFS 接受的 ISO 时间;空值返回 null;解析失败抛异常。</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Completed 数值:整数去小数点,非法值返回 null。</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|