Files
TFSImport/TfsExcelImporter/ExcelService.cs
T
chenshoulin 14f5e32262 feat: 增强数据导入功能与用户界面优化
- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。
- 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。
- 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。
- 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。
- 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
2026-11-02 10:40:52 +08:00

290 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}