Files
TFSImport/TfsExcelImporter/MainForm.cs
T
chenshoulin 8d1af0d11c 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 说明列结构
2026-11-01 11:21:20 +08:00

678 lines
32 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.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);
}
}
}
}