feat: 增加父 PBI 列表加载功能与界面优化

- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。
- 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。
- 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。
- 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。
- 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
This commit is contained in:
chenshoulin
2026-11-02 13:07:58 +08:00
parent 14f5e32262
commit fe23e1c64a
4 changed files with 273 additions and 48 deletions
+126 -23
View File
@@ -38,17 +38,25 @@ namespace TfsExcelImporter
_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, _txtParentPbi })
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 { } };
@@ -72,7 +80,7 @@ namespace TfsExcelImporter
_txtExcel.Text = _cfg.LastExcelPath;
_cmbArea.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_txtParentPbi.Text = _cfg.ParentPbiId;
_cmbParent.Text = _cfg.ParentPbiId;
}
private void SaveUiToConfig()
@@ -86,7 +94,7 @@ namespace TfsExcelImporter
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _cmbArea.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.ParentPbiId = NormalizePbiText(_cmbParent.Text);
_cfg.Save();
}
@@ -209,24 +217,7 @@ namespace TfsExcelImporter
}
catch (Exception ex)
{
var msg = FirstLine(ex.Message);
// TF30063 = 服务器拒绝当前身份:与「连不上」是两回事,提示要能让用户知道该去填账号密码
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 或未连内网。");
}
ReportTfsFailure(ex, what, field, err);
}
finally
{
@@ -235,6 +226,109 @@ namespace TfsExcelImporter
}
}
// ------------------------------------------------------------- 父 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 一并清空,所以要在清空前取值、匹配后回填。
@@ -531,7 +625,7 @@ namespace TfsExcelImporter
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
{
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
_txtParentPbi.Focus();
_cmbParent.Focus();
return;
}
var confirm = MessageBox.Show(this,
@@ -583,6 +677,15 @@ namespace TfsExcelImporter
_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))
{
+52 -21
View File
@@ -23,9 +23,10 @@ namespace TfsExcelImporter
private ComboBox _cmbIteration = new ComboBox();
private ComboBox _cmbArea = new ComboBox();
private TextBox _txtExcel = new TextBox();
private TextBox _txtParentPbi = new TextBox();
private ComboBox _cmbParent = new ComboBox();
private Button _btnLoadIterations = new Button();
private Button _btnLoadAreas = new Button();
private Button _btnLoadPbi = new Button();
private Button _btnTemplate = new Button();
private CheckBox _chkSkip = new CheckBox();
@@ -341,20 +342,25 @@ namespace TfsExcelImporter
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
_cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填
_cmbParent.DropDownStyle = ComboBoxStyle.DropDown; // 可直接输 Id,也可从列表挑
// 不做自定义 DropDownWidth:字段已和迭代同宽,下拉面板跟着控件宽走,
// 形态与「目标迭代」完全一致,也不再有宽度写死后与实际布局对不上的问题。
_fIteration = NewField(_cmbIteration, 500, false);
_fArea = NewField(_cmbArea, 500, true);
_fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_txtParentPbi, 150, true);
_fParent = NewField(_cmbParent, 500, true);
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
_fParent.SetCue("如 219975");
// 行尾不再放说明文字,规则并进 cue:字段宽度与迭代一致后,这里放得下完整提示
_fParent.SetCue("选择或手填父 PBI Id,如 219975;Excel 里加 PBI Id 列可逐行覆盖");
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnLoadAreas = NewButton("加载 Area 列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnLoadPbi = NewButton("加载 PBI 列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
// 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上,
// 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的
@@ -362,8 +368,8 @@ namespace TfsExcelImporter
int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width);
_btnLoadIterations.Width = wBtn;
_btnLoadAreas.Width = wBtn;
_btnLoadPbi.Width = wBtn;
_btnTemplate.Width = wBtn;
_chkSkip = NewCheck("跳过已存在标题", "");
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
@@ -383,12 +389,14 @@ namespace TfsExcelImporter
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
Track(_fExcel, _cardData, wBtn + G + M, 0);
y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent,
new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖"));
// 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。
// 只有「去重」这一个开关;字段校验不可绕过(OM 14.x 无 BypassRules),失败时由
// TfsClient 输出可操作的修复提示。
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
// 父项行结构与上面三行完全一致:字段吃满宽度 + 行尾按钮右对齐。
// Id 本身不可读,列出来才敢确认挂的是哪条。
y = Row(_cardData, y, 52, new Lbl("父 PBI Id", wLbl), _fParent);
AnchorRight(_btnLoadPbi, _cardData, M, _fParent.Top);
Track(_fParent, _cardData, wBtn + G + M, 0);
// 「跳过已存在标题」不在这张卡片里:它是「开始导入」这个动作的开关,已挪到步骤 4 操作条
// 紧贴主按钮(卡片收起后只看得到一行摘要,勾没勾都看不见,容易被当成「没勾」)。
// 字段校验不可绕过(OM 14.x 无 BypassRules),失败时由 TfsClient 输出可操作的修复提示。
_errIteration = AddErrorUnder(_cardData, _fIteration);
_errArea = AddErrorUnder(_cardData, _fArea);
@@ -440,6 +448,10 @@ namespace TfsExcelImporter
// 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条
_btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148);
// 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐
// 去重开关紧贴主按钮右侧:它是「开始导入」这一个动作的开关(一次导入只在开始时读一次),
// 挂在按钮旁边才不会被误读成某个字段的属性。
_chkSkip = NewCheck("跳过已存在标题", "");
_pbar = new ProgressTrack
{
Minimum = 0,
@@ -463,6 +475,7 @@ namespace TfsExcelImporter
_actionBar.Controls.Add(_badgeRun);
_actionBar.Controls.Add(_btnPreview);
_actionBar.Controls.Add(_btnImport);
_actionBar.Controls.Add(_chkSkip);
_actionBar.Controls.Add(_pbar);
_actionBar.Controls.Add(_chipOk);
_actionBar.Controls.Add(_chipSkip);
@@ -484,19 +497,36 @@ namespace TfsExcelImporter
Theme.Place(_btnImport, _btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2,
_btnImport.Width, _btnImport.Height);
// 去重开关:与两个按钮同一条中线,纵向按勾选框自身高度居中
int chkW, chkH;
CheckBoxSize(_chkSkip, Theme.Body, out chkW, out chkH);
Theme.Place(_chkSkip, _btnImport.Right + Theme.S16, (_actionBar.Height - chkH) / 2, chkW, chkH);
Theme.Place(_chipFail, _actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height);
Theme.Place(_chipSkip, _chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height);
Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
// 状态文字按最长常规文案实测给宽,且只给一行的高度:
// 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。
int statW = StatusLabelWidth();
// 进度条至少要 120px 才像一条进度条(再窄就只是一段杂线):先按最长常规文案给状态文字留宽,
// 宽度不够时再收窄状态文字(下限 120)——长文案本来就靠省略尾巴兜底,关键是别把进度条挤没。
int left = _chkSkip.Right + Theme.S16;
int statRoom = _chipOk.Left - G - left - Theme.Px(120) - G;
int statW = Math.Min(StatusLabelWidth(), Math.Max(Theme.Px(120), statRoom));
int statH = Theme.Caption.Height + Theme.Px(4);
Theme.Place(_lblStatus, _chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH);
int left = _btnImport.Right + Theme.S16;
Theme.Place(_lblStatus, _chipOk.Left - G - statW, (_actionBar.Height - statH) / 2, statW, statH);
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
Theme.Place(_pbar, left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
}
/// <summary>
/// net48 的 CheckBox 没有 PreferredWidth/Height 的稳定值:宽度自己量(文字 + 勾选框),
/// 高度取原生 PreferredHeight 并留 18px 下限,调用方据此按中线精确居中。
/// </summary>
private static void CheckBoxSize(CheckBox chk, Font font, out int width, out int height)
{
var pref = chk.GetPreferredSize(Size.Empty);
height = Math.Max(Theme.Px(18), pref.Height);
width = Math.Max(Theme.TextWidth(chk.Text, font) + Theme.Px(22), pref.Width);
}
/// <summary>
/// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取),
/// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」)
@@ -555,11 +585,9 @@ namespace TfsExcelImporter
_btnOpenReport.Width, _btnOpenReport.Height);
Theme.Place(_btnClearLog, _btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2,
_btnClearLog.Width, _btnClearLog.Height);
// net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框);
// 高度取原生 PreferredHeight,整体压在中线上
var pref = _chkAutoScroll.GetPreferredSize(Size.Empty);
int chkH = Math.Max(Theme.Px(18), pref.Height);
int chkW = Math.Max(Theme.TextWidth(_chkAutoScroll.Text, Theme.Caption) + Theme.Px(22), pref.Width);
// net48 的 CheckBox 没有 PreferredWidth:宽高都由 CheckBoxSize 量出来,整体压在中线上
int chkW, chkH;
CheckBoxSize(_chkAutoScroll, Theme.Caption, out chkW, out chkH);
Theme.Place(_chkAutoScroll, _btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, chkH);
});
}
@@ -961,7 +989,7 @@ namespace TfsExcelImporter
string.IsNullOrWhiteSpace(_cmbArea.Text) ? "Area 未填" : "Area " + _cmbArea.Text.Trim(),
string.IsNullOrEmpty(fileName) ? "未选 Excel" : fileName,
_rows.Count > 0 ? _rows.Count + " 行" : "未预览",
string.IsNullOrWhiteSpace(_txtParentPbi.Text) ? "父 PBI 未填" : "父 PBI #" + _txtParentPbi.Text.Trim()
string.IsNullOrWhiteSpace(_cmbParent.Text) ? "父 PBI 未填" : "父 PBI #" + _cmbParent.Text.Trim()
}));
_cardPreview.SetSummary(_rows.Count > 0
? "共 " + _rows.Count + " 行,可直接开始导入"
@@ -1027,8 +1055,11 @@ namespace TfsExcelImporter
_btnTest.Enabled = !busy;
_btnLoadIterations.Enabled = !busy;
_btnLoadAreas.Enabled = !busy;
_btnLoadPbi.Enabled = !busy;
_btnFixArea.Enabled = !busy;
_btnLinkParent.Enabled = !busy;
// 去重开关在导入开始时只读一次:跑起来再改会误导(看着改了,本轮其实不生效),直接置灰
_chkSkip.Enabled = !busy;
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
_btnTemplate.Enabled = !busy;
}
+91
View File
@@ -169,6 +169,97 @@ namespace TfsExcelImporter
return project + "\\" + string.Join("\\", segs);
}
// ------------------------------------------------------------- 父项(backlog item)
/// <summary>下拉用的轻量工作项快照:只要 Id / 标题 / 类型 / 状态 / 迭代。</summary>
public class WorkItemBrief
{
public int Id;
public string Title;
public string Type;
public string State;
public string IterationPath;
/// <summary>
/// 列表里给人看的文本:Id 打头,后面是标题;状态只在「非 New」时追加。
/// 新建未审批的 PBI 全是 New,逐条显示纯属噪音——真正要看的是 Done/Closed 这类
/// 挂上去也展不开的父项,让异常状态自己显形就够了。
/// </summary>
public override string ToString()
{
var tail = string.IsNullOrWhiteSpace(State)
|| string.Equals(State, "New", StringComparison.OrdinalIgnoreCase)
? "" : " · " + State;
return "#" + Id + " " + (Title ?? "") + tail;
}
}
/// <summary>
/// 查可当父项用的工作项(默认 Product Backlog Item)。
/// 先按目标迭代过滤;该迭代下一条都没有时放宽到全项目——迭代里还没建 PBI 是常态,
/// 直接返回空列表会让人以为「接口没通」。
/// </summary>
public async Task<List<WorkItemBrief>> GetBacklogItemsAsync(string project, string iterationPath,
Action<string> log = null)
{
var scoped = await QueryWorkItemsAsync(project, iterationPath, "Product Backlog Item");
if (scoped.Count > 0)
{
log?.Invoke($"父项候选: 迭代「{iterationPath}」下的 Product Backlog Item {scoped.Count} 条");
return scoped;
}
log?.Invoke("父项候选: 该迭代下没有 Product Backlog Item,放宽到全项目");
var all = await QueryWorkItemsAsync(project, null, "Product Backlog Item");
if (all.Count > 0)
{
log?.Invoke($"父项候选: 全项目 Product Backlog Item {all.Count} 条");
return all;
}
// 非敏捷模板(CMMI/Scrum 的老项目)没有 PBI 类型:退化为「除 Task 外的所有工作项」
var fallback = await QueryWorkItemsAsync(project, iterationPath, null, excludeType: "Task");
log?.Invoke($"父项候选: 未找到 Product Backlog Item,退回「非 Task 工作项」{fallback.Count} 条");
return fallback;
}
/// <summary>
/// WIQL 取工作项快照。workItemType 为空时按 excludeType 反向过滤(排除导入类型本身)。
/// iterationPath 为空时不限迭代。
/// </summary>
private async Task<List<WorkItemBrief>> QueryWorkItemsAsync(string project, string iterationPath,
string workItemType, string excludeType = null)
{
return await Task.Run(() =>
{
var wiql = new StringBuilder(
"SELECT [System.Id], [System.Title], [System.WorkItemType], [System.State], [System.IterationPath] " +
"FROM WorkItems WHERE ");
wiql.Append($"[System.TeamProject] = '{EscapeWiql(project)}'");
if (!string.IsNullOrWhiteSpace(iterationPath))
wiql.Append($" AND [System.IterationPath] = '{EscapeWiql(iterationPath)}'");
if (!string.IsNullOrWhiteSpace(workItemType))
wiql.Append($" AND [System.WorkItemType] = '{EscapeWiql(workItemType)}'");
else if (!string.IsNullOrWhiteSpace(excludeType))
wiql.Append($" AND [System.WorkItemType] <> '{EscapeWiql(excludeType)}'");
wiql.Append(" ORDER BY [System.Id]");
var list = new List<WorkItemBrief>();
foreach (WorkItem wi in Store.Query(wiql.ToString()))
{
list.Add(new WorkItemBrief
{
Id = wi.Id,
Title = wi.Title,
Type = Convert.ToString(wi.Fields["System.WorkItemType"].Value),
State = Convert.ToString(wi.Fields["System.State"].Value),
IterationPath = Convert.ToString(wi.Fields["System.IterationPath"].Value)
});
}
return list;
});
}
// ------------------------------------------------------------- 查重
/// <summary>查询指定迭代下同类型工作项的标题集合。</summary>