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
{
///
/// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
///
public partial class MainForm : Form
{
private AppConfig _cfg;
private List _rows = new List();
public MainForm()
{
// System DPI 感知(见 app.manifest):先定缩放系数,再按它摆控件
using (var g = Graphics.FromHwnd(IntPtr.Zero))
Theme.Scale = g.DpiX / 96f;
_cfg = AppConfig.Load();
Icon = AppIcon.Load();
BuildUi();
LoadConfigToUi();
WireEvents();
RefreshRibbon();
RefreshSummaries();
SetConnection("连接未验证", ChipKind.Neutral);
UpdateEmptyState();
}
private void WireEvents()
{
_btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnLoadAreas.Click += async (s, e) => await LoadAreasAsync();
_btnLoadPbi.Click += async (s, e) => await LoadBacklogAsync();
_btnTemplate.Click += (s, e) => ExportTemplate();
_btnPreview.Click += async (s, e) => await PreviewDataAsync();
_btnImport.Click += async (s, e) => await ImportAsync();
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd, _txtExcel })
tb.TextChanged += (s, e) => OnFieldChanged();
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
_cmbArea.TextChanged += (s, e) => OnFieldChanged();
_cmbParent.TextChanged += (s, e) => OnFieldChanged();
// 从列表里挑的是「#219975 标题 · 状态」这种展示文本,落库/导入得是纯 Id。
// 两个事件都挂:鼠标点选主要走 SelectionChangeCommitted(下拉关闭时),
// 键盘上下选择/程序置位只有 SelectedIndexChanged,重复选同一项则只有前者。
_cmbParent.SelectedIndexChanged += (s, e) => CommitPbiSelection();
_cmbParent.SelectionChangeCommitted += (s, e) => CommitPbiSelection();
// 下拉收起时再兜一次:无论键盘还是鼠标选完,这里一定能拿到最终选中项
_cmbParent.DropDownClosed += (s, e) => CommitPbiSelection();
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
}
private void OnFieldChanged()
{
RefreshRibbon();
RefreshSummaries();
}
// ------------------------------------------------------------- 配置互转
private void LoadConfigToUi()
{
_txtUrl.Text = _cfg.TfsUrl;
_txtProject.Text = _cfg.Project;
_txtType.Text = _cfg.WorkItemType;
_txtUser.Text = _cfg.Username;
_txtPwd.Text = _cfg.Password;
_cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath;
_cmbArea.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_cmbParent.Text = _cfg.ParentPbiId;
}
private void SaveUiToConfig()
{
_cfg.TfsUrl = _txtUrl.Text.Trim();
_cfg.Project = _txtProject.Text.Trim();
_cfg.WorkItemType = _txtType.Text.Trim();
_cfg.Username = _txtUser.Text.Trim();
_cfg.Password = _txtPwd.Text;
_cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _cmbArea.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.ParentPbiId = NormalizePbiText(_cmbParent.Text);
_cfg.Save();
}
// ------------------------------------------------------------- 日志
private void Log(string msg)
{
Append(msg, LogView.Classify(msg));
}
private static string FirstLine(string text)
{
if (string.IsNullOrEmpty(text)) return "";
var i = text.IndexOf('\n');
return i < 0 ? text : text.Substring(0, i).Trim();
}
// ------------------------------------------------------------- 连接测试
private async Task TestConnectionAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
SetConnection("URL 未填", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
Append("请先填写服务器 URL", LogLevel.Warn);
_txtUrl.Focus();
return;
}
SetConnection("连接中…", ChipKind.Info);
SetBadgeOf(_cardConn, BadgeKind.Active);
_btnTest.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("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
}
}
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
OpenDetail(_cardData);
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
Log("连接失败: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
_cardConn.Expanded = true;
_cardConn.ApplyHeight();
Relayout();
}
finally
{
_btnTest.Enabled = true;
}
}
// ------------------------------------------------------------- 迭代 / Area 列表
// 两者都是 TFS 分类结构(ICommonStructureService),取数、填表、报错完全同构,
// 只有「结构根节点」「是否自动选第一条」不同,所以共用一套加载流程。
private async Task LoadIterationsAsync()
{
await LoadPathListAsync("迭代", c => c.GetIterationPathsAsync(_cfg.Project, Log),
_cmbIteration, _fIteration, _errIteration, true);
}
private async Task LoadAreasAsync()
{
// Area 不自动选第一条:选错区域会静默写到别人的团队视图下,让用户自己挑更安全
await LoadPathListAsync("Area", c => c.GetAreaPathsAsync(_cfg.Project, Log),
_cmbArea, _fArea, _errArea, false);
}
private async Task LoadPathListAsync(string what, Func>> fetch,
ComboBox combo, InputField field, Label err, bool selectFirstWhenNoMatch)
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
Append("请先填写服务器 URL", LogLevel.Warn);
OpenDetail(_cardConn);
return;
}
SetBusy(true);
SetConnection($"读取 {what} 列表中…", ChipKind.Info);
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("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
}
var paths = await fetch(client);
FillPathCombo(combo, paths, selectFirstWhenNoMatch);
Log($"已加载 {combo.Items.Count} 条 {what} 路径");
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
ShowError(err, field, _cardData, "");
}
}
catch (Exception ex)
{
ReportTfsFailure(ex, what, field, err);
}
finally
{
SetBusy(false);
OnFieldChanged();
}
}
// ------------------------------------------------------------- 父 PBI 列表
private async Task LoadBacklogAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
Append("请先填写服务器 URL", LogLevel.Warn);
OpenDetail(_cardConn);
return;
}
SetBusy(true);
SetConnection("读取 PBI 列表中…", ChipKind.Info);
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
var items = await client.GetBacklogItemsAsync(_cfg.Project, _cfg.IterationPath, Log);
// 下拉项是「#219975 标题 · 状态」,但字段本身只存纯 Id:
// Items.Clear 会连带清空 Text,所以先存后回填,刷新列表不丢已填的 Id。
var keep = _cmbParent.Text;
_cmbParent.Items.Clear();
foreach (var it in items) _cmbParent.Items.Add(it);
_cmbParent.Text = keep;
Log($"已加载 {items.Count} 条候选父项(从列表选中后会自动回填 Id)");
if (items.Count == 0) Log("提示: 没有可选父项,可直接手填 Id,或先确认项目/迭代是否正确");
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
ShowError(_errParent, _fParent, _cardData, "");
}
}
catch (Exception ex)
{
ReportTfsFailure(ex, "父 PBI", _fParent, _errParent);
}
finally
{
SetBusy(false);
OnFieldChanged();
}
}
///
/// 列表项是「#219975 标题 · State」这种展示文本,字段要的是纯数字 Id:
/// 选中后立刻把 Text 归位成 Id,避免展示串被写进 System.Parent 或落进 config。
/// 用 SelectedIndexChanged 而不是 SelectionChangeCommitted——后者只在「下拉关闭」时才发,
/// 键盘/程序触发的选中收不到;这里以「# 开头」判定是不是列表回填,手输纯 Id 不受影响。
/// 回填 Text 会让 SelectedIndex 变 -1 再触发一次,此时文本已是数字,直接 return 不会递归。
///
private void CommitPbiSelection()
{
int idx = _cmbParent.SelectedIndex;
if (idx < 0 || idx >= _cmbParent.Items.Count) return; // 回填后 SelectedIndex 会变 -1,这条路用于收敛
var item = _cmbParent.Items[idx] as TfsClient.WorkItemBrief;
if (item == null) return;
var id = item.Id.ToString();
if (string.Equals(_cmbParent.Text, id, StringComparison.Ordinal)) return;
_cmbParent.Text = id;
_cmbParent.SelectionStart = id.Length;
Log($"父 PBI 已选: #{item.Id} {item.Title}");
}
///
/// 把「#219975 标题 · State」洗成纯 Id 再落库。
/// 选中事件负责即时回填,这里是最后一道保险:事件万一没触发(比如某些自绘交互),
/// 写进 config / 传给 TFS 的仍是干净 Id,不会出现「挂不上父项却看不出原因」。
///
private static string NormalizePbiText(string text)
{
if (string.IsNullOrWhiteSpace(text)) return "";
text = text.Trim();
if (text[0] != '#') return text;
int i = 1;
while (i < text.Length && text[i] >= '0' && text[i] <= '9') i++;
return i > 1 ? text.Substring(1, i - 1) : text;
}
///
/// 连接类失败的统一呈现:TF30063(服务器拒绝当前身份)与网络不通是两回事,
/// 前者该去填账号密码,后者才查 VPN/代理,提示分开写才能真正指引到动作。
///
private void ReportTfsFailure(Exception ex, string what, InputField field, Label err)
{
var msg = FirstLine(ex.Message);
if (msg.IndexOf("TF30063", StringComparison.OrdinalIgnoreCase) >= 0)
{
SetConnection("认证失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(err, field, _cardData, "认证失败:请在步骤 1 填写可访问该项目的用户名/密码");
Log($"加载 {what} 失败: 未授权(TF30063),当前 Windows 身份无权访问该项目");
Log("排查: 用户名可写 user 或 DOMAIN\\user;程序固定走 NTLM 直连。");
}
else
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(err, field, _cardData, $"{what} 列表加载失败,可手动填写");
Log($"加载 {what} 失败: " + msg);
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
}
///
/// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。
/// Items.Clear 会把 DropDown 样式的 Text 一并清空,所以要在清空前取值、匹配后回填。
///
private static void FillPathCombo(ComboBox combo, List paths, bool selectFirstWhenNoMatch)
{
var keep = combo.Text;
combo.Items.Clear();
foreach (var p in paths) combo.Items.Add(p);
if (combo.Items.Count == 0)
{
combo.Text = keep;
return;
}
int match = -1;
for (int i = 0; i < combo.Items.Count; i++)
{
if (string.Equals(combo.Items[i].ToString(), keep, StringComparison.OrdinalIgnoreCase))
{
match = i;
break;
}
}
if (match >= 0) combo.SelectedIndex = match;
else if (selectFirstWhenNoMatch) combo.SelectedIndex = 0;
else combo.Text = keep;
}
// ------------------------------------------------------------- Excel
private async 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();
await PreviewDataAsync();
}
}
private async void ExportTemplate()
{
var startDir = ExportStartDirectory();
using (var dlg = new SaveFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
dlg.Title = "导出默认模板";
dlg.DefaultExt = "xlsx";
dlg.AddExtension = true;
// 关掉系统自带的"是否替换",换成能看清目标文件是什么的确认框(见 ConfirmOverwriteTemplate)
dlg.OverwritePrompt = false;
dlg.InitialDirectory = startDir;
// 关键:FileName 给完整路径。只给文件名时对话框会沿用系统"上次目录",
// 那个目录里往往躺着 TFS导入模板11.xlsx 这类近似名,容易选错/覆盖到已有数据表
dlg.FileName = Path.Combine(startDir, ExcelService.TemplateFileName);
if (dlg.ShowDialog(this) != DialogResult.OK) return;
var path = dlg.FileName;
try
{
if (!ConfirmOverwriteTemplate(path)) return;
bool embeddedOnly;
if (!ConfirmTemplateSource(out embeddedOnly)) return;
var srcDesc = ExcelService.CopyTemplate(path, embeddedOnly);
Log("模板已导出(来源:" + srcDesc + "): " + path);
_txtExcel.Text = path;
SaveUiToConfig();
await PreviewDataAsync();
}
catch (Exception ex)
{
Log("导出模板失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
}
}
}
///
/// 导出对话框起始目录:优先当前 Excel 所在目录,其次桌面。
/// 显式给目录,避免对话框落到"系统记住的上次目录"而出乎意料。
///
private string ExportStartDirectory()
{
var last = _txtExcel.Text.Trim();
if (!string.IsNullOrEmpty(last))
{
try
{
var dir = Path.GetDirectoryName(last);
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) return dir;
}
catch { }
}
try
{
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (!string.IsNullOrEmpty(desktop) && Directory.Exists(desktop)) return desktop;
}
catch { }
return AppDomain.CurrentDomain.BaseDirectory;
}
///
/// 覆盖确认。系统默认提示只问"是否替换",看不出目标文件其实是用户已有的数据表,
/// 这里把路径、大小、修改时间和已有数据行数都摆出来,避免把导入数据当模板覆盖掉。
///
private bool ConfirmOverwriteTemplate(string path)
{
FileInfo fi;
try { fi = new FileInfo(path); }
catch { return true; }
if (!fi.Exists) return true;
// 大文件数行会卡住界面:超过 20MB 就只报大小/时间,不数行
int rows = fi.Length <= 20 * 1024 * 1024 ? ExcelService.CountDataRows(path) : 0;
var sb = new StringBuilder();
sb.AppendLine("目标位置已存在文件,继续将用空白模板覆盖它:");
sb.AppendLine(path);
sb.AppendLine();
sb.AppendLine("大小 " + FormatSize(fi.Length) + " · 修改时间 " + fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm"));
if (rows > 0)
sb.AppendLine("该文件已有 " + rows + " 行数据,覆盖后无法恢复!");
else if (rows < 0)
sb.AppendLine("该文件读不出内容(可能正被 Excel 打开,或不是 Excel 文件)。");
return MessageBox.Show(this, sb.ToString(), "确认覆盖已有文件",
MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK;
}
private static string FormatSize(long bytes)
{
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("0.#") + " KB";
return (bytes / 1024.0 / 1024.0).ToString("0.#") + " MB";
}
///
/// 导出前确认模板来源。exe 同目录的 TFS导入模板.xlsx 一旦被现场替换、或被填过数据,
/// 导出结果就不再是空白模板——用户会看到"导出的模板里带着别的数据"。
/// 这里把选择权交出来:内置空白模板 / 仍用该文件 / 不导出。
///
private bool ConfirmTemplateSource(out bool embeddedOnly)
{
embeddedOnly = false;
var local = ExcelService.LocalTemplatePath;
if (!File.Exists(local) || ExcelService.LocalTemplateIsBuiltIn()) return true;
int rows = ExcelService.CountDataRows(local);
var sb = new StringBuilder();
sb.AppendLine("exe 同目录的模板文件与内置模板不一致:");
sb.AppendLine(local);
sb.AppendLine();
if (rows > 0)
sb.AppendLine("它当前有 " + rows + " 行内容,用它导出的模板会带上这些行。");
else if (rows < 0)
sb.AppendLine("它读不出数据(可能正被 Excel 打开,或不是模板文件)。");
else
sb.AppendLine("它已被替换为自定义模板。");
sb.AppendLine();
sb.AppendLine("是(Y):改用内置空白模板");
sb.AppendLine("否(N):仍用这个文件");
sb.AppendLine("取消:不导出");
var r = MessageBox.Show(this, sb.ToString(), "模板来源确认",
MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning);
if (r == DialogResult.Cancel) return false;
embeddedOnly = r == DialogResult.Yes;
return true;
}
// ------------------------------------------------------------- 预览
// ClosedXML 全量载入工作簿,大文件在 UI 线程读会冻结界面——放 Task.Run,
// 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。
private async Task PreviewDataAsync()
{
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
return;
}
SetBusy(true);
try
{
_rows = await Task.Run(() => 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,
日期 = r.LogDate,
父PBI = r.PbiId,
说明 = r.Description
}).ToList();
ShowError(_errExcel, _fExcel, _cardData, "");
StyleGridColumns();
_lblStatus.Text = $"已读取 {_rows.Count} 行";
Log($"读取 {path} 共 {_rows.Count} 行");
if (_rows.Count == 0) Log("警告: 没有可导入的数据行(Title 为空的行会被忽略)");
SetBadgeOf(_cardData, BadgeKind.Done);
SetBadgeOf(_cardPreview, _rows.Count > 0 ? BadgeKind.Done : BadgeKind.Warn);
UpdateEmptyState();
RefreshSummaries();
SaveUiToConfig();
}
catch (Exception ex)
{
// 读取失败必须清掉上一次的数据,否则表格会继续显示旧文件的行,
// 看起来就像"刚导出的文件里装的是别的文件的数据"
_rows = new List();
_grid.DataSource = null;
_lblStatus.Text = "读取失败";
Log("读取 Excel 失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
UpdateEmptyState();
RefreshSummaries();
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- Area 修正
private async Task FixAreaAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "修 Area 需要先选择目标 Iteration");
Append("按迭代修 Area 取消:目标迭代未选", LogLevel.Warn);
return;
}
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
_cmbArea.Focus();
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;
SetBusy(true);
OpenDetail(_cardLog);
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);
ShowError(_errArea, _fArea, _cardData, "");
}
}
catch (Exception ex)
{
Log("Area 修正失败: " + FirstLine(ex.Message));
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- 补挂父项
private async Task LinkParentAsync()
{
SaveUiToConfig();
int parentId;
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "补挂父项需要先选择目标 Iteration");
Append("补挂父项取消:目标迭代未选", LogLevel.Warn);
return;
}
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
{
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
_cmbParent.Focus();
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;
SetBusy(true);
OpenDetail(_cardLog);
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);
Log("挂接后在积压工作页展开父项即可看到子项。");
ShowError(_errParent, _fParent, _cardData, "");
}
}
catch (Exception ex)
{
Log("补挂父项失败: " + FirstLine(ex.Message));
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- 导入
private async Task ImportAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "请先选择目标 Iteration(可点「加载迭代列表」,也可手填完整路径)");
Append("导入取消:目标迭代未选", LogLevel.Warn);
return;
}
// Area 必填:不写 AreaPath 的工作项落在项目根区域,团队视图里看不到(历史坑)
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
Append("导入取消:Area 路径未填", LogLevel.Warn);
_cmbArea.Focus();
return;
}
// 父 PBI 是可选字段,但一旦填了就必须是纯数字:非数字会在挂接时被静默当成「不挂父项」,
// 导入"成功"却少了层级,比直接报错更难发现。
if (!string.IsNullOrWhiteSpace(_cfg.ParentPbiId) && !int.TryParse(_cfg.ParentPbiId, out _))
{
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,可点右侧「加载 PBI 列表」选择");
Append("导入取消:父 PBI Id 不是数字", LogLevel.Warn);
_cmbParent.Focus();
return;
}
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("导入取消:Excel 文件不存在", LogLevel.Warn);
return;
}
if (_rows.Count == 0)
{
SetBusy(true);
try { _rows = await Task.Run(() => ExcelService.ReadRows(path)); }
catch (Exception ex)
{
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
Log("读取 Excel 失败: " + FirstLine(ex.Message));
return;
}
finally { SetBusy(false); }
}
if (_rows.Count == 0)
{
ShowError(_errExcel, _fExcel, _cardData, "Excel 中没有可导入的数据行");
Append("导入取消:没有可导入的数据行", LogLevel.Warn);
return;
}
ShowError(_errIteration, _fIteration, _cardData, "");
ShowError(_errArea, _fArea, _cardData, "");
ShowError(_errExcel, _fExcel, _cardData, "");
SetRunBadge(BadgeKind.Active);
SetBusy(true);
OpenDetail(_cardLog);
_pbar.Maximum = _rows.Count;
_pbar.Value = 0;
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());
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
// 字段解析
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 finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef);
var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef);
var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef);
var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef);
Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"} OriginalEstimate->{origEstRef ?? "(未找到)"}");
Log($"目标: 迭代 {_cfg.IterationPath} Area {_cfg.AreaPath}");
Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖");
HashSet 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>
{
new KeyValuePair(titleRef, row.Title),
new KeyValuePair(stateRef,
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
new KeyValuePair(iterationRef, _cfg.IterationPath),
// Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域
new KeyValuePair(areaRef, _cfg.AreaPath.Trim())
};
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
fields.Add(new KeyValuePair(assignedRef, row.AssignedTo.Trim()));
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
fields.Add(new KeyValuePair(finRef, row.FinancialProject.Trim()));
var completed = ExcelService.ToNumber(row.Completed);
if (completed != null && compRef != null)
fields.Add(new KeyValuePair(compRef, completed));
// Original Estimate 默认取 Completed 的值
if (completed != null && origEstRef != null)
fields.Add(new KeyValuePair(origEstRef, completed));
if (!string.IsNullOrWhiteSpace(row.Description))
fields.Add(new KeyValuePair("System.Description", row.Description.Trim()));
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
if (logDate != null && logRef != null)
fields.Add(new KeyValuePair(logRef, logDate));
// 父 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, 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}");
}
_pbar.Value = Math.Min(i + 1, _pbar.Maximum);
_lblStatus.Text = $"{i + 1}/{_rows.Count}";
SetChips(ok, skip, fail, false);
}
SetRunBadge(fail > 0 ? BadgeKind.Warn : BadgeKind.Done);
}
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetRunBadge(BadgeKind.Warn);
Log("导入中断: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
finally
{
SetBusy(false);
try
{
var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv");
File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8);
_lastReportPath = reportPath;
Log("报告已保存: " + reportPath);
}
catch (Exception ex) { Log("写报告失败: " + FirstLine(ex.Message)); }
_pbar.Value = _pbar.Maximum;
// 成功/跳过/失败的分布已在右侧三个胶囊里,这里只补胶囊没有的「总数」,明细仍写日志
_lblStatus.Text = $"完成 {ok + skip + fail}/{_rows.Count}";
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
SetChips(ok, skip, fail, false);
RefreshSummaries();
if (fail == 0 && ok > 0) OpenDetail(_cardLog);
}
}
}
}