feat: 增强数据导入功能与用户界面优化
- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。 - 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。 - 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。 - 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。 - 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
This commit is contained in:
@@ -68,10 +68,10 @@ 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 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项);勾「跳过已存在标题」按「项目/类型/迭代 + 标题」去重。字段校验**不可绕过**,不合法时日志会直接告诉你改哪一列。
|
2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);**Area 路径同样在这一步填,且为必填**(新导入的工作项都写到该 Area,留空会导致团队视图看不到,所以导入前会被拦下),点「加载 Area 列表」可从服务器拉真实 Area 树,也可手填完整路径;点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI 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」「补挂父项」批量纠正;Area 路径也在这一栏里填(以前只能手改 config.json)。
|
5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;两者都按第 2 步的「目标迭代 / Area 路径」筛选已有工作项。
|
||||||
|
|
||||||
桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。
|
桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。
|
||||||
|
|
||||||
@@ -107,11 +107,11 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `TfsUrl` / `Project` / `WorkItemType` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) |
|
| `TfsUrl` / `Project` / `WorkItemType` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) |
|
||||||
| `IterationPath` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 |
|
| `IterationPath` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 |
|
||||||
| `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;Excel 每行 `PBI Id` 列可覆盖 |
|
||||||
| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重 |
|
| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重;**默认不勾选**,需要去重时手动勾 |
|
||||||
| `DefaultState` | Excel `State` 列留空时的默认值 |
|
| `DefaultState` | Excel `State` 列留空时的默认值 |
|
||||||
| `LastExcelPath` | 上次选中的数据文件,界面回填用 |
|
| `LastExcelPath` | 上次选中的数据文件,界面回填用 |
|
||||||
|
|
||||||
@@ -145,6 +145,7 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\
|
|||||||
|
|
||||||
- **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。
|
- **wit REST 路由全 404**:TFS 2013 RTM/低 Update 不带 `/_apis/wit/*`,`Classification.asmx?WSDL` 也被禁 → 桌面版整体走 Client OM(NuGet `Microsoft.TeamFoundationServer.ExtendedClient` 钉 `14.*`,对应 VS2015 客户端代际;15.x 不兼容 TFS 2013)。
|
||||||
- **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版固定把 `WebRequest.DefaultWebProxy` 置空强制直连,界面不提供代理开关(本机若只能经代理上网,程序会直连失败)。
|
- **502 Bad Gateway**:TFS OM 内部用 `HttpWebRequest`,默认跟随 IE 系统代理,代理会拦内网 TFS。桌面版固定把 `WebRequest.DefaultWebProxy` 置空强制直连,界面不提供代理开关(本机若只能经代理上网,程序会直连失败)。
|
||||||
|
- **Area 与迭代同源**:两者都是 `ICommonStructureService` 的分类树(Area = `ProjectModelHierarchy`,迭代 = `ProjectLifecycle`),`GetNodesXml` 深度展开后取每个节点的 `Path`,再由 `NormalizeNodePath` 去掉项目名前缀与 `Area`/`Iteration` 根段、拼回 `项目\子...`。项目根 Area 归一化后为空会被过滤,所以下拉里只有子区域;列表拉不到时手填完整路径一样可用。`TF30063` 是「服务器拒绝当前身份」(填步骤 1 的账号密码),与 DNS/代理导致的连不上是两码事,界面已分开提示。
|
||||||
- **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。
|
- **TF400276 / 关闭状态只读**:改已关闭工作项的 Area 会被拒;`FixAreaAsync` 走「重新打开 → 改 Area → 还原状态」的状态循环,中途失败回滚状态。
|
||||||
- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时按普通字段可尝试写入。模板已去掉日期列,所以导入不再走这条路——若某模板要求 `Done` 必填 Closed Date,需另加列或改回写日期。
|
- **`System.CreatedDate` 写不进去**:OM 14.x 没有 `SaveFlags.BypassRules`(15.x 才加),该字段服务器托管;`Microsoft.VSTS.Common.ClosedDate` 在 `State=Done` 时按普通字段可尝试写入。模板已去掉日期列,所以导入不再走这条路——若某模板要求 `Done` 必填 Closed Date,需另加列或改回写日期。
|
||||||
- **字段校验不可绕过**:OM 14.x 没有 `SaveFlags.BypassRules`,旧界面上那个 `bypassRules` 勾选从未真正生效,已移除。校验失败时日志与 report CSV 直接给出「字段(原因) → 怎么改」,列表字段还会列出允许值,按提示改 Excel 对应列后重跑即可。
|
- **字段校验不可绕过**:OM 14.x 没有 `SaveFlags.BypassRules`,旧界面上那个 `bypassRules` 勾选从未真正生效,已移除。校验失败时日志与 report CSV 直接给出「字段(原因) → 怎么改」,列表字段还会列出允许值,按提示改 Excel 对应列后重跑即可。
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ namespace TfsExcelImporter
|
|||||||
// 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules
|
// 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules
|
||||||
// (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由
|
// (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由
|
||||||
// Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。
|
// Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。
|
||||||
public bool SkipExistingTitles { get; set; } = true;
|
// 默认不勾选:首次导入时希望先看到真实冲突/报错,而不是被静默跳过
|
||||||
|
public bool SkipExistingTitles { get; set; } = false;
|
||||||
public string DefaultState { get; set; } = "To Do";
|
public string DefaultState { get; set; } = "To Do";
|
||||||
|
|
||||||
// 自定义字段引用名;留空则按 DisplayName 自动解析
|
// 自定义字段引用名;留空则按 DisplayName 自动解析
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
|
|
||||||
namespace TfsExcelImporter
|
namespace TfsExcelImporter
|
||||||
@@ -45,34 +46,128 @@ namespace TfsExcelImporter
|
|||||||
|
|
||||||
// --------------------------------------------------------- 默认模板
|
// --------------------------------------------------------- 默认模板
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>exe 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。</summary>
|
||||||
/// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换),
|
public static string LocalTemplatePath
|
||||||
/// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。
|
|
||||||
/// </summary>
|
|
||||||
public static void CopyTemplate(string path)
|
|
||||||
{
|
{
|
||||||
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
|
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); }
|
||||||
if (File.Exists(src))
|
|
||||||
{
|
|
||||||
File.Copy(src, path, true);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。
|
||||||
|
/// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。
|
||||||
|
/// </summary>
|
||||||
|
public static bool LocalTemplateIsBuiltIn()
|
||||||
|
{
|
||||||
|
var local = LocalTemplatePath;
|
||||||
|
if (!File.Exists(local)) return true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var embed = ReadEmbeddedTemplate();
|
||||||
|
return embed != null && SameDigest(embed, File.ReadAllBytes(local));
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。
|
||||||
|
/// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。
|
||||||
|
///
|
||||||
|
/// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。
|
||||||
|
/// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。
|
||||||
|
/// </summary>
|
||||||
|
public static string CopyTemplate(string path, bool forceEmbedded = false)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
throw new ArgumentException("导出路径为空", nameof(path));
|
||||||
|
|
||||||
|
var dst = Path.GetFullPath(path);
|
||||||
|
var src = LocalTemplatePath;
|
||||||
|
|
||||||
|
if (!forceEmbedded && File.Exists(src))
|
||||||
|
{
|
||||||
|
var srcFull = Path.GetFullPath(src);
|
||||||
|
// 目标就是源本身:File.Copy 会抛"源和目标是同一个文件",先给出可读提示
|
||||||
|
if (string.Equals(srcFull, dst, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new IOException(
|
||||||
|
"导出路径与模板源文件相同(" + srcFull + "),请换一个文件名或目录。");
|
||||||
|
|
||||||
|
File.Copy(srcFull, dst, true);
|
||||||
|
AssertSameContent(srcFull, dst);
|
||||||
|
return "exe 同目录 " + TemplateFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var embed = ReadEmbeddedTemplate();
|
||||||
|
if (embed == null)
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
|
||||||
|
|
||||||
|
// 先写临时文件再替换:中途失败不会把目标位置已有的文件写坏
|
||||||
|
var tmp = dst + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(tmp, embed);
|
||||||
|
File.Copy(tmp, dst, true);
|
||||||
|
AssertSameContent(tmp, dst);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
|
||||||
|
}
|
||||||
|
return "exe 内嵌模板";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ReadEmbeddedTemplate()
|
||||||
|
{
|
||||||
var asm = typeof(ExcelService).Assembly;
|
var asm = typeof(ExcelService).Assembly;
|
||||||
// 嵌入名取决于 Link 名:默认 <RootNamespace>.<文件名>
|
// 嵌入名取决于 Link 名:默认 <RootNamespace>.<文件名>
|
||||||
var name = asm.GetManifestResourceNames()
|
var name = asm.GetManifestResourceNames()
|
||||||
.FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase));
|
||||||
if (name == null)
|
if (name == null) return null;
|
||||||
throw new FileNotFoundException(
|
|
||||||
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
|
|
||||||
|
|
||||||
using (var input = asm.GetManifestResourceStream(name))
|
using (var input = asm.GetManifestResourceStream(name))
|
||||||
{
|
{
|
||||||
if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src);
|
if (input == null) return null;
|
||||||
using (var output = File.Create(path))
|
using (var buffer = new MemoryStream())
|
||||||
input.CopyTo(output);
|
{
|
||||||
|
input.CopyTo(buffer);
|
||||||
|
return buffer.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。</summary>
|
||||||
|
private static void AssertSameContent(string expected, string actual)
|
||||||
|
{
|
||||||
|
var a = new FileInfo(expected);
|
||||||
|
var b = new FileInfo(actual);
|
||||||
|
if (!b.Exists || b.Length != a.Length || !SameDigest(expected, actual))
|
||||||
|
throw new IOException("模板写出后校验不一致,目标文件内容与模板不同:" + actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameDigest(string x, string y)
|
||||||
|
{
|
||||||
|
return SameDigest(File.ReadAllBytes(x), File.ReadAllBytes(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameDigest(byte[] x, byte[] y)
|
||||||
|
{
|
||||||
|
if (x == null || y == null || x.Length != y.Length) return false;
|
||||||
|
using (var sha = SHA256.Create())
|
||||||
|
{
|
||||||
|
byte[] hx = sha.ComputeHash(x);
|
||||||
|
byte[] hy = sha.ComputeHash(y);
|
||||||
|
for (int i = 0; i < hx.Length; i++)
|
||||||
|
if (hx[i] != hy[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。</summary>
|
||||||
|
public static int CountDataRows(string path)
|
||||||
|
{
|
||||||
|
try { return ReadRows(path).Count; }
|
||||||
|
catch { return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------- 读取数据
|
// --------------------------------------------------------- 读取数据
|
||||||
|
|
||||||
|
|||||||
+204
-38
@@ -37,6 +37,7 @@ 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();
|
||||||
_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();
|
||||||
@@ -44,9 +45,10 @@ namespace TfsExcelImporter
|
|||||||
_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, _txtParentPbi, _txtAreaPath })
|
_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();
|
||||||
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
|
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
|
||||||
|
|
||||||
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
|
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
|
||||||
@@ -68,7 +70,7 @@ namespace TfsExcelImporter
|
|||||||
_txtPwd.Text = _cfg.Password;
|
_txtPwd.Text = _cfg.Password;
|
||||||
_cmbIteration.Text = _cfg.IterationPath;
|
_cmbIteration.Text = _cfg.IterationPath;
|
||||||
_txtExcel.Text = _cfg.LastExcelPath;
|
_txtExcel.Text = _cfg.LastExcelPath;
|
||||||
_txtAreaPath.Text = _cfg.AreaPath;
|
_cmbArea.Text = _cfg.AreaPath;
|
||||||
_chkSkip.Checked = _cfg.SkipExistingTitles;
|
_chkSkip.Checked = _cfg.SkipExistingTitles;
|
||||||
_txtParentPbi.Text = _cfg.ParentPbiId;
|
_txtParentPbi.Text = _cfg.ParentPbiId;
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,7 @@ namespace TfsExcelImporter
|
|||||||
_cfg.Password = _txtPwd.Text;
|
_cfg.Password = _txtPwd.Text;
|
||||||
_cfg.IterationPath = _cmbIteration.Text.Trim();
|
_cfg.IterationPath = _cmbIteration.Text.Trim();
|
||||||
_cfg.LastExcelPath = _txtExcel.Text.Trim();
|
_cfg.LastExcelPath = _txtExcel.Text.Trim();
|
||||||
_cfg.AreaPath = _txtAreaPath.Text.Trim();
|
_cfg.AreaPath = _cmbArea.Text.Trim();
|
||||||
_cfg.SkipExistingTitles = _chkSkip.Checked;
|
_cfg.SkipExistingTitles = _chkSkip.Checked;
|
||||||
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
|
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
|
||||||
_cfg.Save();
|
_cfg.Save();
|
||||||
@@ -153,8 +155,24 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- 迭代加载
|
// ------------------------------------------------------------- 迭代 / Area 列表
|
||||||
|
// 两者都是 TFS 分类结构(ICommonStructureService),取数、填表、报错完全同构,
|
||||||
|
// 只有「结构根节点」「是否自动选第一条」不同,所以共用一套加载流程。
|
||||||
private async Task LoadIterationsAsync()
|
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();
|
SaveUiToConfig();
|
||||||
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
|
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
|
||||||
@@ -164,7 +182,7 @@ namespace TfsExcelImporter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SetBusy(true);
|
SetBusy(true);
|
||||||
SetConnection("读取迭代中…", ChipKind.Info);
|
SetConnection($"读取 {what} 列表中…", ChipKind.Info);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var client = new TfsClient(_cfg))
|
using (var client = new TfsClient(_cfg))
|
||||||
@@ -181,38 +199,35 @@ namespace TfsExcelImporter
|
|||||||
{
|
{
|
||||||
Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
|
Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
|
||||||
}
|
}
|
||||||
var paths = await client.GetIterationPathsAsync(_cfg.Project, Log);
|
var paths = await fetch(client);
|
||||||
var keep = _cmbIteration.Text;
|
FillPathCombo(combo, paths, selectFirstWhenNoMatch);
|
||||||
_cmbIteration.Items.Clear();
|
Log($"已加载 {combo.Items.Count} 条 {what} 路径");
|
||||||
foreach (var p in paths) _cmbIteration.Items.Add(p);
|
|
||||||
if (_cmbIteration.Items.Count > 0)
|
|
||||||
{
|
|
||||||
int match = -1;
|
|
||||||
for (int i = 0; i < _cmbIteration.Items.Count; i++)
|
|
||||||
{
|
|
||||||
if (string.Equals(_cmbIteration.Items[i].ToString(), keep, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
match = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_cmbIteration.SelectedIndex = match >= 0 ? match : 0;
|
|
||||||
}
|
|
||||||
Log($"已加载 {_cmbIteration.Items.Count} 个迭代路径");
|
|
||||||
SetConnection("已连接", ChipKind.Ok);
|
SetConnection("已连接", ChipKind.Ok);
|
||||||
SetBadgeOf(_cardConn, BadgeKind.Done);
|
SetBadgeOf(_cardConn, BadgeKind.Done);
|
||||||
ShowError(_errIteration, _fIteration, _cardData, "");
|
ShowError(err, field, _cardData, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
SetConnection("连接失败", ChipKind.Danger);
|
||||||
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
SetBadgeOf(_cardConn, BadgeKind.Warn);
|
||||||
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
|
ShowError(err, field, _cardData, $"{what} 列表加载失败,可手填完整路径");
|
||||||
Log("加载迭代失败: " + FirstLine(ex.Message));
|
Log($"加载 {what} 失败: " + msg);
|
||||||
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
|
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
SetBusy(false);
|
SetBusy(false);
|
||||||
@@ -220,6 +235,34 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
// ------------------------------------------------------------- Excel
|
||||||
private async void BrowseExcel()
|
private async void BrowseExcel()
|
||||||
{
|
{
|
||||||
@@ -240,17 +283,32 @@ namespace TfsExcelImporter
|
|||||||
|
|
||||||
private async void ExportTemplate()
|
private async void ExportTemplate()
|
||||||
{
|
{
|
||||||
|
var startDir = ExportStartDirectory();
|
||||||
using (var dlg = new SaveFileDialog())
|
using (var dlg = new SaveFileDialog())
|
||||||
{
|
{
|
||||||
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
|
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
|
||||||
dlg.FileName = ExcelService.TemplateFileName;
|
|
||||||
dlg.Title = "导出默认模板";
|
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;
|
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
||||||
|
|
||||||
|
var path = dlg.FileName;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ExcelService.CopyTemplate(dlg.FileName);
|
if (!ConfirmOverwriteTemplate(path)) return;
|
||||||
Log("模板已导出: " + dlg.FileName);
|
|
||||||
_txtExcel.Text = dlg.FileName;
|
bool embeddedOnly;
|
||||||
|
if (!ConfirmTemplateSource(out embeddedOnly)) return;
|
||||||
|
|
||||||
|
var srcDesc = ExcelService.CopyTemplate(path, embeddedOnly);
|
||||||
|
Log("模板已导出(来源:" + srcDesc + "): " + path);
|
||||||
|
_txtExcel.Text = path;
|
||||||
SaveUiToConfig();
|
SaveUiToConfig();
|
||||||
await PreviewDataAsync();
|
await PreviewDataAsync();
|
||||||
}
|
}
|
||||||
@@ -262,6 +320,99 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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,
|
// ClosedXML 全量载入工作簿,大文件在 UI 线程读会冻结界面——放 Task.Run,
|
||||||
// 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。
|
// 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。
|
||||||
@@ -304,9 +455,15 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// 读取失败必须清掉上一次的数据,否则表格会继续显示旧文件的行,
|
||||||
|
// 看起来就像"刚导出的文件里装的是别的文件的数据"
|
||||||
|
_rows = new List<WorkItemRow>();
|
||||||
|
_grid.DataSource = null;
|
||||||
|
_lblStatus.Text = "读取失败";
|
||||||
Log("读取 Excel 失败: " + FirstLine(ex.Message));
|
Log("读取 Excel 失败: " + FirstLine(ex.Message));
|
||||||
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
|
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
|
||||||
UpdateEmptyState();
|
UpdateEmptyState();
|
||||||
|
RefreshSummaries();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -326,9 +483,8 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||||||
{
|
{
|
||||||
ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
|
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
|
||||||
OpenDetail(_cardAdv);
|
_cmbArea.Focus();
|
||||||
_txtAreaPath.Focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var confirm = MessageBox.Show(this,
|
var confirm = MessageBox.Show(this,
|
||||||
@@ -348,7 +504,7 @@ namespace TfsExcelImporter
|
|||||||
: $"Area 修正完成,共 {r.Item1} 条";
|
: $"Area 修正完成,共 {r.Item1} 条";
|
||||||
_lblStatus.Text = summary;
|
_lblStatus.Text = summary;
|
||||||
Log(summary);
|
Log(summary);
|
||||||
ShowError(_errArea, _fArea, _cardAdv, "");
|
ShowError(_errArea, _fArea, _cardData, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -419,6 +575,14 @@ namespace TfsExcelImporter
|
|||||||
Append("导入取消:目标迭代未选", LogLevel.Warn);
|
Append("导入取消:目标迭代未选", LogLevel.Warn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Area 必填:不写 AreaPath 的工作项落在项目根区域,团队视图里看不到(历史坑)
|
||||||
|
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
||||||
|
{
|
||||||
|
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
|
||||||
|
Append("导入取消:Area 路径未填", LogLevel.Warn);
|
||||||
|
_cmbArea.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))
|
||||||
{
|
{
|
||||||
@@ -446,6 +610,7 @@ namespace TfsExcelImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
ShowError(_errIteration, _fIteration, _cardData, "");
|
ShowError(_errIteration, _fIteration, _cardData, "");
|
||||||
|
ShowError(_errArea, _fArea, _cardData, "");
|
||||||
ShowError(_errExcel, _fExcel, _cardData, "");
|
ShowError(_errExcel, _fExcel, _cardData, "");
|
||||||
SetRunBadge(BadgeKind.Active);
|
SetRunBadge(BadgeKind.Active);
|
||||||
SetBusy(true);
|
SetBusy(true);
|
||||||
@@ -478,6 +643,7 @@ namespace TfsExcelImporter
|
|||||||
var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef);
|
var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef);
|
||||||
var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef);
|
var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef);
|
||||||
Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"} OriginalEstimate->{origEstRef ?? "(未找到)"}");
|
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 列覆盖");
|
Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖");
|
||||||
|
|
||||||
HashSet<string> existing = null;
|
HashSet<string> existing = null;
|
||||||
@@ -507,12 +673,12 @@ namespace TfsExcelImporter
|
|||||||
new KeyValuePair<string, object>(titleRef, row.Title),
|
new KeyValuePair<string, object>(titleRef, row.Title),
|
||||||
new KeyValuePair<string, object>(stateRef,
|
new KeyValuePair<string, object>(stateRef,
|
||||||
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
|
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
|
||||||
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath)
|
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath),
|
||||||
|
// Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域
|
||||||
|
new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim())
|
||||||
};
|
};
|
||||||
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
|
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
|
||||||
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
|
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
|
||||||
if (!string.IsNullOrWhiteSpace(_cfg.AreaPath))
|
|
||||||
fields.Add(new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim()));
|
|
||||||
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
|
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
|
||||||
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
|
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ namespace TfsExcelImporter
|
|||||||
private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
|
private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
|
||||||
|
|
||||||
private ComboBox _cmbIteration = new ComboBox();
|
private ComboBox _cmbIteration = new ComboBox();
|
||||||
|
private ComboBox _cmbArea = new ComboBox();
|
||||||
private TextBox _txtExcel = new TextBox();
|
private TextBox _txtExcel = new TextBox();
|
||||||
private TextBox _txtParentPbi = new TextBox();
|
private TextBox _txtParentPbi = new TextBox();
|
||||||
private TextBox _txtAreaPath = new TextBox();
|
|
||||||
private Button _btnLoadIterations = new Button();
|
private Button _btnLoadIterations = new Button();
|
||||||
|
private Button _btnLoadAreas = new Button();
|
||||||
private Button _btnTemplate = new Button();
|
private Button _btnTemplate = new Button();
|
||||||
private CheckBox _chkSkip = new CheckBox();
|
private CheckBox _chkSkip = new CheckBox();
|
||||||
|
|
||||||
@@ -337,34 +338,47 @@ namespace TfsExcelImporter
|
|||||||
private void BuildCardData()
|
private void BuildCardData()
|
||||||
{
|
{
|
||||||
_cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44));
|
_cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44));
|
||||||
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,四项状态一眼可辨
|
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨
|
||||||
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
|
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
|
||||||
|
_cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填
|
||||||
|
|
||||||
_fIteration = NewField(_cmbIteration, 500, false);
|
_fIteration = NewField(_cmbIteration, 500, false);
|
||||||
|
_fArea = NewField(_cmbArea, 500, true);
|
||||||
_fExcel = NewField(_txtExcel, 500, true);
|
_fExcel = NewField(_txtExcel, 500, true);
|
||||||
_fParent = NewField(_txtParentPbi, 150, true);
|
_fParent = NewField(_txtParentPbi, 150, true);
|
||||||
|
|
||||||
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
|
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
|
||||||
|
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
|
||||||
|
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
|
||||||
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
|
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
|
||||||
_fParent.SetCue("如 219975");
|
_fParent.SetCue("如 219975");
|
||||||
|
|
||||||
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
|
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
|
||||||
|
_btnLoadAreas = NewButton("加载 Area 列表", 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 给右侧留的
|
||||||
// 宽度变成同一个值,上面两个输入框的右边框才对得齐。
|
// 宽度变成同一个值,上面两个输入框的右边框才对得齐。
|
||||||
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;
|
||||||
_btnTemplate.Width = wBtn;
|
_btnTemplate.Width = wBtn;
|
||||||
_chkSkip = NewCheck("跳过已存在标题", "");
|
_chkSkip = NewCheck("跳过已存在标题", "");
|
||||||
|
|
||||||
// 三行字段对齐同一条左缘:标签列宽取最大值
|
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
|
||||||
int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id");
|
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
|
||||||
|
int wLbl = MaxLabelWidth("目标迭代", "Area 路径", "Excel 文件", "父 PBI Id")
|
||||||
|
+ Theme.Px(2) + Theme.TextWidth("*", Theme.Body);
|
||||||
int y = _cardData.HeaderH + Theme.S12;
|
int y = _cardData.HeaderH + Theme.S12;
|
||||||
y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration);
|
y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl, true), _fIteration);
|
||||||
AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top);
|
AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top);
|
||||||
Track(_fIteration, _cardData, wBtn + G + M, 0);
|
Track(_fIteration, _cardData, wBtn + G + M, 0);
|
||||||
y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl), _fExcel);
|
// Area 与迭代同属「写到哪儿」:紧贴迭代下方,值直接落进 System.AreaPath。
|
||||||
|
// 行尾同样挂一个列表按钮:Area 树往往比迭代树深得多,手打路径最容易写错。
|
||||||
|
y = Row(_cardData, y, 52, new Lbl("Area 路径", wLbl, true), _fArea);
|
||||||
|
AnchorRight(_btnLoadAreas, _cardData, M, _fArea.Top);
|
||||||
|
Track(_fArea, _cardData, wBtn + G + M, 0);
|
||||||
|
y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl, true), _fExcel);
|
||||||
// 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮
|
// 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮
|
||||||
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
|
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
|
||||||
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
|
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
|
||||||
@@ -377,6 +391,7 @@ namespace TfsExcelImporter
|
|||||||
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
|
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
|
||||||
|
|
||||||
_errIteration = AddErrorUnder(_cardData, _fIteration);
|
_errIteration = AddErrorUnder(_cardData, _fIteration);
|
||||||
|
_errArea = AddErrorUnder(_cardData, _fArea);
|
||||||
_errExcel = AddErrorUnder(_cardData, _fExcel);
|
_errExcel = AddErrorUnder(_cardData, _fExcel);
|
||||||
_errParent = AddErrorUnder(_cardData, _fParent);
|
_errParent = AddErrorUnder(_cardData, _fParent);
|
||||||
_cardData.BodyH = y - _cardData.HeaderH + Theme.S8;
|
_cardData.BodyH = y - _cardData.HeaderH + Theme.S8;
|
||||||
@@ -554,17 +569,16 @@ namespace TfsExcelImporter
|
|||||||
{
|
{
|
||||||
_cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
|
_cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
|
||||||
_cardAdv.ShowBadge = false;
|
_cardAdv.ShowBadge = false;
|
||||||
_fArea = NewField(_txtAreaPath, 420, true);
|
// Area 输入框已上移到第 2 步(必填),这里只剩两个批量修正动作,
|
||||||
_fArea.SetCue("如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area");
|
// 两者都按第 2 步填的「目标迭代 / Area 路径」筛选已有工作项。
|
||||||
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
|
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
|
||||||
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
|
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
|
||||||
|
|
||||||
int y = _cardAdv.HeaderH + Theme.S12;
|
int y = _cardAdv.HeaderH + Theme.S12;
|
||||||
y = Row(_cardAdv, y, 52, "Area 路径", _fArea,
|
y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G,
|
||||||
new Hint("改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项"));
|
new Hint("Area 取第 2 步的「Area 路径」,按「目标迭代」筛选;已关闭的工作项会临时重新打开,完成后自动还原状态"));
|
||||||
y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态");
|
|
||||||
_errArea = AddErrorUnder(_cardAdv, _fArea);
|
|
||||||
_cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
|
_cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
|
||||||
|
_cardAdv.SetSummary("按迭代批量修 Area / 补挂父项");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- 摆放助手
|
// ------------------------------------------------------------- 摆放助手
|
||||||
@@ -573,8 +587,15 @@ namespace TfsExcelImporter
|
|||||||
{
|
{
|
||||||
public readonly string Text;
|
public readonly string Text;
|
||||||
public readonly int MinWidth; // 运行时像素(构建时已含缩放)
|
public readonly int MinWidth; // 运行时像素(构建时已含缩放)
|
||||||
|
/// <summary>必填项:标签后补一个红色 *,且列宽里预留它的位置。</summary>
|
||||||
|
public readonly bool Required;
|
||||||
|
|
||||||
public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; }
|
public Lbl(string text, int minWidth, bool required = false)
|
||||||
|
{
|
||||||
|
Text = text;
|
||||||
|
MinWidth = minWidth;
|
||||||
|
Required = required;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。</summary>
|
/// <summary>紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。</summary>
|
||||||
@@ -616,6 +637,21 @@ namespace TfsExcelImporter
|
|||||||
};
|
};
|
||||||
lbl.SetBounds(x, y + (Theme.FieldH - lbl.PreferredHeight) / 2, lbl.PreferredWidth, lbl.PreferredHeight);
|
lbl.SetBounds(x, y + (Theme.FieldH - lbl.PreferredHeight) / 2, lbl.PreferredWidth, lbl.PreferredHeight);
|
||||||
card.Controls.Add(lbl);
|
card.Controls.Add(lbl);
|
||||||
|
if (spec != null && spec.Required)
|
||||||
|
{
|
||||||
|
// 红色 * 紧跟标签右缘:颜色是这套界面里唯一的「必填」色板(与错误提示同色)
|
||||||
|
var star = new Label
|
||||||
|
{
|
||||||
|
Text = "*",
|
||||||
|
Font = Theme.Body,
|
||||||
|
ForeColor = Theme.Danger,
|
||||||
|
AutoSize = true,
|
||||||
|
BackColor = Theme.Card
|
||||||
|
};
|
||||||
|
star.SetBounds(x + lbl.PreferredWidth + Theme.Px(1),
|
||||||
|
y + (Theme.FieldH - star.PreferredHeight) / 2, star.PreferredWidth, star.PreferredHeight);
|
||||||
|
card.Controls.Add(star);
|
||||||
|
}
|
||||||
x += Math.Max(lbl.PreferredWidth, spec != null ? spec.MinWidth : 0) + Theme.Px(6);
|
x += Math.Max(lbl.PreferredWidth, spec != null ? spec.MinWidth : 0) + Theme.Px(6);
|
||||||
lastLabel = label;
|
lastLabel = label;
|
||||||
continue;
|
continue;
|
||||||
@@ -922,6 +958,7 @@ namespace TfsExcelImporter
|
|||||||
_cardData.SetSummary(string.Join(" · ", new[]
|
_cardData.SetSummary(string.Join(" · ", new[]
|
||||||
{
|
{
|
||||||
string.IsNullOrEmpty(iter) ? "迭代未选" : iter,
|
string.IsNullOrEmpty(iter) ? "迭代未选" : iter,
|
||||||
|
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(_txtParentPbi.Text) ? "父 PBI 未填" : "父 PBI #" + _txtParentPbi.Text.Trim()
|
||||||
@@ -929,9 +966,6 @@ namespace TfsExcelImporter
|
|||||||
_cardPreview.SetSummary(_rows.Count > 0
|
_cardPreview.SetSummary(_rows.Count > 0
|
||||||
? "共 " + _rows.Count + " 行,可直接开始导入"
|
? "共 " + _rows.Count + " 行,可直接开始导入"
|
||||||
: "选择 Excel 后在这里核对将要写入的内容");
|
: "选择 Excel 后在这里核对将要写入的内容");
|
||||||
_cardAdv.SetSummary(string.IsNullOrWhiteSpace(_txtAreaPath.Text)
|
|
||||||
? "Area 未设置"
|
|
||||||
: "Area " + _txtAreaPath.Text.Trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>第 4 步徽标:待办 / 进行中 / 完成 / 有失败。</summary>
|
/// <summary>第 4 步徽标:待办 / 进行中 / 完成 / 有失败。</summary>
|
||||||
@@ -992,6 +1026,7 @@ namespace TfsExcelImporter
|
|||||||
_btnPreview.Enabled = !busy;
|
_btnPreview.Enabled = !busy;
|
||||||
_btnTest.Enabled = !busy;
|
_btnTest.Enabled = !busy;
|
||||||
_btnLoadIterations.Enabled = !busy;
|
_btnLoadIterations.Enabled = !busy;
|
||||||
|
_btnLoadAreas.Enabled = !busy;
|
||||||
_btnFixArea.Enabled = !busy;
|
_btnFixArea.Enabled = !busy;
|
||||||
_btnLinkParent.Enabled = !busy;
|
_btnLinkParent.Enabled = !busy;
|
||||||
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
|
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
|
||||||
|
|||||||
@@ -88,10 +88,30 @@ namespace TfsExcelImporter
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- 迭代
|
// ------------------------------------------------------------- 迭代 / Area
|
||||||
|
|
||||||
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
|
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
|
||||||
public async Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
|
public Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
|
||||||
|
{
|
||||||
|
return GetClassificationPathsAsync(project, "ProjectLifecycle", "Iteration", "迭代", log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 取项目下全部 Area 完整路径(Tools_Dev\DataTechnologies 形式),按路径排序。
|
||||||
|
/// Area 与迭代同属分类结构(ICommonStructureService),只是根节点不同:
|
||||||
|
/// TFS 2013 里 Area 的 StructureType 固定为 ProjectModelHierarchy,迭代是 ProjectLifecycle。
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<string>> GetAreaPathsAsync(string project, Action<string> log = null)
|
||||||
|
{
|
||||||
|
return GetClassificationPathsAsync(project, "ProjectModelHierarchy", "Area", "Area", log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通用分类树抓取:按 StructureType 挑根节点(拿不到就用名字模糊匹配兜底),
|
||||||
|
/// GetNodesXml 深度展开整棵树,再取每个节点的 Path 属性。迭代与 Area 共用这一条通道。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<string>> GetClassificationPathsAsync(string project, string structureType,
|
||||||
|
string nameHint, string label, Action<string> log)
|
||||||
{
|
{
|
||||||
var list = await Task.Run(() =>
|
var list = await Task.Run(() =>
|
||||||
{
|
{
|
||||||
@@ -101,13 +121,13 @@ namespace TfsExcelImporter
|
|||||||
throw new InvalidOperationException("项目不存在或无权访问: " + project);
|
throw new InvalidOperationException("项目不存在或无权访问: " + project);
|
||||||
|
|
||||||
var structures = css.ListStructures(proj.Uri);
|
var structures = css.ListStructures(proj.Uri);
|
||||||
var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle")
|
var root = structures.FirstOrDefault(n => string.Equals(n.StructureType, structureType, StringComparison.OrdinalIgnoreCase))
|
||||||
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0);
|
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf(nameHint, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||||
if (iterations == null)
|
if (root == null)
|
||||||
throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构");
|
throw new InvalidOperationException($"未找到 {label} 结构(StructureType={structureType})");
|
||||||
|
|
||||||
// GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径
|
// 深度展开全树;Node 元素的 Path 属性为分类节点路径
|
||||||
var tree = css.GetNodesXml(new[] { iterations.Uri }, true);
|
var tree = css.GetNodesXml(new[] { root.Uri }, true);
|
||||||
var raw = new List<string>();
|
var raw = new List<string>();
|
||||||
if (tree != null)
|
if (tree != null)
|
||||||
{
|
{
|
||||||
@@ -120,20 +140,20 @@ namespace TfsExcelImporter
|
|||||||
return raw;
|
return raw;
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = list.Select(p => NormalizeIterationPath(p, project))
|
var result = list.Select(p => NormalizeNodePath(p, project))
|
||||||
.Where(p => p != null)
|
.Where(p => p != null)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)");
|
log?.Invoke($"{label}列表来源: TFS OM ICommonStructureService({result.Count} 个)");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 节点路径归一化为工作项 IterationPath(项目\子...)。
|
/// 分类节点路径归一化为工作项字段值(项目\子...),迭代与 Area 共用。
|
||||||
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
|
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NormalizeIterationPath(string nodePath, string project)
|
private static string NormalizeNodePath(string nodePath, string project)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(nodePath)) return null;
|
if (string.IsNullOrWhiteSpace(nodePath)) return null;
|
||||||
var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
|
var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
"Project": "Tools_Dev",
|
"Project": "Tools_Dev",
|
||||||
"WorkItemType": "Task",
|
"WorkItemType": "Task",
|
||||||
"IterationPath": "",
|
"IterationPath": "",
|
||||||
"AreaPath": "Tools_Dev\\DataTechnologies",
|
"AreaPath": "",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": "",
|
"Password": "",
|
||||||
"SkipExistingTitles": true,
|
"SkipExistingTitles": false,
|
||||||
"DefaultState": "To Do",
|
"DefaultState": "To Do",
|
||||||
"FinancialProjectRef": "",
|
"FinancialProjectRef": "",
|
||||||
"CompletedRef": "",
|
"CompletedRef": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user