Files
chenshoulin fe23e1c64a feat: 增加父 PBI 列表加载功能与界面优化
- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。
- 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。
- 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。
- 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。
- 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
2026-11-02 13:07:58 +08:00

855 lines
40 KiB
C#
Raw Permalink 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
{
/// <summary>
/// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
/// </summary>
public partial class MainForm : Form
{
private AppConfig _cfg;
private List<WorkItemRow> _rows = new List<WorkItemRow>();
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<TfsClient, Task<List<string>>> 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();
}
}
/// <summary>
/// 列表项是「#219975 标题 · State」这种展示文本,字段要的是纯数字 Id:
/// 选中后立刻把 Text 归位成 Id,避免展示串被写进 System.Parent 或落进 config。
/// 用 SelectedIndexChanged 而不是 SelectionChangeCommitted——后者只在「下拉关闭」时才发,
/// 键盘/程序触发的选中收不到;这里以「# 开头」判定是不是列表回填,手输纯 Id 不受影响。
/// 回填 Text 会让 SelectedIndex 变 -1 再触发一次,此时文本已是数字,直接 return 不会递归。
/// </summary>
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}");
}
/// <summary>
/// 把「#219975 标题 · State」洗成纯 Id 再落库。
/// 选中事件负责即时回填,这里是最后一道保险:事件万一没触发(比如某些自绘交互),
/// 写进 config / 传给 TFS 的仍是干净 Id,不会出现「挂不上父项却看不出原因」。
/// </summary>
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;
}
/// <summary>
/// 连接类失败的统一呈现:TF30063(服务器拒绝当前身份)与网络不通是两回事,
/// 前者该去填账号密码,后者才查 VPN/代理,提示分开写才能真正指引到动作。
/// </summary>
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 或未连内网。");
}
}
/// <summary>
/// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。
/// Items.Clear 会把 DropDown 样式的 Text 一并清空,所以要在清空前取值、匹配后回填。
/// </summary>
private static void FillPathCombo(ComboBox combo, List<string> 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));
}
}
}
/// <summary>
/// 导出对话框起始目录:优先当前 Excel 所在目录,其次桌面。
/// 显式给目录,避免对话框落到"系统记住的上次目录"而出乎意料。
/// </summary>
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;
}
/// <summary>
/// 覆盖确认。系统默认提示只问"是否替换",看不出目标文件其实是用户已有的数据表,
/// 这里把路径、大小、修改时间和已有数据行数都摆出来,避免把导入数据当模板覆盖掉。
/// </summary>
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";
}
/// <summary>
/// 导出前确认模板来源。exe 同目录的 TFS导入模板.xlsx 一旦被现场替换、或被填过数据,
/// 导出结果就不再是空白模板——用户会看到"导出的模板里带着别的数据"。
/// 这里把选择权交出来:内置空白模板 / 仍用该文件 / 不导出。
/// </summary>
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<WorkItemRow>();
_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<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),
// Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域
new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim())
};
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.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));
// Original Estimate 默认取 Completed 的值
if (completed != null && origEstRef != null)
fields.Add(new KeyValuePair<string, object>(origEstRef, completed));
if (!string.IsNullOrWhiteSpace(row.Description))
fields.Add(new KeyValuePair<string, object>("System.Description", row.Description.Trim()));
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
if (logDate != null && logRef != null)
fields.Add(new KeyValuePair<string, object>(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);
}
}
}
}