diff --git a/README.md b/README.md index 4d22946..c74ba88 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ python tools/win_screenshot.py TfsExcelImporter\bin\Release\net48\TfsExcelImport 界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。 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 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。 4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。 -5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;Area 路径也在这一栏里填(以前只能手改 config.json)。 +5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;两者都按第 2 步的「目标迭代 / Area 路径」筛选已有工作项。 桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。 @@ -107,11 +107,11 @@ copy TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe dist\ | --- | --- | | `TfsUrl` / `Project` / `WorkItemType` | 目标集合、项目、工作项类型(`Task` 或 `Product Backlog Item`) | | `IterationPath` | 可只写 `2026年Sprint\2026年Sprint10`,程序自动补项目名前缀 | -| `AreaPath` | 区域路径;界面「高级工具」栏也可改 | +| `AreaPath` | 区域路径;导入必填,界面上与迭代同在第 2 步卡片(可点「加载 Area 列表」从 TFS 分类树拉取),值直接写进 `System.AreaPath` | | `Username` / `Password` | 认证固定 NTLM,用户名可写 `user` 或 `DOMAIN\user` | | `FinancialProjectRef` / `CompletedRef` / `LogDateRef` / `OriginalEstimateRef` | 自定义字段引用名;留空则按显示名在服务器字段表里查(支持前缀模糊匹配,如 `Completed` → `Completed Work`) | | `ParentPbiId` | 所有 Task 默认挂接的父 PBI Id;Excel 每行 `PBI Id` 列可覆盖 | -| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重 | +| `SkipExistingTitles` | 同项目/同类型/同迭代下按标题去重;**默认不勾选**,需要去重时手动勾 | | `DefaultState` | Excel `State` 列留空时的默认值 | | `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)。 - **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 → 还原状态」的状态循环,中途失败回滚状态。 - **`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 对应列后重跑即可。 diff --git a/TfsExcelImporter/Config.cs b/TfsExcelImporter/Config.cs index 75fa873..a46b379 100644 --- a/TfsExcelImporter/Config.cs +++ b/TfsExcelImporter/Config.cs @@ -62,7 +62,8 @@ namespace TfsExcelImporter // 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules // (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由 // Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。 - public bool SkipExistingTitles { get; set; } = true; + // 默认不勾选:首次导入时希望先看到真实冲突/报错,而不是被静默跳过 + public bool SkipExistingTitles { get; set; } = false; public string DefaultState { get; set; } = "To Do"; // 自定义字段引用名;留空则按 DisplayName 自动解析 diff --git a/TfsExcelImporter/ExcelService.cs b/TfsExcelImporter/ExcelService.cs index e3ceb9d..e7437cd 100644 --- a/TfsExcelImporter/ExcelService.cs +++ b/TfsExcelImporter/ExcelService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; +using System.Security.Cryptography; using ClosedXML.Excel; namespace TfsExcelImporter @@ -45,35 +46,129 @@ namespace TfsExcelImporter // --------------------------------------------------------- 默认模板 - /// - /// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换), - /// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。 - /// - public static void CopyTemplate(string path) + /// exe 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。 + public static string LocalTemplatePath { - var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); - if (File.Exists(src)) + get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); } + } + + /// + /// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。 + /// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。 + /// + public static bool LocalTemplateIsBuiltIn() + { + var local = LocalTemplatePath; + if (!File.Exists(local)) return true; + try { - File.Copy(src, path, true); - return; + var embed = ReadEmbeddedTemplate(); + return embed != null && SameDigest(embed, File.ReadAllBytes(local)); + } + catch { return false; } + } + + /// + /// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。 + /// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。 + /// + /// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。 + /// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。 + /// + 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; // 嵌入名取决于 Link 名:默认 .<文件名> var name = asm.GetManifestResourceNames() .FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase)); - if (name == null) - throw new FileNotFoundException( - "未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src); + if (name == null) return null; using (var input = asm.GetManifestResourceStream(name)) { - if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src); - using (var output = File.Create(path)) - input.CopyTo(output); + if (input == null) return null; + using (var buffer = new MemoryStream()) + { + input.CopyTo(buffer); + return buffer.ToArray(); + } } } + /// 写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。 + 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; + } + } + + /// 数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。 + public static int CountDataRows(string path) + { + try { return ReadRows(path).Count; } + catch { return -1; } + } + // --------------------------------------------------------- 读取数据 /// 读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。 diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs index 5d9a14a..081d15b 100644 --- a/TfsExcelImporter/MainForm.cs +++ b/TfsExcelImporter/MainForm.cs @@ -37,6 +37,7 @@ namespace TfsExcelImporter { _btnTest.Click += async (s, e) => await TestConnectionAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); + _btnLoadAreas.Click += async (s, e) => await LoadAreasAsync(); _btnTemplate.Click += (s, e) => ExportTemplate(); _btnPreview.Click += async (s, e) => await PreviewDataAsync(); _btnImport.Click += async (s, e) => await ImportAsync(); @@ -44,9 +45,10 @@ namespace TfsExcelImporter _btnLinkParent.Click += async (s, e) => await LinkParentAsync(); foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd, - _txtExcel, _txtParentPbi, _txtAreaPath }) + _txtExcel, _txtParentPbi }) tb.TextChanged += (s, e) => OnFieldChanged(); _cmbIteration.TextChanged += (s, e) => OnFieldChanged(); + _cmbArea.TextChanged += (s, e) => OnFieldChanged(); _chkSkip.CheckedChanged += (s, e) => OnFieldChanged(); FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } }; @@ -68,7 +70,7 @@ namespace TfsExcelImporter _txtPwd.Text = _cfg.Password; _cmbIteration.Text = _cfg.IterationPath; _txtExcel.Text = _cfg.LastExcelPath; - _txtAreaPath.Text = _cfg.AreaPath; + _cmbArea.Text = _cfg.AreaPath; _chkSkip.Checked = _cfg.SkipExistingTitles; _txtParentPbi.Text = _cfg.ParentPbiId; } @@ -82,7 +84,7 @@ namespace TfsExcelImporter _cfg.Password = _txtPwd.Text; _cfg.IterationPath = _cmbIteration.Text.Trim(); _cfg.LastExcelPath = _txtExcel.Text.Trim(); - _cfg.AreaPath = _txtAreaPath.Text.Trim(); + _cfg.AreaPath = _cmbArea.Text.Trim(); _cfg.SkipExistingTitles = _chkSkip.Checked; _cfg.ParentPbiId = _txtParentPbi.Text.Trim(); _cfg.Save(); @@ -153,8 +155,24 @@ namespace TfsExcelImporter } } - // ------------------------------------------------------------- 迭代加载 + // ------------------------------------------------------------- 迭代 / Area 列表 + // 两者都是 TFS 分类结构(ICommonStructureService),取数、填表、报错完全同构, + // 只有「结构根节点」「是否自动选第一条」不同,所以共用一套加载流程。 private async Task LoadIterationsAsync() + { + await LoadPathListAsync("迭代", c => c.GetIterationPathsAsync(_cfg.Project, Log), + _cmbIteration, _fIteration, _errIteration, true); + } + + private async Task LoadAreasAsync() + { + // Area 不自动选第一条:选错区域会静默写到别人的团队视图下,让用户自己挑更安全 + await LoadPathListAsync("Area", c => c.GetAreaPathsAsync(_cfg.Project, Log), + _cmbArea, _fArea, _errArea, false); + } + + private async Task LoadPathListAsync(string what, Func>> fetch, + ComboBox combo, InputField field, Label err, bool selectFirstWhenNoMatch) { SaveUiToConfig(); if (string.IsNullOrWhiteSpace(_cfg.TfsUrl)) @@ -164,7 +182,7 @@ namespace TfsExcelImporter return; } SetBusy(true); - SetConnection("读取迭代中…", ChipKind.Info); + SetConnection($"读取 {what} 列表中…", ChipKind.Info); try { using (var client = new TfsClient(_cfg)) @@ -181,37 +199,34 @@ namespace TfsExcelImporter { Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message)); } - var paths = await client.GetIterationPathsAsync(_cfg.Project, Log); - var keep = _cmbIteration.Text; - _cmbIteration.Items.Clear(); - 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} 个迭代路径"); + var paths = await fetch(client); + FillPathCombo(combo, paths, selectFirstWhenNoMatch); + Log($"已加载 {combo.Items.Count} 条 {what} 路径"); SetConnection("已连接", ChipKind.Ok); SetBadgeOf(_cardConn, BadgeKind.Done); - ShowError(_errIteration, _fIteration, _cardData, ""); + ShowError(err, field, _cardData, ""); } } catch (Exception ex) { - SetConnection("连接失败", ChipKind.Danger); - SetBadgeOf(_cardConn, BadgeKind.Warn); - ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径"); - Log("加载迭代失败: " + FirstLine(ex.Message)); - Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。"); + 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 或未连内网。"); + } } finally { @@ -220,6 +235,34 @@ namespace TfsExcelImporter } } + /// + /// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。 + /// Items.Clear 会把 DropDown 样式的 Text 一并清空,所以要在清空前取值、匹配后回填。 + /// + private static void FillPathCombo(ComboBox combo, List paths, bool selectFirstWhenNoMatch) + { + var keep = combo.Text; + combo.Items.Clear(); + foreach (var p in paths) combo.Items.Add(p); + if (combo.Items.Count == 0) + { + combo.Text = keep; + return; + } + int match = -1; + for (int i = 0; i < combo.Items.Count; i++) + { + if (string.Equals(combo.Items[i].ToString(), keep, StringComparison.OrdinalIgnoreCase)) + { + match = i; + break; + } + } + if (match >= 0) combo.SelectedIndex = match; + else if (selectFirstWhenNoMatch) combo.SelectedIndex = 0; + else combo.Text = keep; + } + // ------------------------------------------------------------- Excel private async void BrowseExcel() { @@ -240,17 +283,32 @@ namespace TfsExcelImporter private async void ExportTemplate() { + var startDir = ExportStartDirectory(); using (var dlg = new SaveFileDialog()) { dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx"; - dlg.FileName = ExcelService.TemplateFileName; dlg.Title = "导出默认模板"; + dlg.DefaultExt = "xlsx"; + dlg.AddExtension = true; + // 关掉系统自带的"是否替换",换成能看清目标文件是什么的确认框(见 ConfirmOverwriteTemplate) + dlg.OverwritePrompt = false; + dlg.InitialDirectory = startDir; + // 关键:FileName 给完整路径。只给文件名时对话框会沿用系统"上次目录", + // 那个目录里往往躺着 TFS导入模板11.xlsx 这类近似名,容易选错/覆盖到已有数据表 + dlg.FileName = Path.Combine(startDir, ExcelService.TemplateFileName); if (dlg.ShowDialog(this) != DialogResult.OK) return; + + var path = dlg.FileName; try { - ExcelService.CopyTemplate(dlg.FileName); - Log("模板已导出: " + dlg.FileName); - _txtExcel.Text = dlg.FileName; + if (!ConfirmOverwriteTemplate(path)) return; + + bool embeddedOnly; + if (!ConfirmTemplateSource(out embeddedOnly)) return; + + var srcDesc = ExcelService.CopyTemplate(path, embeddedOnly); + Log("模板已导出(来源:" + srcDesc + "): " + path); + _txtExcel.Text = path; SaveUiToConfig(); await PreviewDataAsync(); } @@ -262,6 +320,99 @@ namespace TfsExcelImporter } } + /// + /// 导出对话框起始目录:优先当前 Excel 所在目录,其次桌面。 + /// 显式给目录,避免对话框落到"系统记住的上次目录"而出乎意料。 + /// + private string ExportStartDirectory() + { + var last = _txtExcel.Text.Trim(); + if (!string.IsNullOrEmpty(last)) + { + try + { + var dir = Path.GetDirectoryName(last); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) return dir; + } + catch { } + } + try + { + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + if (!string.IsNullOrEmpty(desktop) && Directory.Exists(desktop)) return desktop; + } + catch { } + return AppDomain.CurrentDomain.BaseDirectory; + } + + /// + /// 覆盖确认。系统默认提示只问"是否替换",看不出目标文件其实是用户已有的数据表, + /// 这里把路径、大小、修改时间和已有数据行数都摆出来,避免把导入数据当模板覆盖掉。 + /// + private bool ConfirmOverwriteTemplate(string path) + { + FileInfo fi; + try { fi = new FileInfo(path); } + catch { return true; } + if (!fi.Exists) return true; + + // 大文件数行会卡住界面:超过 20MB 就只报大小/时间,不数行 + int rows = fi.Length <= 20 * 1024 * 1024 ? ExcelService.CountDataRows(path) : 0; + var sb = new StringBuilder(); + sb.AppendLine("目标位置已存在文件,继续将用空白模板覆盖它:"); + sb.AppendLine(path); + sb.AppendLine(); + sb.AppendLine("大小 " + FormatSize(fi.Length) + " · 修改时间 " + fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm")); + if (rows > 0) + sb.AppendLine("该文件已有 " + rows + " 行数据,覆盖后无法恢复!"); + else if (rows < 0) + sb.AppendLine("该文件读不出内容(可能正被 Excel 打开,或不是 Excel 文件)。"); + + return MessageBox.Show(this, sb.ToString(), "确认覆盖已有文件", + MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK; + } + + private static string FormatSize(long bytes) + { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("0.#") + " KB"; + return (bytes / 1024.0 / 1024.0).ToString("0.#") + " MB"; + } + + /// + /// 导出前确认模板来源。exe 同目录的 TFS导入模板.xlsx 一旦被现场替换、或被填过数据, + /// 导出结果就不再是空白模板——用户会看到"导出的模板里带着别的数据"。 + /// 这里把选择权交出来:内置空白模板 / 仍用该文件 / 不导出。 + /// + private bool ConfirmTemplateSource(out bool embeddedOnly) + { + embeddedOnly = false; + var local = ExcelService.LocalTemplatePath; + if (!File.Exists(local) || ExcelService.LocalTemplateIsBuiltIn()) return true; + + int rows = ExcelService.CountDataRows(local); + var sb = new StringBuilder(); + sb.AppendLine("exe 同目录的模板文件与内置模板不一致:"); + sb.AppendLine(local); + sb.AppendLine(); + if (rows > 0) + sb.AppendLine("它当前有 " + rows + " 行内容,用它导出的模板会带上这些行。"); + else if (rows < 0) + sb.AppendLine("它读不出数据(可能正被 Excel 打开,或不是模板文件)。"); + else + sb.AppendLine("它已被替换为自定义模板。"); + sb.AppendLine(); + sb.AppendLine("是(Y):改用内置空白模板"); + sb.AppendLine("否(N):仍用这个文件"); + sb.AppendLine("取消:不导出"); + + var r = MessageBox.Show(this, sb.ToString(), "模板来源确认", + MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning); + if (r == DialogResult.Cancel) return false; + embeddedOnly = r == DialogResult.Yes; + return true; + } + // ------------------------------------------------------------- 预览 // ClosedXML 全量载入工作簿,大文件在 UI 线程读会冻结界面——放 Task.Run, // 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。 @@ -304,9 +455,15 @@ namespace TfsExcelImporter } catch (Exception ex) { + // 读取失败必须清掉上一次的数据,否则表格会继续显示旧文件的行, + // 看起来就像"刚导出的文件里装的是别的文件的数据" + _rows = new List(); + _grid.DataSource = null; + _lblStatus.Text = "读取失败"; Log("读取 Excel 失败: " + FirstLine(ex.Message)); ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); UpdateEmptyState(); + RefreshSummaries(); } finally { @@ -326,9 +483,8 @@ namespace TfsExcelImporter } if (string.IsNullOrWhiteSpace(_cfg.AreaPath)) { - ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies"); - OpenDetail(_cardAdv); - _txtAreaPath.Focus(); + ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies"); + _cmbArea.Focus(); return; } var confirm = MessageBox.Show(this, @@ -348,7 +504,7 @@ namespace TfsExcelImporter : $"Area 修正完成,共 {r.Item1} 条"; _lblStatus.Text = summary; Log(summary); - ShowError(_errArea, _fArea, _cardAdv, ""); + ShowError(_errArea, _fArea, _cardData, ""); } } catch (Exception ex) @@ -419,6 +575,14 @@ namespace TfsExcelImporter Append("导入取消:目标迭代未选", LogLevel.Warn); return; } + // Area 必填:不写 AreaPath 的工作项落在项目根区域,团队视图里看不到(历史坑) + if (string.IsNullOrWhiteSpace(_cfg.AreaPath)) + { + ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies"); + Append("导入取消:Area 路径未填", LogLevel.Warn); + _cmbArea.Focus(); + return; + } var path = _txtExcel.Text.Trim(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { @@ -446,6 +610,7 @@ namespace TfsExcelImporter } ShowError(_errIteration, _fIteration, _cardData, ""); + ShowError(_errArea, _fArea, _cardData, ""); ShowError(_errExcel, _fExcel, _cardData, ""); SetRunBadge(BadgeKind.Active); SetBusy(true); @@ -478,6 +643,7 @@ namespace TfsExcelImporter var logRef = TfsClient.ResolveRef(fieldMap, "LogDate", _cfg.LogDateRef); var origEstRef = TfsClient.ResolveRef(fieldMap, "Original Estimate", _cfg.OriginalEstimateRef); Log($"字段: FinancialProject->{finRef ?? "(未找到)"} Completed->{compRef ?? "(未找到)"} LogDate->{logRef ?? "(未找到)"} OriginalEstimate->{origEstRef ?? "(未找到)"}"); + Log($"目标: 迭代 {_cfg.IterationPath} Area {_cfg.AreaPath}"); Log($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖"); HashSet existing = null; @@ -507,12 +673,12 @@ namespace TfsExcelImporter new KeyValuePair(titleRef, row.Title), new KeyValuePair(stateRef, string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()), - new KeyValuePair(iterationRef, _cfg.IterationPath) + new KeyValuePair(iterationRef, _cfg.IterationPath), + // Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域 + new KeyValuePair(areaRef, _cfg.AreaPath.Trim()) }; if (!string.IsNullOrWhiteSpace(row.AssignedTo)) fields.Add(new KeyValuePair(assignedRef, row.AssignedTo.Trim())); - if (!string.IsNullOrWhiteSpace(_cfg.AreaPath)) - fields.Add(new KeyValuePair(areaRef, _cfg.AreaPath.Trim())); if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null) fields.Add(new KeyValuePair(finRef, row.FinancialProject.Trim())); diff --git a/TfsExcelImporter/MainFormUi.cs b/TfsExcelImporter/MainFormUi.cs index 7eceac3..39cffb7 100644 --- a/TfsExcelImporter/MainFormUi.cs +++ b/TfsExcelImporter/MainFormUi.cs @@ -21,10 +21,11 @@ namespace TfsExcelImporter private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true }; private ComboBox _cmbIteration = new ComboBox(); + private ComboBox _cmbArea = new ComboBox(); private TextBox _txtExcel = new TextBox(); private TextBox _txtParentPbi = new TextBox(); - private TextBox _txtAreaPath = new TextBox(); private Button _btnLoadIterations = new Button(); + private Button _btnLoadAreas = new Button(); private Button _btnTemplate = new Button(); private CheckBox _chkSkip = new CheckBox(); @@ -337,34 +338,47 @@ namespace TfsExcelImporter private void BuildCardData() { _cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44)); - _cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,四项状态一眼可辨 + _cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨 _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径 + _cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填 _fIteration = NewField(_cmbIteration, 500, false); + _fArea = NewField(_cmbArea, 500, true); _fExcel = NewField(_txtExcel, 500, true); _fParent = NewField(_txtParentPbi, 150, true); _fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1"); + // Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法 + _fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies"); _fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览"); _fParent.SetCue("如 219975"); _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130); + _btnLoadAreas = NewButton("加载 Area 列表", BtnKind.Secondary, IconKind.Fetch, 130); _btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142); // 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上, // 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的 // 宽度变成同一个值,上面两个输入框的右边框才对得齐。 int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width); _btnLoadIterations.Width = wBtn; + _btnLoadAreas.Width = wBtn; _btnTemplate.Width = wBtn; _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; - 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); 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); AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top); @@ -377,6 +391,7 @@ namespace TfsExcelImporter y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip); _errIteration = AddErrorUnder(_cardData, _fIteration); + _errArea = AddErrorUnder(_cardData, _fArea); _errExcel = AddErrorUnder(_cardData, _fExcel); _errParent = AddErrorUnder(_cardData, _fParent); _cardData.BodyH = y - _cardData.HeaderH + Theme.S8; @@ -554,17 +569,16 @@ namespace TfsExcelImporter { _cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36)); _cardAdv.ShowBadge = false; - _fArea = NewField(_txtAreaPath, 420, true); - _fArea.SetCue("如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area"); + // Area 输入框已上移到第 2 步(必填),这里只剩两个批量修正动作, + // 两者都按第 2 步填的「目标迭代 / Area 路径」筛选已有工作项。 _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158); _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120); int y = _cardAdv.HeaderH + Theme.S12; - y = Row(_cardAdv, y, 52, "Area 路径", _fArea, - new Hint("改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项")); - y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态"); - _errArea = AddErrorUnder(_cardAdv, _fArea); + y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, + new Hint("Area 取第 2 步的「Area 路径」,按「目标迭代」筛选;已关闭的工作项会临时重新打开,完成后自动还原状态")); _cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8; + _cardAdv.SetSummary("按迭代批量修 Area / 补挂父项"); } // ------------------------------------------------------------- 摆放助手 @@ -573,8 +587,15 @@ namespace TfsExcelImporter { public readonly string Text; public readonly int MinWidth; // 运行时像素(构建时已含缩放) + /// 必填项:标签后补一个红色 *,且列宽里预留它的位置。 + 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; + } } /// 紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。 @@ -616,6 +637,21 @@ namespace TfsExcelImporter }; lbl.SetBounds(x, y + (Theme.FieldH - lbl.PreferredHeight) / 2, lbl.PreferredWidth, lbl.PreferredHeight); 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); lastLabel = label; continue; @@ -922,6 +958,7 @@ namespace TfsExcelImporter _cardData.SetSummary(string.Join(" · ", new[] { string.IsNullOrEmpty(iter) ? "迭代未选" : iter, + 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() @@ -929,9 +966,6 @@ namespace TfsExcelImporter _cardPreview.SetSummary(_rows.Count > 0 ? "共 " + _rows.Count + " 行,可直接开始导入" : "选择 Excel 后在这里核对将要写入的内容"); - _cardAdv.SetSummary(string.IsNullOrWhiteSpace(_txtAreaPath.Text) - ? "Area 未设置" - : "Area " + _txtAreaPath.Text.Trim()); } /// 第 4 步徽标:待办 / 进行中 / 完成 / 有失败。 @@ -992,6 +1026,7 @@ namespace TfsExcelImporter _btnPreview.Enabled = !busy; _btnTest.Enabled = !busy; _btnLoadIterations.Enabled = !busy; + _btnLoadAreas.Enabled = !busy; _btnFixArea.Enabled = !busy; _btnLinkParent.Enabled = !busy; _fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用 diff --git a/TfsExcelImporter/TfsClient.cs b/TfsExcelImporter/TfsClient.cs index a35b2e4..10bfc99 100644 --- a/TfsExcelImporter/TfsClient.cs +++ b/TfsExcelImporter/TfsClient.cs @@ -88,10 +88,30 @@ namespace TfsExcelImporter return null; } - // ------------------------------------------------------------- 迭代 + // ------------------------------------------------------------- 迭代 / Area /// 取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。 - public async Task> GetIterationPathsAsync(string project, Action log = null) + public Task> GetIterationPathsAsync(string project, Action log = null) + { + return GetClassificationPathsAsync(project, "ProjectLifecycle", "Iteration", "迭代", log); + } + + /// + /// 取项目下全部 Area 完整路径(Tools_Dev\DataTechnologies 形式),按路径排序。 + /// Area 与迭代同属分类结构(ICommonStructureService),只是根节点不同: + /// TFS 2013 里 Area 的 StructureType 固定为 ProjectModelHierarchy,迭代是 ProjectLifecycle。 + /// + public Task> GetAreaPathsAsync(string project, Action log = null) + { + return GetClassificationPathsAsync(project, "ProjectModelHierarchy", "Area", "Area", log); + } + + /// + /// 通用分类树抓取:按 StructureType 挑根节点(拿不到就用名字模糊匹配兜底), + /// GetNodesXml 深度展开整棵树,再取每个节点的 Path 属性。迭代与 Area 共用这一条通道。 + /// + private async Task> GetClassificationPathsAsync(string project, string structureType, + string nameHint, string label, Action log) { var list = await Task.Run(() => { @@ -101,13 +121,13 @@ namespace TfsExcelImporter throw new InvalidOperationException("项目不存在或无权访问: " + project); var structures = css.ListStructures(proj.Uri); - var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle") - ?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0); - if (iterations == null) - throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构"); + var root = structures.FirstOrDefault(n => string.Equals(n.StructureType, structureType, StringComparison.OrdinalIgnoreCase)) + ?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf(nameHint, StringComparison.OrdinalIgnoreCase) >= 0); + if (root == null) + throw new InvalidOperationException($"未找到 {label} 结构(StructureType={structureType})"); - // GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径 - var tree = css.GetNodesXml(new[] { iterations.Uri }, true); + // 深度展开全树;Node 元素的 Path 属性为分类节点路径 + var tree = css.GetNodesXml(new[] { root.Uri }, true); var raw = new List(); if (tree != null) { @@ -120,20 +140,20 @@ namespace TfsExcelImporter return raw; }); - var result = list.Select(p => NormalizeIterationPath(p, project)) + var result = list.Select(p => NormalizeNodePath(p, project)) .Where(p => p != null) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) .ToList(); - log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)"); + log?.Invoke($"{label}列表来源: TFS OM ICommonStructureService({result.Count} 个)"); return result; } /// - /// 节点路径归一化为工作项 IterationPath(项目\子...)。 + /// 分类节点路径归一化为工作项字段值(项目\子...),迭代与 Area 共用。 /// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。 /// - private static string NormalizeIterationPath(string nodePath, string project) + private static string NormalizeNodePath(string nodePath, string project) { if (string.IsNullOrWhiteSpace(nodePath)) return null; var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); diff --git a/TfsExcelImporter/config.json b/TfsExcelImporter/config.json index 3a3ecb8..9984956 100644 --- a/TfsExcelImporter/config.json +++ b/TfsExcelImporter/config.json @@ -3,10 +3,10 @@ "Project": "Tools_Dev", "WorkItemType": "Task", "IterationPath": "", - "AreaPath": "Tools_Dev\\DataTechnologies", + "AreaPath": "", "Username": "", "Password": "", - "SkipExistingTitles": true, + "SkipExistingTitles": false, "DefaultState": "To Do", "FinancialProjectRef": "", "CompletedRef": "",