Files
TFSImport/TfsExcelImporter/ExcelService.cs
T
chenshoulin d055b415fa feat: 增强便携版支持与配置管理
- 更新 .gitignore 文件,添加便携版分发目录和 Fody 生成的配置文件。
- 修改 README.md,详细说明便携版的构建与分发流程,强调单文件分发的优势。
- 在 Config.cs 中实现便携配置管理,优先使用 exe 同目录的 config.json,提升用户体验。
- 更新 ExcelService.cs,优化默认模板的导出逻辑,支持现场替换模板文件。
- 在 MainForm.cs 中加载应用图标,提升界面一致性。
- 调整 Program.cs,确保在单文件分发时正确加载 native DLL。
2026-11-01 20:17:56 +08:00

195 lines
8.4 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 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 同目录的同名文件(可由现场替换),
/// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。
/// </summary>
public static void CopyTemplate(string path)
{
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
if (File.Exists(src))
{
File.Copy(src, path, true);
return;
}
var asm = typeof(ExcelService).Assembly;
// 嵌入名取决于 Link 名:默认 <RootNamespace>.<文件名>
var name = asm.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase));
if (name == null)
throw new FileNotFoundException(
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
using (var input = asm.GetManifestResourceStream(name))
{
if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src);
using (var output = File.Create(path))
input.CopyTo(output);
}
}
// --------------------------------------------------------- 读取数据
/// <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;
}
}
}