- README.md: 修改数据源描述,明确默认模板随程序发布,并更新操作流程中的按钮名称。 - ExcelService.cs: 重命名模板生成方法为 CopyTemplate,简化模板导出逻辑。 - MainForm.cs: 更新按钮文本和事件处理,改为导出默认模板。 - Config.cs 和 config.json: 添加 OriginalEstimateRef 字段以支持新功能。 - 删除不再使用的 build_template.ps1 脚本及相关模板部件。
174 lines
7.3 KiB
C#
174 lines
7.3 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Data;
|
||
using System.IO;
|
||
using System.Linq;
|
||
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 目录后原样导出。
|
||
/// 列: 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>把随程序发布的默认模板原样复制到目标路径。</summary>
|
||
public static void CopyTemplate(string path)
|
||
{
|
||
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
|
||
if (!File.Exists(src))
|
||
throw new FileNotFoundException("未找到默认模板文件: " + src, src);
|
||
File.Copy(src, path, true);
|
||
}
|
||
|
||
// --------------------------------------------------------- 读取数据
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|