feat: 增加父 PBI 列表加载功能与界面优化
- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。 - 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。 - 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。 - 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。 - 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
This commit is contained in:
@@ -68,9 +68,9 @@ python tools/win_screenshot.py TfsExcelImporter\bin\Release\net48\TfsExcelImport
|
|||||||
界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。
|
界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。
|
||||||
|
|
||||||
1. **步骤 1 连接**:填 URL、项目、工作项类型与账号密码(认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user`),点「测试连接」——右上状态灯变绿即连通。
|
1. **步骤 1 连接**:填 URL、项目、工作项类型与账号密码(认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user`),点「测试连接」——右上状态灯变绿即连通。
|
||||||
2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);**Area 路径同样在这一步填,且为必填**(新导入的工作项都写到该 Area,留空会导致团队视图看不到,所以导入前会被拦下),点「加载 Area 列表」可从服务器拉真实 Area 树,也可手填完整路径;点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项);勾「跳过已存在标题」(默认不勾)按「项目/类型/迭代 + 标题」去重。字段校验**不可绕过**,不合法时日志会直接告诉你改哪一列。
|
2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);**Area 路径同样在这一步填,且为必填**(新导入的工作项都写到该 Area,留空会导致团队视图看不到,所以导入前会被拦下),点「加载 Area 列表」可从服务器拉真实 Area 树,也可手填完整路径;点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项),点「加载 PBI 列表」可拉出该迭代下的 Product Backlog Item(下拉显示「#Id 标题 · 状态」,选中自动回填纯 Id),也可直接手输 Id。字段校验**不可绕过**,不合法时日志会直接告诉你改哪一列。
|
||||||
3. **步骤 3 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。
|
3. **步骤 3 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。
|
||||||
4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。
|
4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。主按钮右侧的「跳过已存在标题」(默认不勾)是去重开关,按「项目/类型/迭代 + 标题」判重——它只作用于导入这一个动作,勾上后立即生效于下一次导入;导入跑起来后该勾选框会置灰(开关在一次导入开始时只读一次)。
|
||||||
5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;两者都按第 2 步的「目标迭代 / Area 路径」筛选已有工作项。
|
5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;两者都按第 2 步的「目标迭代 / Area 路径」筛选已有工作项。
|
||||||
|
|
||||||
桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。
|
桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。
|
||||||
@@ -110,7 +110,7 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\
|
|||||||
| `AreaPath` | 区域路径;导入必填,界面上与迭代同在第 2 步卡片(可点「加载 Area 列表」从 TFS 分类树拉取),值直接写进 `System.AreaPath` |
|
| `AreaPath` | 区域路径;导入必填,界面上与迭代同在第 2 步卡片(可点「加载 Area 列表」从 TFS 分类树拉取),值直接写进 `System.AreaPath` |
|
||||||
| `Username` / `Password` | 认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user` |
|
| `Username` / `Password` | 认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user` |
|
||||||
| `FinancialProjectRef` / `CompletedRef` / `LogDateRef` / `OriginalEstimateRef` | 自定义字段引用名;留空则按显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) |
|
| `FinancialProjectRef` / `CompletedRef` / `LogDateRef` / `OriginalEstimateRef` | 自定义字段引用名;留空则按显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) |
|
||||||
| `ParentPbiId` | 所有 Task 默认挂接的父 PBI Id;Excel 每行 `PBI Id` 列可覆盖 |
|
| `ParentPbiId` | 所有 Task 默认挂接的父 PBI Id(界面上可从「加载 PBI 列表」里挑,落库只存纯数字);Excel 每行 `PBI Id` 列可覆盖 |
|
||||||
| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重;**默认不勾选**,需要去重时手动勾 |
|
| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重;**默认不勾选**,需要去重时手动勾 |
|
||||||
| `DefaultState` | Excel `State` 列留空时的默认值 |
|
| `DefaultState` | Excel `State` 列留空时的默认值 |
|
||||||
| `LastExcelPath` | 上次选中的数据文件,界面回填用 |
|
| `LastExcelPath` | 上次选中的数据文件,界面回填用 |
|
||||||
@@ -154,4 +154,4 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
桌面版改完,先用只含几行的 Excel 跑一次小批量导入(先删服务器已有同标题项或勾「跳过已存在标题」),再在积压工作页展开父项确认子任务可见;改动涉及界面布局或图标时,跑 `python tools/win_screenshot.py` 抓图核对。确认无误后再跑全量。
|
桌面版改完,先用只含几行的 Excel 跑一次小批量导入(先删服务器已有同标题项或勾操作条上「跳过已存在标题」),再在积压工作页展开父项确认子任务可见;改动涉及界面布局或图标时,跑 `python tools/win_screenshot.py` 抓图核对。确认无误后再跑全量。
|
||||||
|
|||||||
+116
-13
@@ -38,17 +38,25 @@ namespace TfsExcelImporter
|
|||||||
_btnTest.Click += async (s, e) => await TestConnectionAsync();
|
_btnTest.Click += async (s, e) => await TestConnectionAsync();
|
||||||
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
|
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
|
||||||
_btnLoadAreas.Click += async (s, e) => await LoadAreasAsync();
|
_btnLoadAreas.Click += async (s, e) => await LoadAreasAsync();
|
||||||
|
_btnLoadPbi.Click += async (s, e) => await LoadBacklogAsync();
|
||||||
_btnTemplate.Click += (s, e) => ExportTemplate();
|
_btnTemplate.Click += (s, e) => ExportTemplate();
|
||||||
_btnPreview.Click += async (s, e) => await PreviewDataAsync();
|
_btnPreview.Click += async (s, e) => await PreviewDataAsync();
|
||||||
_btnImport.Click += async (s, e) => await ImportAsync();
|
_btnImport.Click += async (s, e) => await ImportAsync();
|
||||||
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
|
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
|
||||||
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
|
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
|
||||||
|
|
||||||
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd,
|
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd, _txtExcel })
|
||||||
_txtExcel, _txtParentPbi })
|
|
||||||
tb.TextChanged += (s, e) => OnFieldChanged();
|
tb.TextChanged += (s, e) => OnFieldChanged();
|
||||||
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
|
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
|
||||||
_cmbArea.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();
|
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
|
||||||
|
|
||||||
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
|
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
|
||||||
@@ -72,7 +80,7 @@ namespace TfsExcelImporter
|
|||||||
_txtExcel.Text = _cfg.LastExcelPath;
|
_txtExcel.Text = _cfg.LastExcelPath;
|
||||||
_cmbArea.Text = _cfg.AreaPath;
|
_cmbArea.Text = _cfg.AreaPath;
|
||||||
_chkSkip.Checked = _cfg.SkipExistingTitles;
|
_chkSkip.Checked = _cfg.SkipExistingTitles;
|
||||||
_txtParentPbi.Text = _cfg.ParentPbiId;
|
_cmbParent.Text = _cfg.ParentPbiId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveUiToConfig()
|
private void SaveUiToConfig()
|
||||||
@@ -86,7 +94,7 @@ namespace TfsExcelImporter
|
|||||||
_cfg.LastExcelPath = _txtExcel.Text.Trim();
|
_cfg.LastExcelPath = _txtExcel.Text.Trim();
|
||||||
_cfg.AreaPath = _cmbArea.Text.Trim();
|
_cfg.AreaPath = _cmbArea.Text.Trim();
|
||||||
_cfg.SkipExistingTitles = _chkSkip.Checked;
|
_cfg.SkipExistingTitles = _chkSkip.Checked;
|
||||||
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
|
_cfg.ParentPbiId = NormalizePbiText(_cmbParent.Text);
|
||||||
_cfg.Save();
|
_cfg.Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +216,101 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
var msg = FirstLine(ex.Message);
|
||||||
// TF30063 = 服务器拒绝当前身份:与「连不上」是两回事,提示要能让用户知道该去填账号密码
|
|
||||||
if (msg.IndexOf("TF30063", StringComparison.OrdinalIgnoreCase) >= 0)
|
if (msg.IndexOf("TF30063", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
{
|
{
|
||||||
SetConnection("认证失败", ChipKind.Danger);
|
SetConnection("认证失败", ChipKind.Danger);
|
||||||
@@ -223,17 +323,11 @@ namespace TfsExcelImporter
|
|||||||
{
|
{
|
||||||
SetConnection("连接失败", ChipKind.Danger);
|
SetConnection("连接失败", ChipKind.Danger);
|
||||||
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
||||||
ShowError(err, field, _cardData, $"{what} 列表加载失败,可手填完整路径");
|
ShowError(err, field, _cardData, $"{what} 列表加载失败,可手动填写");
|
||||||
Log($"加载 {what} 失败: " + msg);
|
Log($"加载 {what} 失败: " + msg);
|
||||||
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
|
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetBusy(false);
|
|
||||||
OnFieldChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。
|
/// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。
|
||||||
@@ -531,7 +625,7 @@ namespace TfsExcelImporter
|
|||||||
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
|
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
|
||||||
{
|
{
|
||||||
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
|
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
|
||||||
_txtParentPbi.Focus();
|
_cmbParent.Focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var confirm = MessageBox.Show(this,
|
var confirm = MessageBox.Show(this,
|
||||||
@@ -583,6 +677,15 @@ namespace TfsExcelImporter
|
|||||||
_cmbArea.Focus();
|
_cmbArea.Focus();
|
||||||
return;
|
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();
|
var path = _txtExcel.Text.Trim();
|
||||||
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
if (string.IsNullOrEmpty(path) || !File.Exists(path))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ namespace TfsExcelImporter
|
|||||||
private ComboBox _cmbIteration = new ComboBox();
|
private ComboBox _cmbIteration = new ComboBox();
|
||||||
private ComboBox _cmbArea = new ComboBox();
|
private ComboBox _cmbArea = new ComboBox();
|
||||||
private TextBox _txtExcel = new TextBox();
|
private TextBox _txtExcel = new TextBox();
|
||||||
private TextBox _txtParentPbi = new TextBox();
|
private ComboBox _cmbParent = new ComboBox();
|
||||||
private Button _btnLoadIterations = new Button();
|
private Button _btnLoadIterations = new Button();
|
||||||
private Button _btnLoadAreas = new Button();
|
private Button _btnLoadAreas = new Button();
|
||||||
|
private Button _btnLoadPbi = new Button();
|
||||||
private Button _btnTemplate = new Button();
|
private Button _btnTemplate = new Button();
|
||||||
private CheckBox _chkSkip = new CheckBox();
|
private CheckBox _chkSkip = new CheckBox();
|
||||||
|
|
||||||
@@ -341,20 +342,25 @@ namespace TfsExcelImporter
|
|||||||
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨
|
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨
|
||||||
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
|
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
|
||||||
_cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填
|
_cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填
|
||||||
|
_cmbParent.DropDownStyle = ComboBoxStyle.DropDown; // 可直接输 Id,也可从列表挑
|
||||||
|
// 不做自定义 DropDownWidth:字段已和迭代同宽,下拉面板跟着控件宽走,
|
||||||
|
// 形态与「目标迭代」完全一致,也不再有宽度写死后与实际布局对不上的问题。
|
||||||
|
|
||||||
_fIteration = NewField(_cmbIteration, 500, false);
|
_fIteration = NewField(_cmbIteration, 500, false);
|
||||||
_fArea = NewField(_cmbArea, 500, true);
|
_fArea = NewField(_cmbArea, 500, true);
|
||||||
_fExcel = NewField(_txtExcel, 500, true);
|
_fExcel = NewField(_txtExcel, 500, true);
|
||||||
_fParent = NewField(_txtParentPbi, 150, true);
|
_fParent = NewField(_cmbParent, 500, true);
|
||||||
|
|
||||||
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
|
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
|
||||||
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
|
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
|
||||||
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
|
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
|
||||||
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
|
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
|
||||||
_fParent.SetCue("如 219975");
|
// 行尾不再放说明文字,规则并进 cue:字段宽度与迭代一致后,这里放得下完整提示
|
||||||
|
_fParent.SetCue("选择或手填父 PBI Id,如 219975;Excel 里加 PBI Id 列可逐行覆盖");
|
||||||
|
|
||||||
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
|
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
|
||||||
_btnLoadAreas = NewButton("加载 Area 列表", 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);
|
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
|
||||||
// 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上,
|
// 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上,
|
||||||
// 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的
|
// 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的
|
||||||
@@ -362,8 +368,8 @@ namespace TfsExcelImporter
|
|||||||
int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width);
|
int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width);
|
||||||
_btnLoadIterations.Width = wBtn;
|
_btnLoadIterations.Width = wBtn;
|
||||||
_btnLoadAreas.Width = wBtn;
|
_btnLoadAreas.Width = wBtn;
|
||||||
|
_btnLoadPbi.Width = wBtn;
|
||||||
_btnTemplate.Width = wBtn;
|
_btnTemplate.Width = wBtn;
|
||||||
_chkSkip = NewCheck("跳过已存在标题", "");
|
|
||||||
|
|
||||||
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
|
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
|
||||||
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
|
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
|
||||||
@@ -383,12 +389,14 @@ namespace TfsExcelImporter
|
|||||||
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
|
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
|
||||||
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
|
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
|
||||||
Track(_fExcel, _cardData, wBtn + G + M, 0);
|
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 列可逐行覆盖"));
|
// Id 本身不可读,列出来才敢确认挂的是哪条。
|
||||||
// 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。
|
y = Row(_cardData, y, 52, new Lbl("父 PBI Id", wLbl), _fParent);
|
||||||
// 只有「去重」这一个开关;字段校验不可绕过(OM 14.x 无 BypassRules),失败时由
|
AnchorRight(_btnLoadPbi, _cardData, M, _fParent.Top);
|
||||||
// TfsClient 输出可操作的修复提示。
|
Track(_fParent, _cardData, wBtn + G + M, 0);
|
||||||
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
|
// 「跳过已存在标题」不在这张卡片里:它是「开始导入」这个动作的开关,已挪到步骤 4 操作条
|
||||||
|
// 紧贴主按钮(卡片收起后只看得到一行摘要,勾没勾都看不见,容易被当成「没勾」)。
|
||||||
|
// 字段校验不可绕过(OM 14.x 无 BypassRules),失败时由 TfsClient 输出可操作的修复提示。
|
||||||
|
|
||||||
_errIteration = AddErrorUnder(_cardData, _fIteration);
|
_errIteration = AddErrorUnder(_cardData, _fIteration);
|
||||||
_errArea = AddErrorUnder(_cardData, _fArea);
|
_errArea = AddErrorUnder(_cardData, _fArea);
|
||||||
@@ -440,6 +448,10 @@ namespace TfsExcelImporter
|
|||||||
// 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条
|
// 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条
|
||||||
_btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148);
|
_btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148);
|
||||||
// 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐
|
// 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐
|
||||||
|
|
||||||
|
// 去重开关紧贴主按钮右侧:它是「开始导入」这一个动作的开关(一次导入只在开始时读一次),
|
||||||
|
// 挂在按钮旁边才不会被误读成某个字段的属性。
|
||||||
|
_chkSkip = NewCheck("跳过已存在标题", "");
|
||||||
_pbar = new ProgressTrack
|
_pbar = new ProgressTrack
|
||||||
{
|
{
|
||||||
Minimum = 0,
|
Minimum = 0,
|
||||||
@@ -463,6 +475,7 @@ namespace TfsExcelImporter
|
|||||||
_actionBar.Controls.Add(_badgeRun);
|
_actionBar.Controls.Add(_badgeRun);
|
||||||
_actionBar.Controls.Add(_btnPreview);
|
_actionBar.Controls.Add(_btnPreview);
|
||||||
_actionBar.Controls.Add(_btnImport);
|
_actionBar.Controls.Add(_btnImport);
|
||||||
|
_actionBar.Controls.Add(_chkSkip);
|
||||||
_actionBar.Controls.Add(_pbar);
|
_actionBar.Controls.Add(_pbar);
|
||||||
_actionBar.Controls.Add(_chipOk);
|
_actionBar.Controls.Add(_chipOk);
|
||||||
_actionBar.Controls.Add(_chipSkip);
|
_actionBar.Controls.Add(_chipSkip);
|
||||||
@@ -484,19 +497,36 @@ namespace TfsExcelImporter
|
|||||||
Theme.Place(_btnImport, _btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2,
|
Theme.Place(_btnImport, _btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2,
|
||||||
_btnImport.Width, _btnImport.Height);
|
_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(_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(_chipSkip, _chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height);
|
||||||
Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
|
Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
|
||||||
// 状态文字按最长常规文案实测给宽,且只给一行的高度:
|
// 进度条至少要 120px 才像一条进度条(再窄就只是一段杂线):先按最长常规文案给状态文字留宽,
|
||||||
// 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。
|
// 宽度不够时再收窄状态文字(下限 120)——长文案本来就靠省略尾巴兜底,关键是别把进度条挤没。
|
||||||
int statW = StatusLabelWidth();
|
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);
|
int statH = Theme.Caption.Height + Theme.Px(4);
|
||||||
Theme.Place(_lblStatus, _chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH);
|
Theme.Place(_lblStatus, _chipOk.Left - G - statW, (_actionBar.Height - statH) / 2, statW, statH);
|
||||||
int left = _btnImport.Right + Theme.S16;
|
|
||||||
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
|
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
|
||||||
Theme.Place(_pbar, left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
|
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>
|
/// <summary>
|
||||||
/// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取),
|
/// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取),
|
||||||
/// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」)
|
/// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」)
|
||||||
@@ -555,11 +585,9 @@ namespace TfsExcelImporter
|
|||||||
_btnOpenReport.Width, _btnOpenReport.Height);
|
_btnOpenReport.Width, _btnOpenReport.Height);
|
||||||
Theme.Place(_btnClearLog, _btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2,
|
Theme.Place(_btnClearLog, _btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2,
|
||||||
_btnClearLog.Width, _btnClearLog.Height);
|
_btnClearLog.Width, _btnClearLog.Height);
|
||||||
// net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框);
|
// net48 的 CheckBox 没有 PreferredWidth:宽高都由 CheckBoxSize 量出来,整体压在中线上
|
||||||
// 高度取原生 PreferredHeight,整体压在中线上
|
int chkW, chkH;
|
||||||
var pref = _chkAutoScroll.GetPreferredSize(Size.Empty);
|
CheckBoxSize(_chkAutoScroll, Theme.Caption, out chkW, out chkH);
|
||||||
int chkH = Math.Max(Theme.Px(18), pref.Height);
|
|
||||||
int chkW = Math.Max(Theme.TextWidth(_chkAutoScroll.Text, Theme.Caption) + Theme.Px(22), pref.Width);
|
|
||||||
Theme.Place(_chkAutoScroll, _btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, 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.IsNullOrWhiteSpace(_cmbArea.Text) ? "Area 未填" : "Area " + _cmbArea.Text.Trim(),
|
||||||
string.IsNullOrEmpty(fileName) ? "未选 Excel" : fileName,
|
string.IsNullOrEmpty(fileName) ? "未选 Excel" : fileName,
|
||||||
_rows.Count > 0 ? _rows.Count + " 行" : "未预览",
|
_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
|
_cardPreview.SetSummary(_rows.Count > 0
|
||||||
? "共 " + _rows.Count + " 行,可直接开始导入"
|
? "共 " + _rows.Count + " 行,可直接开始导入"
|
||||||
@@ -1027,8 +1055,11 @@ namespace TfsExcelImporter
|
|||||||
_btnTest.Enabled = !busy;
|
_btnTest.Enabled = !busy;
|
||||||
_btnLoadIterations.Enabled = !busy;
|
_btnLoadIterations.Enabled = !busy;
|
||||||
_btnLoadAreas.Enabled = !busy;
|
_btnLoadAreas.Enabled = !busy;
|
||||||
|
_btnLoadPbi.Enabled = !busy;
|
||||||
_btnFixArea.Enabled = !busy;
|
_btnFixArea.Enabled = !busy;
|
||||||
_btnLinkParent.Enabled = !busy;
|
_btnLinkParent.Enabled = !busy;
|
||||||
|
// 去重开关在导入开始时只读一次:跑起来再改会误导(看着改了,本轮其实不生效),直接置灰
|
||||||
|
_chkSkip.Enabled = !busy;
|
||||||
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
|
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
|
||||||
_btnTemplate.Enabled = !busy;
|
_btnTemplate.Enabled = !busy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,97 @@ namespace TfsExcelImporter
|
|||||||
return project + "\\" + string.Join("\\", segs);
|
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>
|
/// <summary>查询指定迭代下同类型工作项的标题集合。</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user