chore: 初始化 TFS 2013 工作项批量导入工具集

- TfsExcelImporter: WinForms 桌面版,走 TFS Client OM(本环境 wit REST 路由 404,OM 是唯一可行通道)
- tfs_import.py: REST 版命令行导入器,供 wit 接口可用的 TFS 实例使用
- templates: Excel 导入模板及其 OOXML 源部件
- README: 两套工具定位、配置、环境坑点与验证方式
- 真实台账与本机工作笔记不入库,仅提交 work_items.example.csv 说明列结构
This commit is contained in:
chenshoulin
2026-11-01 11:21:20 +08:00
commit 8d1af0d11c
23 changed files with 2423 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.IO;
using Newtonsoft.Json;
namespace TfsExcelImporter
{
/// <summary>
/// 程序配置。保存于 exe 同目录 config.json,界面修改后自动写回。
/// </summary>
public class AppConfig
{
public string TfsUrl { get; set; } = "http://pdtfs01.dev.goldenstand.cn:8080/tfs/DefaultCollection";
public string Project { get; set; } = "Tools_Dev";
public string WorkItemType { get; set; } = "Task";
// 目标迭代:完整迭代路径,如 Tools_Dev\2026年Sprint\2026年Sprint10
public string IterationPath { get; set; } = "";
// 可选:区域路径
public string AreaPath { get; set; } = "";
// ntlm | basic | default(当前 Windows 凭据)
public string AuthMode { get; set; } = "ntlm";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string Domain { get; set; } = "";
// 是否走 IE 系统代理。内网 TFS 请保持 false(直连);
// 代理/网关常对 HttpClient 请求返回 502(浏览器例外名单直连,.NET 不一定继承)
public bool UseSystemProxy { get; set; } = false;
public bool BypassRules { get; set; } = true;
public bool SkipExistingTitles { get; set; } = true;
public string DefaultState { get; set; } = "To Do";
// 自定义字段引用名;留空则按 DisplayName 自动解析
public string FinancialProjectRef { get; set; } = "";
public string CompletedRef { get; set; } = "";
public string LogDateRef { get; set; } = "";
public string LastExcelPath { get; set; } = "";
// 父 Product Backlog Item:所有 Task 默认挂接的 PBI Id(如 219975);Excel 每行 PBI Id 列可覆盖
public string ParentPbiId { get; set; } = "";
private static string ConfigPath
{
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json"); }
}
public static AppConfig Load()
{
try
{
if (File.Exists(ConfigPath))
{
var cfg = JsonConvert.DeserializeObject<AppConfig>(File.ReadAllText(ConfigPath));
if (cfg != null) return cfg;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("读取 config.json 失败: " + ex.Message);
}
return new AppConfig();
}
public void Save()
{
try
{
File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(this, Formatting.Indented));
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine("写入 config.json 失败: " + ex.Message);
}
}
}
}
+226
View File
@@ -0,0 +1,226 @@
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 CreatedDate { get; set; }
public string ClosedDate { get; set; }
public string PbiId { get; set; }
public string Notes { get; set; }
}
/// <summary>
/// Excel 读取与模板生成。
/// 模板列: Title | State | Assigned To | FinancialProject | Completed | LogDate | Created Date | Closed Date | PBI Id | Notes
/// </summary>
public static class ExcelService
{
public static readonly string[] Headers =
{
"Title", "State", "Assigned To", "FinancialProject",
"Completed", "LogDate", "Created Date", "Closed Date", "PBI Id", "Notes"
};
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"
};
// --------------------------------------------------------- 生成模板
public static void GenerateTemplate(string path)
{
using (var wb = new XLWorkbook())
{
var ws = wb.Worksheets.Add("工作项");
for (int i = 0; i < Headers.Length; i++)
{
var cell = ws.Cell(1, i + 1);
cell.Value = Headers[i];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
}
// 示例行(To Do / Done 各一,帮助理解格式)
var sample1 = new WorkItemRow
{
Title = "20261012 - 示例:货载报告解析并上传凭证受托报告相关内容",
State = "To Do",
AssignedTo = "李晓柯",
FinancialProject = "信托 - 上海信托",
Completed = "5",
LogDate = "2026-10-12",
CreatedDate = "2026-10-30 11:14:41",
ClosedDate = "",
Notes = "示例行,导入前请删除"
};
var sample2 = new WorkItemRow
{
Title = "20261009 示例:受托报告bug修复",
State = "Done",
AssignedTo = "陈卫宇",
FinancialProject = "信托 - 上海信托",
Completed = "8",
LogDate = "2026-10-09",
CreatedDate = "2026-10-10 18:20:00",
ClosedDate = "2026-10-30 18:00:00",
Notes = "示例行,导入前请删除"
};
WriteRow(ws, 2, sample1);
WriteRow(ws, 3, sample2);
ws.Columns().AdjustToContents();
ws.SheetView.FreezeRows(1);
wb.SaveAs(path);
}
}
private static void WriteRow(IXLWorksheet ws, int r, WorkItemRow row)
{
ws.Cell(r, 1).Value = row.Title ?? "";
ws.Cell(r, 2).Value = row.State ?? "";
ws.Cell(r, 3).Value = row.AssignedTo ?? "";
ws.Cell(r, 4).Value = row.FinancialProject ?? "";
ws.Cell(r, 5).Value = row.Completed ?? "";
ws.Cell(r, 6).Value = row.LogDate ?? "";
ws.Cell(r, 7).Value = row.CreatedDate ?? "";
ws.Cell(r, 8).Value = row.ClosedDate ?? "";
ws.Cell(r, 9).Value = row.PbiId ?? "";
ws.Cell(r, 10).Value = row.Notes ?? "";
}
// --------------------------------------------------------- 读取数据
/// <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"),
CreatedDate = CellText(ws, r, headerMap, "Created Date"),
ClosedDate = CellText(ws, r, headerMap, "Closed Date"),
PbiId = CellText(ws, r, headerMap, "PBI Id"),
Notes = CellText(ws, r, headerMap, "Notes")
});
}
}
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" / "Created Date" 等
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))
{
int idx = Array.IndexOf(Headers, h) + 1;
if (idx <= lastCol) 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;
}
}
}
+677
View File
@@ -0,0 +1,677 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace TfsExcelImporter
{
public class MainForm : Form
{
// ------------------------------------------------------------- 控件
private readonly TextBox _txtUrl = new TextBox();
private readonly TextBox _txtProject = new TextBox();
private readonly TextBox _txtType = new TextBox();
private readonly ComboBox _cmbAuth = new ComboBox();
private readonly TextBox _txtUser = new TextBox();
private readonly TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
private readonly TextBox _txtDomain = new TextBox();
private readonly CheckBox _chkProxy = new CheckBox();
private readonly ComboBox _cmbIteration = new ComboBox();
private readonly Button _btnLoadIterations = new Button();
private readonly TextBox _txtExcel = new TextBox();
private readonly Button _btnBrowse = new Button();
private readonly Button _btnTemplate = new Button();
private readonly CheckBox _chkSkip = new CheckBox();
private readonly CheckBox _chkBypass = new CheckBox();
private readonly TextBox _txtParentPbi = new TextBox();
private readonly DataGridView _grid = new DataGridView();
private readonly Button _btnPreview = new Button();
private readonly Button _btnImport = new Button();
private readonly Button _btnFixArea = new Button();
private readonly Button _btnLinkParent = new Button();
private readonly TextBox _txtLog = new TextBox();
private readonly Label _lblStatus = new Label();
private AppConfig _cfg;
private List<WorkItemRow> _rows = new List<WorkItemRow>();
public MainForm()
{
_cfg = AppConfig.Load();
BuildUi();
LoadConfigToUi();
}
// ------------------------------------------------------------- UI 构建
private void BuildUi()
{
Text = "TFS 2013 工作项 Excel 导入工具";
ClientSize = new Size(1060, 720);
MinimumSize = new Size(960, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9F);
int y = 12, left = 12, lblW = 84;
// --- 服务器配置区 ---
var grpServer = new GroupBox
{
Text = "TFS 连接",
Location = new Point(left, y),
Size = new Size(1036, 120),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
Controls.Add(grpServer);
AddLabel(grpServer, "服务器URL:", 12, 24, lblW);
_txtUrl.Location = new Point(100, 22); _txtUrl.Size = new Size(460, 27);
_txtUrl.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpServer.Controls.Add(_txtUrl);
_chkProxy.Text = "使用系统代理";
_chkProxy.Location = new Point(564, 24); _chkProxy.Size = new Size(110, 24);
_chkProxy.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_chkProxy.Checked = false; // 内网 TFS 默认直连
grpServer.Controls.Add(_chkProxy);
AddLabel(grpServer, "团队项目:", 680, 24, lblW);
_txtProject.Location = new Point(768, 22); _txtProject.Size = new Size(150, 27);
_txtProject.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpServer.Controls.Add(_txtProject);
AddLabel(grpServer, "工作项类型:", 12, 58, lblW);
_txtType.Location = new Point(100, 56); _txtType.Size = new Size(220, 27);
grpServer.Controls.Add(_txtType);
AddLabel(grpServer, "认证方式:", 340, 58, lblW);
_cmbAuth.Location = new Point(428, 56); _cmbAuth.Size = new Size(110, 27);
_cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" });
_cmbAuth.SelectedIndexChanged += (s, e) => UpdateAuthUi();
grpServer.Controls.Add(_cmbAuth);
AddLabel(grpServer, "用户:", 556, 58, 44);
_txtUser.Location = new Point(604, 56); _txtUser.Size = new Size(110, 27);
grpServer.Controls.Add(_txtUser);
AddLabel(grpServer, "密码:", 726, 58, 44);
_txtPwd.Location = new Point(774, 56); _txtPwd.Size = new Size(110, 27);
grpServer.Controls.Add(_txtPwd);
AddLabel(grpServer, "域:", 896, 58, 28);
_txtDomain.Location = new Point(928, 56); _txtDomain.Size = new Size(94, 27);
_txtDomain.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpServer.Controls.Add(_txtDomain);
y += 128;
// --- 导入选区 ---
var grpImport = new GroupBox
{
Text = "导入选区",
Location = new Point(left, y),
Size = new Size(1036, 120),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
Controls.Add(grpImport);
AddLabel(grpImport, "目标Iteration:", 12, 26, 96);
_cmbIteration.Location = new Point(112, 23); _cmbIteration.Size = new Size(560, 27);
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填
_cmbIteration.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpImport.Controls.Add(_cmbIteration);
_btnLoadIterations.Text = "加载迭代列表";
_btnLoadIterations.Location = new Point(682, 22); _btnLoadIterations.Size = new Size(110, 29);
_btnLoadIterations.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
grpImport.Controls.Add(_btnLoadIterations);
AddLabel(grpImport, "Excel 文件:", 12, 62, 96);
_txtExcel.Location = new Point(112, 59); _txtExcel.Size = new Size(560, 27);
_txtExcel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpImport.Controls.Add(_txtExcel);
_btnBrowse.Text = "浏览...";
_btnBrowse.Location = new Point(682, 58); _btnBrowse.Size = new Size(70, 29);
_btnBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_btnBrowse.Click += (s, e) => BrowseExcel();
grpImport.Controls.Add(_btnBrowse);
_btnTemplate.Text = "生成Excel模板";
_btnTemplate.Location = new Point(760, 58); _btnTemplate.Size = new Size(110, 29);
_btnTemplate.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_btnTemplate.Click += (s, e) => GenerateTemplate();
grpImport.Controls.Add(_btnTemplate);
_chkSkip.Text = "跳过已存在标题";
_chkSkip.Location = new Point(886, 26); _chkSkip.Size = new Size(130, 24);
_chkSkip.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpImport.Controls.Add(_chkSkip);
_chkBypass.Text = "bypassRules(可写系统日期)";
_chkBypass.Location = new Point(886, 60); _chkBypass.Size = new Size(150, 24);
_chkBypass.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpImport.Controls.Add(_chkBypass);
// 父 PBI:全部 Task 默认挂接的 Product Backlog Item(Excel 每行 PBI Id 列可覆盖)
AddLabel(grpImport, "父PBI Id:", 12, 92, 96);
_txtParentPbi.Location = new Point(112, 89); _txtParentPbi.Size = new Size(200, 27);
grpImport.Controls.Add(_txtParentPbi);
var lblPbiHint = new Label
{
Text = "Task 将挂接为该 Product Backlog Item 的子项;Excel 加 PBI Id 列可逐行指定不同父项",
Location = new Point(320, 92), Size = new Size(560, 22),
TextAlign = ContentAlignment.MiddleLeft,
ForeColor = SystemColors.GrayText
};
grpImport.Controls.Add(lblPbiHint);
y += 128;
// --- 预览表格 ---
_grid.Location = new Point(left, y);
_grid.Size = new Size(1036, 300);
_grid.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
_grid.ReadOnly = true;
_grid.AllowUserToAddRows = false;
_grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
_grid.RowHeadersVisible = false;
_grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
Controls.Add(_grid);
y += 308;
// --- 操作按钮 ---
_btnPreview.Text = "预览数据";
_btnPreview.Location = new Point(left, y); _btnPreview.Size = new Size(110, 32);
_btnPreview.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnPreview.Click += (s, e) => PreviewData();
Controls.Add(_btnPreview);
_btnImport.Text = "开始导入 TFS";
_btnImport.Location = new Point(left + 122, y); _btnImport.Size = new Size(130, 32);
_btnImport.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnImport.BackColor = Color.FromArgb(0, 120, 215);
_btnImport.ForeColor = Color.White;
_btnImport.Click += async (s, e) => await ImportAsync();
Controls.Add(_btnImport);
_btnFixArea.Text = "按迭代修Area";
_btnFixArea.Location = new Point(left + 264, y); _btnFixArea.Size = new Size(130, 32);
_btnFixArea.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
Controls.Add(_btnFixArea);
_btnLinkParent.Text = "补挂父项";
_btnLinkParent.Location = new Point(left + 406, y); _btnLinkParent.Size = new Size(130, 32);
_btnLinkParent.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
Controls.Add(_btnLinkParent);
_lblStatus.Text = "就绪";
_lblStatus.Location = new Point(left + 548, y + 6); _lblStatus.Size = new Size(400, 22);
_lblStatus.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
Controls.Add(_lblStatus);
y += 40;
// --- 日志 ---
_txtLog.Location = new Point(left, y);
_txtLog.Size = new Size(1036, 120);
_txtLog.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
_txtLog.Multiline = true;
_txtLog.ScrollBars = ScrollBars.Vertical;
_txtLog.ReadOnly = true;
Controls.Add(_txtLog);
}
private static void AddLabel(Control parent, string text, int x, int y, int w)
{
var lbl = new Label { Text = text, Location = new Point(x, y), Size = new Size(w, 22), TextAlign = ContentAlignment.MiddleLeft };
parent.Controls.Add(lbl);
}
private void UpdateAuthUi()
{
bool needCreds = _cmbAuth.SelectedItem != null && _cmbAuth.SelectedItem.ToString() != "default";
_txtUser.Enabled = needCreds;
_txtPwd.Enabled = needCreds;
_txtDomain.Enabled = needCreds;
}
// ------------------------------------------------------------- 配置互转
private void LoadConfigToUi()
{
_txtUrl.Text = _cfg.TfsUrl;
_txtProject.Text = _cfg.Project;
_txtType.Text = _cfg.WorkItemType;
_cmbAuth.SelectedItem = _cfg.AuthMode;
if (_cmbAuth.SelectedItem == null) _cmbAuth.SelectedItem = "ntlm";
_txtUser.Text = _cfg.Username;
_txtPwd.Text = _cfg.Password;
_txtDomain.Text = _cfg.Domain;
_chkProxy.Checked = _cfg.UseSystemProxy;
_cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_chkBypass.Checked = _cfg.BypassRules;
_txtParentPbi.Text = _cfg.ParentPbiId;
UpdateAuthUi();
}
private void SaveUiToConfig()
{
_cfg.TfsUrl = _txtUrl.Text.Trim();
_cfg.Project = _txtProject.Text.Trim();
_cfg.WorkItemType = _txtType.Text.Trim();
_cfg.AuthMode = _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm";
_cfg.Username = _txtUser.Text.Trim();
_cfg.Password = _txtPwd.Text;
_cfg.Domain = _txtDomain.Text.Trim();
_cfg.UseSystemProxy = _chkProxy.Checked;
_cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.BypassRules = _chkBypass.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.Save();
}
// ------------------------------------------------------------- 日志
private void Log(string msg)
{
if (InvokeRequired) { BeginInvoke(new Action(() => Log(msg))); return; }
_txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
}
// ------------------------------------------------------------- 迭代加载
private async Task LoadIterationsAsync()
{
SaveUiToConfig();
_btnLoadIterations.Enabled = false;
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
// 提前探测导入所需字段接口,避免导入时才发现路由缺失
try
{
var fm = await client.GetFieldMapAsync();
Log($"字段接口可用(字段 {fm.Count} 个)");
}
catch (Exception ex)
{
Log("⚠ 字段接口(_apis/wit/fields)不可用,导入可能受影响: " + ex.Message.Split('\n')[0]);
}
var paths = await client.GetIterationPathsAsync(_cfg.Project, Log);
_cmbIteration.Items.Clear();
foreach (var p in paths) _cmbIteration.Items.Add(p);
if (_cmbIteration.Items.Count > 0 && string.IsNullOrWhiteSpace(_cmbIteration.Text))
_cmbIteration.SelectedIndex = 0;
// 优先选中配置中的迭代
for (int i = 0; i < _cmbIteration.Items.Count; i++)
{
if (string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath,
StringComparison.OrdinalIgnoreCase))
{
_cmbIteration.SelectedIndex = i;
break;
}
}
Log($"已加载 {_cmbIteration.Items.Count} 个迭代路径");
}
}
catch (Exception ex)
{
Log("加载迭代失败: " + ex.Message);
MessageBox.Show(this, "加载迭代失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
_btnLoadIterations.Enabled = true;
}
}
// ------------------------------------------------------------- Excel
private void BrowseExcel()
{
using (var dlg = new OpenFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx;*.xlsm)|*.xlsx;*.xlsm|所有文件 (*.*)|*.*";
dlg.Title = "选择要导入的 Excel 文件";
if (!string.IsNullOrEmpty(_txtExcel.Text))
{
try { dlg.InitialDirectory = Path.GetDirectoryName(_txtExcel.Text); } catch { }
}
if (dlg.ShowDialog(this) != DialogResult.OK) return;
_txtExcel.Text = dlg.FileName;
SaveUiToConfig();
PreviewData();
}
}
private void GenerateTemplate()
{
using (var dlg = new SaveFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
dlg.FileName = "TFS导入模板.xlsx";
dlg.Title = "保存 Excel 模板";
if (dlg.ShowDialog(this) != DialogResult.OK) return;
try
{
ExcelService.GenerateTemplate(dlg.FileName);
Log("模板已生成: " + dlg.FileName);
MessageBox.Show(this, "模板已生成:\n" + dlg.FileName, "完成",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log("生成模板失败: " + ex.Message);
MessageBox.Show(this, "生成模板失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
private void PreviewData()
{
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
_rows = ExcelService.ReadRows(path);
_grid.DataSource = null;
_grid.DataSource = _rows.Select((r, i) => new
{
行号 = i + 1,
r.Title,
r.State,
指派 = r.AssignedTo,
财务项目 = r.FinancialProject,
r.Completed,
LogDate = r.LogDate,
Created = r.CreatedDate,
Closed = r.ClosedDate,
r.Notes
}).ToList();
_lblStatus.Text = $"已读取 {_rows.Count} 行";
Log($"读取 {path} 共 {_rows.Count} 行");
SaveUiToConfig();
}
catch (Exception ex)
{
Log("读取 Excel 失败: " + ex.Message);
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// ------------------------------------------------------------- Area 修正
private async Task FixAreaAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
MessageBox.Show(this, "请先在 config.json 中设置 AreaPath(如 Tools_Dev\\DataTechnologies)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var confirm = MessageBox.Show(this,
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且 Area≠{_cfg.AreaPath} 的工作项\nArea 改为 {_cfg.AreaPath}。\n\n注意:已关闭(如 Done)的工作项会被临时重新打开,改完 Area 后自动还原状态。\n\n确认执行?",
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
_btnFixArea.Enabled = false;
try
{
using (var client = new TfsClient(_cfg))
{
var r = await client.FixAreaAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType, _cfg.AreaPath, Log);
var summary = r.Item2 > 0
? $"Area 修正完成:成功 {r.Item1} 条,失败 {r.Item2} 条(详见日志)"
: $"Area 修正完成,共 {r.Item1} 条";
_lblStatus.Text = summary;
Log(summary);
MessageBox.Show(this, summary, r.Item2 > 0 ? "部分完成" : "完成",
MessageBoxButtons.OK, r.Item2 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
}
}
catch (Exception ex)
{
Log("Area 修正失败: " + ex.Message);
MessageBox.Show(this, "Area 修正失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
_btnFixArea.Enabled = true;
}
}
// ------------------------------------------------------------- 补挂父项
private async Task LinkParentAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (!int.TryParse(_cfg.ParentPbiId, out var parentId))
{
MessageBox.Show(this, "请先在「父PBI Id」输入框填入父工作项 Id(纯数字)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var confirm = MessageBox.Show(this,
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且尚无父项的工作项\n补挂为 #{parentId} 的子项。\n\n已有父项的自动跳过;已关闭的工作项会临时重新打开,挂完自动还原状态。\n\n确认执行?",
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
_btnLinkParent.Enabled = false;
try
{
using (var client = new TfsClient(_cfg))
{
var r = await client.LinkExistingAsync(_cfg.Project, _cfg.IterationPath,
_cfg.WorkItemType, parentId, Log);
var summary = $"补挂完成: 挂接 {r.Item1} / 已有父跳过 {r.Item2} / 失败 {r.Item3}";
_lblStatus.Text = summary;
Log(summary);
MessageBox.Show(this, summary + "\n挂接后在积压工作页展开父项即可看到子项。",
r.Item3 > 0 ? "部分完成" : "完成", MessageBoxButtons.OK,
r.Item3 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
}
}
catch (Exception ex)
{
Log("补挂父项失败: " + ex.Message);
MessageBox.Show(this, "补挂父项失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
_btnLinkParent.Enabled = true;
}
}
// ------------------------------------------------------------- 导入
private async Task ImportAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration(可点击“加载迭代列表”)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (_rows.Count == 0)
{
try { _rows = ExcelService.ReadRows(path); }
catch (Exception ex)
{
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
}
if (_rows.Count == 0)
{
MessageBox.Show(this, "Excel 中没有可导入的数据行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_btnImport.Enabled = false;
_btnPreview.Enabled = false;
int ok = 0, skip = 0, fail = 0;
var report = new StringBuilder();
report.AppendLine("行号,Title,结果,WorkItemId,错误");
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
// 字段解析
var fieldMap = await client.GetFieldMapAsync();
var titleRef = "System.Title";
var stateRef = "System.State";
var assignedRef = "System.AssignedTo";
var iterationRef = "System.IterationPath";
var areaRef = "System.AreaPath";
var createdRef = "System.CreatedDate";
var closedRef = "Microsoft.VSTS.Common.ClosedDate";
var finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef);
var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef);
var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef);
Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"}");
Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖");
HashSet<string> existing = null;
if (_chkSkip.Checked)
{
Log("查询已有工作项标题...");
existing = await client.GetExistingTitlesAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType);
Log($"服务器已有 {existing.Count} 条");
}
for (int i = 0; i < _rows.Count; i++)
{
var row = _rows[i];
var line = i + 2; // Excel 实际行号(含表头)
try
{
if (existing != null && existing.Contains(row.Title))
{
skip++;
report.AppendLine($"{line},\"{row.Title}\",SKIP,,标题已存在");
Log($"[{i + 1}/{_rows.Count}] SKIP {row.Title}");
continue;
}
var fields = new List<KeyValuePair<string, object>>
{
new KeyValuePair<string, object>(titleRef, row.Title),
new KeyValuePair<string, object>(stateRef,
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath)
};
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
if (!string.IsNullOrWhiteSpace(_cfg.AreaPath))
fields.Add(new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim()));
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
var completed = ExcelService.ToNumber(row.Completed);
if (completed != null && compRef != null)
fields.Add(new KeyValuePair<string, object>(compRef, completed));
// Notes -> Description(该 Task 模板必填)
if (!string.IsNullOrWhiteSpace(row.Notes))
fields.Add(new KeyValuePair<string, object>("System.Description", row.Notes.Trim()));
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
if (logDate != null && logRef != null)
fields.Add(new KeyValuePair<string, object>(logRef, logDate));
// 系统日期字段:bypassRules 时尝试写入
if (_chkBypass.Checked)
{
var created = ExcelService.ToIsoDateTime(row.CreatedDate);
if (created != null)
fields.Add(new KeyValuePair<string, object>(createdRef, created));
var closed = ExcelService.ToIsoDateTime(row.ClosedDate);
if (closed != null)
fields.Add(new KeyValuePair<string, object>(closedRef, closed));
}
// 父 PBI:行级 PBI Id 列优先,其次界面默认值
var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId;
int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null;
var id = await client.CreateWorkItemAsync(_cfg.Project, _cfg.WorkItemType, fields, _chkBypass.Checked, parentPbi);
ok++;
report.AppendLine($"{line},\"{row.Title}\",OK,{id},");
Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}");
}
catch (Exception ex)
{
fail++;
report.AppendLine($"{line},\"{row.Title}\",FAIL,,\"{ex.Message.Replace("\"", "'")}\"");
Log($"[{i + 1}/{_rows.Count}] FAIL {row.Title} -> {ex.Message}");
}
_lblStatus.Text = $"导入中 {i + 1}/{_rows.Count}...";
}
}
}
catch (Exception ex)
{
Log("导入中断: " + ex.Message);
MessageBox.Show(this, "导入中断:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
_btnImport.Enabled = true;
_btnPreview.Enabled = true;
try
{
var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv");
File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8);
Log($"报告已保存: {reportPath}");
}
catch (Exception ex) { Log("写报告失败: " + ex.Message); }
_lblStatus.Text = $"完成: 成功 {ok} / 跳过 {skip} / 失败 {fail}";
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
MessageBox.Show(this, $"导入完成。\n成功 {ok} / 跳过 {skip} / 失败 {fail}\n详见日志与报告 CSV。",
"完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using System;
using System.Windows.Forms;
namespace TfsExcelImporter
{
internal static class Program
{
[STAThread]
private static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
}
}
+511
View File
@@ -0,0 +1,511 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.TeamFoundation.Client;
using Microsoft.TeamFoundation.Server;
using Microsoft.TeamFoundation.WorkItemTracking.Client;
namespace TfsExcelImporter
{
/// <summary>
/// TFS 客户端:基于 TFS Client Object Model(内部 SOAP 协议,与 VS/Team Explorer 同款)。
/// 适用原因:实测该服务器仅有 /_apis/projects 可用,wit 全部 REST 路由 404(TFS 2013 RTM/低 Update),
/// Classification.asmx 的 WSDL 也不可达——裸 REST/SOAP 均走不通,必须走 OM。
/// </summary>
public class TfsClient : IDisposable
{
private readonly TfsTeamProjectCollection _tpc;
private WorkItemStore _store;
public TfsClient(AppConfig cfg)
{
ICredentials cred;
var mode = (cfg.AuthMode ?? "ntlm").ToLowerInvariant();
if (mode == "default")
{
cred = CredentialCache.DefaultCredentials;
}
else
{
var user = string.IsNullOrEmpty(cfg.Domain) ? cfg.Username : cfg.Domain + "\\" + cfg.Username;
cred = new NetworkCredential(user, cfg.Password ?? "");
}
_tpc = new TfsTeamProjectCollection(new Uri(cfg.TfsUrl), cred);
// TFS OM 内部用 HttpWebRequest 传输,默认跟随 IE 系统代理——实测代理会拦内网 TFS 返回 502。
// 默认直连;勾选"使用系统代理"时保留系统代理并带 Windows 凭据过代理认证。
if (!cfg.UseSystemProxy)
{
WebRequest.DefaultWebProxy = new WebProxy(); // 空 WebProxy = 不走代理,直连
}
else
{
var p = WebRequest.DefaultWebProxy ?? new WebProxy();
p.Credentials = CredentialCache.DefaultCredentials;
WebRequest.DefaultWebProxy = p;
}
}
/// <summary>WorkItemStore 惰性初始化(首次调用会下载字段/类型元数据,耗时数秒属正常)。</summary>
private WorkItemStore Store
{
get { return _store ?? (_store = _tpc.GetService<WorkItemStore>()); }
}
// ------------------------------------------------------------- 连通性
public async Task<string> TestConnectionAsync()
{
return await Task.Run(() =>
{
_tpc.Authenticate();
var css = _tpc.GetService<ICommonStructureService>();
return $"连接成功(TFS OM/SOAP),可见项目 {css.ListAllProjects().Length} 个";
});
}
// ------------------------------------------------------------- 字段
/// <summary>返回显示名 -> 引用名 的不区分大小写字典(来自服务器字段定义)。</summary>
public async Task<Dictionary<string, string>> GetFieldMapAsync()
{
return await Task.Run(() =>
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (FieldDefinition fd in Store.FieldDefinitions)
{
if (!string.IsNullOrEmpty(fd.Name) && !map.ContainsKey(fd.Name))
map[fd.Name] = fd.ReferenceName;
}
return map;
});
}
/// <summary>按显示名解析字段引用名;支持宽松前缀匹配(如 Completed -> Completed Work)。</summary>
public static string ResolveRef(Dictionary<string, string> map, string displayName, string explicitRef)
{
if (!string.IsNullOrEmpty(explicitRef)) return explicitRef;
if (string.IsNullOrEmpty(displayName)) return null;
string exact;
if (map.TryGetValue(displayName.Trim(), out exact)) return exact;
var prefix = displayName.Trim();
foreach (var kv in map)
{
if (kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return kv.Value;
}
return null;
}
// ------------------------------------------------------------- 迭代
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
public async Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
{
var list = await Task.Run(() =>
{
var css = _tpc.GetService<ICommonStructureService>();
var proj = css.GetProjectFromName(project);
if (proj == null)
throw new InvalidOperationException("项目不存在或无权访问: " + project);
var structures = css.ListStructures(proj.Uri);
var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle")
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0);
if (iterations == null)
throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构");
// GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径
var tree = css.GetNodesXml(new[] { iterations.Uri }, true);
var raw = new List<string>();
if (tree != null)
{
foreach (XmlNode n in tree.SelectNodes("//*[@Path]"))
{
var p = n.Attributes != null && n.Attributes["Path"] != null ? n.Attributes["Path"].Value : null;
if (!string.IsNullOrEmpty(p)) raw.Add(p);
}
}
return raw;
});
var result = list.Select(p => NormalizeIterationPath(p, project))
.Where(p => p != null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)");
return result;
}
/// <summary>
/// 节点路径归一化为工作项 IterationPath(项目\子...)。
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
/// </summary>
private static string NormalizeIterationPath(string nodePath, string project)
{
if (string.IsNullOrWhiteSpace(nodePath)) return null;
var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
if (segs.Count == 0) return null;
if (string.Equals(segs[0], project, StringComparison.OrdinalIgnoreCase)) segs.RemoveAt(0);
if (segs.Count > 0 &&
(segs[0].Equals("Iteration", StringComparison.OrdinalIgnoreCase) ||
segs[0].Equals("Iterations", StringComparison.OrdinalIgnoreCase) ||
segs[0].Equals("Area", StringComparison.OrdinalIgnoreCase) ||
segs[0].Equals("Areas", StringComparison.OrdinalIgnoreCase)))
segs.RemoveAt(0);
if (segs.Count == 0) return null;
return project + "\\" + string.Join("\\", segs);
}
// ------------------------------------------------------------- 查重
/// <summary>查询指定迭代下同类型工作项的标题集合。</summary>
public async Task<HashSet<string>> GetExistingTitlesAsync(string project, string iterationPath, string workItemType)
{
return await Task.Run(() =>
{
var wiql =
"SELECT [System.Title] FROM WorkItems WHERE " +
$"[System.TeamProject] = '{EscapeWiql(project)}' AND " +
$"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " +
$"[System.IterationPath] = '{EscapeWiql(iterationPath)}'";
var titles = new HashSet<string>(StringComparer.Ordinal);
foreach (WorkItem wi in Store.Query(wiql))
{
if (!string.IsNullOrEmpty(wi.Title)) titles.Add(wi.Title);
}
return titles;
});
}
// ------------------------------------------------------------- 创建
/// <summary>
/// 创建工作项(OM Save)。返回新工作项 ID。
/// 说明:OM 14.x 的 SaveFlags 无 BypassRules(15.x 才加入,且 15.x 不兼容 TFS 2013)——
/// System.CreatedDate 为服务器管理的只读字段,无法写入(取导入时间);
/// Microsoft.VSTS.Common.ClosedDate 在 State=Done 时按普通规则通常可写:尝试写入,
/// 校验或保存失败则清空该字段降级为普通保存(保证行导入成功)。
/// parentPbiId 非空时,通过 System.LinkTypes.Hierarchy(Reverse=Parent) 挂接到该 PBI。
/// </summary>
public async Task<int> CreateWorkItemAsync(string project, string workItemType,
List<KeyValuePair<string, object>> fields, bool bypassRules, int? parentPbiId = null)
{
return await Task.Run(() =>
{
var wi = new WorkItem(Store.Projects[project].WorkItemTypes[workItemType]);
var closedSet = false;
foreach (var kv in fields)
{
if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) continue;
if (kv.Key == "System.CreatedDate")
continue; // 服务器管理字段,OM14 无法写入
if (kv.Key == "Microsoft.VSTS.Common.ClosedDate")
{
try { wi.Fields[kv.Key].Value = kv.Value; closedSet = true; }
catch { /* 环境不允许设置,跳过 */ }
continue;
}
wi.Fields[kv.Key].Value = kv.Value;
}
// 父 PBI 链接(与建项同一 Save 原子完成;PBI 不存在等错误会整行报 FAIL)
if (parentPbiId.HasValue)
{
var parentEnd = FindParentLinkEnd();
if (parentEnd == null)
throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy");
wi.Links.Add(new RelatedLink(parentEnd, parentPbiId.Value));
}
var bad = CheckInvalid(wi);
if (bad != null && closedSet &&
bad.IndexOf("ClosedDate", StringComparison.OrdinalIgnoreCase) >= 0)
{
ClearClosedDate(wi);
closedSet = false;
bad = CheckInvalid(wi);
}
if (bad != null)
throw new InvalidOperationException("字段校验失败: " + bad);
try
{
wi.Save();
}
catch (Exception ex)
{
if (!closedSet) throw;
// 服务器端拒绝(如 ClosedDate 只读规则):清空降级为普通保存
ClearClosedDate(wi);
try
{
wi.Save();
closedSet = false;
}
catch (Exception ex2)
{
throw new InvalidOperationException("保存失败: " + ex.Message +
(ex2 == null ? "" : " / 降级重试: " + ex2.Message));
}
}
return wi.Id;
});
}
/// <summary>找父子链接类型的 Parent 端(子→父)。</summary>
private WorkItemLinkTypeEnd FindParentLinkEnd()
{
foreach (WorkItemLinkType lt in Store.WorkItemLinkTypes)
{
if (string.Equals(lt.ReferenceName, "System.LinkTypes.Hierarchy", StringComparison.OrdinalIgnoreCase))
return lt.ReverseEnd;
}
return null;
}
private static string CheckInvalid(WorkItem wi)
{
var invalid = wi.Validate();
if (invalid.Count == 0) return null;
var names = new StringBuilder();
foreach (Field f in invalid)
names.Append(f.Name).Append("(").Append(FieldStatusText(f)).Append("); ");
return names.ToString();
}
/// <summary>把 FieldStatus 翻译为可读原因(用字符串匹配,避免不同 OM 版本枚举成员差异)。</summary>
private static string FieldStatusText(Field f)
{
switch (f.Status.ToString())
{
case "InvalidEmpty": return "必填字段为空";
case "InvalidTooLong": return "值超过最大长度";
case "InvalidType": return "值类型不匹配";
case "InvalidValue": return "值不在允许范围内";
case "InvalidFormat": return "值格式不正确";
case "InvalidList": return "值不在允许列表中";
case "InvalidNull": return "值不能为空";
case "InvalidComputed": return "计算字段不可直接修改";
case "InvalidNotNew": return "仅新建时可设置";
case "Valid": return "有效";
default: return "状态=" + f.Status;
}
}
private static void ClearClosedDate(WorkItem wi)
{
try { wi.Fields["Microsoft.VSTS.Common.ClosedDate"].Value = null; } catch { }
}
/// <summary>
/// 把指定迭代下、类型匹配、且 AreaPath 不等于目标值的工作项,批量改为目标 Area。
/// 用于修复"导入时未设 AreaPath 导致团队视图看不到"的历史数据。
/// 关闭状态(如 Task 的 Done)的工作项是只读的,会报 TF400276,
/// 此时会走"重新打开 -> 改 Area -> 还原状态"的状态循环。
/// 返回 (成功条数, 失败条数)。
/// </summary>
public async Task<Tuple<int, int>> FixAreaAsync(string project, string iterationPath, string workItemType,
string areaPath, Action<string> log)
{
return await Task.Run(() =>
{
var wiql = "SELECT [System.Id] FROM WorkItems WHERE " +
$"[System.TeamProject] = '{EscapeWiql(project)}' AND " +
$"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " +
$"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " +
$"[System.AreaPath] <> '{EscapeWiql(areaPath)}'";
var ok = 0;
var fail = 0;
foreach (WorkItem wi in Store.Query(wiql))
{
var state = Convert.ToString(wi.Fields["System.State"].Value);
try
{
wi.Fields["System.AreaPath"].Value = areaPath;
wi.Save();
ok++;
log?.Invoke($" #{wi.Id} [{state}] {wi.Title} Area -> {areaPath}");
}
catch (Exception ex)
{
var msg = ex.Message ?? "";
if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开"))
{
if (TryFixAreaViaReopen(wi, areaPath, log)) { ok++; continue; }
}
fail++;
log?.Invoke($" #{wi.Id} [{state}] {wi.Title} 修改失败: {msg}");
}
}
return Tuple.Create(ok, fail);
});
}
// ------------------------------------------------------------- 补挂父项
/// <summary>
/// 把指定迭代下、类型匹配、且尚未挂父项的工作项,批量补挂到 parentWorkItemId 下。
/// 对应 Python 版 --link-existing:已有父项(Hierarchy-Reverse 链接)的跳过;
/// 关闭状态(如 Task 的 Done,TF400276 只读)走"重新打开 -> 挂接 -> 还原状态"循环。
/// 返回 (成功条数, 已有父跳过条数, 失败条数)。
/// </summary>
public async Task<Tuple<int, int, int>> LinkExistingAsync(string project, string iterationPath,
string workItemType, int parentWorkItemId, Action<string> log)
{
return await Task.Run(() =>
{
var parentEnd = FindParentLinkEnd();
if (parentEnd == null)
throw new InvalidOperationException("找不到父子链接类型 System.LinkTypes.Hierarchy");
var wiql = "SELECT [System.Id] FROM WorkItems WHERE " +
$"[System.TeamProject] = '{EscapeWiql(project)}' AND " +
$"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " +
$"[System.IterationPath] = '{EscapeWiql(iterationPath)}'";
var ok = 0;
var skip = 0;
var fail = 0;
foreach (WorkItem wi in Store.Query(wiql))
{
try
{
if (HasParentLink(wi))
{
skip++;
log?.Invoke($" #{wi.Id} {wi.Title} 已有父项,跳过");
continue;
}
wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId));
wi.Save();
ok++;
log?.Invoke($" #{wi.Id} {wi.Title} -> 父项 #{parentWorkItemId}");
}
catch (Exception ex)
{
var msg = ex.Message ?? "";
if (msg.Contains("TF400276") || msg.Contains("没有打开或部分打开"))
{
if (TryViaReopen(wi, () =>
{
// Save 失败时挂起的链接仍在 Links 中,先查重避免重复添加
if (!HasParentLink(wi))
wi.Links.Add(new RelatedLink(parentEnd, parentWorkItemId));
}, log)) { ok++; continue; }
}
fail++;
log?.Invoke($" #{wi.Id} {wi.Title} 挂接失败: {msg}");
}
}
return Tuple.Create(ok, skip, fail);
});
}
/// <summary>判断工作项是否已有父级链接(System.LinkTypes.Hierarchy-Reverse)。</summary>
private static bool HasParentLink(WorkItem wi)
{
foreach (var link in wi.Links.OfType<RelatedLink>())
{
WorkItemLinkTypeEnd end = null;
try { end = link.LinkTypeEnd; } catch { }
if (end == null) continue;
// OM 14.x 的 WorkItemLinkTypeEnd 无 ReferenceName:用 ImmutableName(Hierarchy-Reverse)
// 或 Name(Parent) 判断;两者都兜底,防不同版本元数据差异
var immutable = end.ImmutableName ?? "";
var name = end.Name ?? "";
if (immutable.IndexOf("Hierarchy-Reverse", StringComparison.OrdinalIgnoreCase) >= 0 ||
string.Equals(name, "Parent", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// 关闭状态的工作项(TF400276)走状态循环执行操作:
/// State 改为打开状态并 Save -> 执行 action 并 Save -> State 改回原值并 Save。
/// 只使用当前状态合法可迁移到的打开状态(读 AllowedValues),迁移不合法则放弃。
/// </summary>
private bool TryViaReopen(WorkItem wi, Action action, Action<string> log)
{
var stateField = wi.Fields["System.State"];
var origState = Convert.ToString(stateField.Value);
var openState = PickOpenState(stateField);
if (openState == null)
{
log?.Invoke($" #{wi.Id} {wi.Title} 处于关闭状态[{origState}]且没有合法的重新打开状态,跳过");
return false;
}
try
{
log?.Invoke($" #{wi.Id} {wi.Title} 已关闭[{origState}],走状态循环: {origState} -> {openState} -> 执行操作 -> {origState}");
stateField.Value = openState;
wi.Save();
action();
stateField.Value = origState;
wi.Save();
log?.Invoke($" #{wi.Id} 操作完成(已还原状态为 {origState})");
return true;
}
catch (Exception ex)
{
// 中途失败时把状态改回去,避免工作项停留在中间状态
try
{
var cur = Convert.ToString(wi.Fields["System.State"].Value);
if (cur != origState)
{
wi.Fields["System.State"].Value = origState;
wi.Save();
log?.Invoke($" #{wi.Id} 已回滚状态为 {origState}");
}
}
catch { /* 回滚失败也只能记录 */ }
log?.Invoke($" #{wi.Id} {wi.Title} 状态循环失败: {ex.Message}");
return false;
}
}
/// <summary>
/// 关闭状态的工作项(TF400276)走状态循环修 Area。
/// </summary>
private bool TryFixAreaViaReopen(WorkItem wi, string areaPath, Action<string> log)
{
return TryViaReopen(wi, () => { wi.Fields["System.AreaPath"].Value = areaPath; }, log);
}
/// <summary>按常见命名顺序,从当前状态可迁移到的 AllowedValues 里挑一个"打开"状态。</summary>
private static string PickOpenState(Field stateField)
{
var candidates = new[]
{
"To Do", "New", "Active", "In Progress", "Proposed", "Approved",
"Committed", "Open", "Design", "Development", "InProgress"
};
foreach (var c in candidates)
foreach (var v in stateField.AllowedValues)
if (string.Equals(Convert.ToString(v), c, StringComparison.OrdinalIgnoreCase))
return Convert.ToString(v);
return null;
}
// ------------------------------------------------------------- 工具
private static string EscapeWiql(string s)
{
return (s ?? "").Replace("'", "''");
}
public void Dispose()
{
_tpc.Dispose();
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyTitle>TFS Excel 导入工具</AssemblyTitle>
<Product>TFS Excel 导入工具</Product>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<ItemGroup>
<!-- Excel 读写 -->
<PackageReference Include="ClosedXML" Version="0.102.2" />
<!-- JSON (保留给 config.json) -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- TFS Client OM(内部 SOAP 协议,兼容 TFS 2013;REST wit 路由缺失时唯一可行通道) -->
<PackageReference Include="Microsoft.TeamFoundationServer.ExtendedClient" Version="14.*" />
<!-- 无需安装 VS 也可用 dotnet build 编译 net48 -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
{
"TfsUrl": "http://pdtsf01.dev.goldenstand.cn:8080/tfs/DefaultCollection",
"Project": "Tools_Dev",
"WorkItemType": "Product Backlog Item",
"IterationPath": "",
"AreaPath": "Tools_Dev\\DataTechnologies",
"AuthMode": "ntlm",
"Username": "",
"Password": "",
"Domain": "",
"UseSystemProxy": false,
"BypassRules": true,
"SkipExistingTitles": true,
"DefaultState": "To Do",
"FinancialProjectRef": "",
"CompletedRef": "",
"LogDateRef": "",
"LastExcelPath": "",
"ParentPbiId": ""
}