diff --git a/TfsExcelImporter/Config.cs b/TfsExcelImporter/Config.cs index 0cdc9c7..75fa873 100644 --- a/TfsExcelImporter/Config.cs +++ b/TfsExcelImporter/Config.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Security.Cryptography; +using System.Text; using Newtonsoft.Json; namespace TfsExcelImporter @@ -20,8 +22,43 @@ namespace TfsExcelImporter // 认证固定 NTLM:用户名可写 user,也可写 DOMAIN\user public string Username { get; set; } = ""; + + // 明文密码只存在于内存(UI 回显需要);落盘一律走 DPAPI 加密的 PasswordEnc。 + // [JsonIgnore] 防止明文随 SerializeObject 写进 config.json。 + [JsonIgnore] public string Password { get; set; } = ""; + // 兼容旧版明文键:反序列化时承接旧 config.json 的 "Password",下次 Save 自动转为加密存储。 + // getter 恒返 null,序列化不再输出明文键。 + [JsonProperty("Password", NullValueHandling = NullValueHandling.Ignore)] + private string LegacyPassword { get { return null; } set { if (!string.IsNullOrEmpty(value)) Password = value; } } + + // DPAPI(CurrentUser) 加密后的密码,Base64;同机同用户可解,拷到别的机器/账户需重输。 + [JsonProperty("PasswordEnc", NullValueHandling = NullValueHandling.Ignore)] + private string PasswordEnc { get; set; } = ""; + + // ------------------------------------------------------------- DPAPI + + private static string Protect(string plain) + { + var bytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(plain), null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(bytes); + } + + /// 解密失败返回 null(不抛异常,让调用方回退到空密码)。 + private static string TryUnprotect(string cipher) + { + try + { + var bytes = ProtectedData.Unprotect(Convert.FromBase64String(cipher), null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(bytes); + } + catch + { + return null; + } + } + // 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules // (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由 // Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。 @@ -80,7 +117,16 @@ namespace TfsExcelImporter if (File.Exists(ConfigPath)) { var cfg = JsonConvert.DeserializeObject(File.ReadAllText(ConfigPath)); - if (cfg != null) return cfg; + if (cfg != null) + { + // 加密值优先于旧明文键;解密失败(换机/换账户/文件被改)留空,用户重输即可 + if (!string.IsNullOrEmpty(cfg.PasswordEnc)) + { + var plain = TryUnprotect(cfg.PasswordEnc); + if (plain != null) cfg.Password = plain; + } + return cfg; + } } } catch (Exception ex) @@ -92,6 +138,8 @@ namespace TfsExcelImporter public void Save() { + // 落盘前把内存中的明文加密;Password 本身带 [JsonIgnore] 不会写出 + PasswordEnc = string.IsNullOrEmpty(Password) ? "" : Protect(Password); var json = JsonConvert.SerializeObject(this, Formatting.Indented); try { diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs index a34f159..90fe9ae 100644 --- a/TfsExcelImporter/MainForm.cs +++ b/TfsExcelImporter/MainForm.cs @@ -39,7 +39,7 @@ namespace TfsExcelImporter _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); _btnBrowse.Click += (s, e) => BrowseExcel(); _btnTemplate.Click += (s, e) => ExportTemplate(); - _btnPreview.Click += (s, e) => PreviewData(); + _btnPreview.Click += async (s, e) => await PreviewDataAsync(); _btnImport.Click += async (s, e) => await ImportAsync(); _btnFixArea.Click += async (s, e) => await FixAreaAsync(); _btnLinkParent.Click += async (s, e) => await LinkParentAsync(); @@ -222,7 +222,7 @@ namespace TfsExcelImporter } // ------------------------------------------------------------- Excel - private void BrowseExcel() + private async void BrowseExcel() { using (var dlg = new OpenFileDialog()) { @@ -235,11 +235,11 @@ namespace TfsExcelImporter if (dlg.ShowDialog(this) != DialogResult.OK) return; _txtExcel.Text = dlg.FileName; SaveUiToConfig(); - PreviewData(); + await PreviewDataAsync(); } } - private void ExportTemplate() + private async void ExportTemplate() { using (var dlg = new SaveFileDialog()) { @@ -253,7 +253,7 @@ namespace TfsExcelImporter Log("模板已导出: " + dlg.FileName); _txtExcel.Text = dlg.FileName; SaveUiToConfig(); - PreviewData(); + await PreviewDataAsync(); } catch (Exception ex) { @@ -263,7 +263,10 @@ namespace TfsExcelImporter } } - private void PreviewData() + // ------------------------------------------------------------- 预览 + // ClosedXML 全量载入工作簿,大文件在 UI 线程读会冻结界面——放 Task.Run, + // 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。 + private async Task PreviewDataAsync() { var path = _txtExcel.Text.Trim(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) @@ -272,9 +275,10 @@ namespace TfsExcelImporter Append("预览未执行:Excel 文件不存在", LogLevel.Warn); return; } + SetBusy(true); try { - _rows = ExcelService.ReadRows(path); + _rows = await Task.Run(() => ExcelService.ReadRows(path)); _grid.DataSource = null; _grid.DataSource = _rows.Select((r, i) => new { @@ -305,6 +309,10 @@ namespace TfsExcelImporter ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); UpdateEmptyState(); } + finally + { + SetBusy(false); + } } // ------------------------------------------------------------- Area 修正 @@ -421,13 +429,15 @@ namespace TfsExcelImporter } if (_rows.Count == 0) { - try { _rows = ExcelService.ReadRows(path); } + SetBusy(true); + try { _rows = await Task.Run(() => ExcelService.ReadRows(path)); } catch (Exception ex) { ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); Log("读取 Excel 失败: " + FirstLine(ex.Message)); return; } + finally { SetBusy(false); } } if (_rows.Count == 0) { diff --git a/TfsExcelImporter/TfsClient.cs b/TfsExcelImporter/TfsClient.cs index 073336c..a35b2e4 100644 --- a/TfsExcelImporter/TfsClient.cs +++ b/TfsExcelImporter/TfsClient.cs @@ -367,7 +367,9 @@ namespace TfsExcelImporter { return await Task.Run(() => { - var wiql = "SELECT [System.Id] FROM WorkItems WHERE " + + // SELECT 必须带全循环里要读的字段:OM 对查询未包含的字段会逐条触发完整加载 + // (每条一次服务器往返),只 SELECT Id 会让批量修 Area 退化成 N+1 + var wiql = "SELECT [System.Id], [System.Title], [System.State], [System.AreaPath] FROM WorkItems WHERE " + $"[System.TeamProject] = '{EscapeWiql(project)}' AND " + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " + diff --git a/TfsExcelImporter/TfsExcelImporter.csproj b/TfsExcelImporter/TfsExcelImporter.csproj index 85dc59d..f236f4d 100644 --- a/TfsExcelImporter/TfsExcelImporter.csproj +++ b/TfsExcelImporter/TfsExcelImporter.csproj @@ -31,6 +31,11 @@ + + + + + PreserveNewest