feat: 实现密码加密存储与异步数据预览功能

- 在 Config.cs 中添加 DPAPI 加密支持,确保密码安全存储,兼容旧版配置。
- 更新 MainForm.cs,重构数据预览逻辑为异步方法,提升用户体验,避免界面冻结。
- 修改相关方法以支持异步操作,确保在处理大文件时界面响应流畅。
- 更新项目文件以引用 System.Security,支持加密功能。
This commit is contained in:
chenshoulin
2026-11-01 21:57:53 +08:00
parent 8a6f9d55bc
commit 8824a81758
4 changed files with 75 additions and 10 deletions
+49 -1
View File
@@ -1,5 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace TfsExcelImporter namespace TfsExcelImporter
@@ -20,8 +22,43 @@ namespace TfsExcelImporter
// 认证固定 NTLM:用户名可写 user,也可写 DOMAIN\user // 认证固定 NTLM:用户名可写 user,也可写 DOMAIN\user
public string Username { get; set; } = ""; public string Username { get; set; } = "";
// 明文密码只存在于内存(UI 回显需要);落盘一律走 DPAPI 加密的 PasswordEnc。
// [JsonIgnore] 防止明文随 SerializeObject 写进 config.json。
[JsonIgnore]
public string Password { get; set; } = ""; 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);
}
/// <summary>解密失败返回 null(不抛异常,让调用方回退到空密码)。</summary>
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 // 旧版本有 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 忽略,不需要手工清理。
@@ -80,7 +117,16 @@ namespace TfsExcelImporter
if (File.Exists(ConfigPath)) if (File.Exists(ConfigPath))
{ {
var cfg = JsonConvert.DeserializeObject<AppConfig>(File.ReadAllText(ConfigPath)); var cfg = JsonConvert.DeserializeObject<AppConfig>(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) catch (Exception ex)
@@ -92,6 +138,8 @@ namespace TfsExcelImporter
public void Save() public void Save()
{ {
// 落盘前把内存中的明文加密;Password 本身带 [JsonIgnore] 不会写出
PasswordEnc = string.IsNullOrEmpty(Password) ? "" : Protect(Password);
var json = JsonConvert.SerializeObject(this, Formatting.Indented); var json = JsonConvert.SerializeObject(this, Formatting.Indented);
try try
{ {
+18 -8
View File
@@ -39,7 +39,7 @@ namespace TfsExcelImporter
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnBrowse.Click += (s, e) => BrowseExcel(); _btnBrowse.Click += (s, e) => BrowseExcel();
_btnTemplate.Click += (s, e) => ExportTemplate(); _btnTemplate.Click += (s, e) => ExportTemplate();
_btnPreview.Click += (s, e) => PreviewData(); _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();
@@ -222,7 +222,7 @@ namespace TfsExcelImporter
} }
// ------------------------------------------------------------- Excel // ------------------------------------------------------------- Excel
private void BrowseExcel() private async void BrowseExcel()
{ {
using (var dlg = new OpenFileDialog()) using (var dlg = new OpenFileDialog())
{ {
@@ -235,11 +235,11 @@ namespace TfsExcelImporter
if (dlg.ShowDialog(this) != DialogResult.OK) return; if (dlg.ShowDialog(this) != DialogResult.OK) return;
_txtExcel.Text = dlg.FileName; _txtExcel.Text = dlg.FileName;
SaveUiToConfig(); SaveUiToConfig();
PreviewData(); await PreviewDataAsync();
} }
} }
private void ExportTemplate() private async void ExportTemplate()
{ {
using (var dlg = new SaveFileDialog()) using (var dlg = new SaveFileDialog())
{ {
@@ -253,7 +253,7 @@ namespace TfsExcelImporter
Log("模板已导出: " + dlg.FileName); Log("模板已导出: " + dlg.FileName);
_txtExcel.Text = dlg.FileName; _txtExcel.Text = dlg.FileName;
SaveUiToConfig(); SaveUiToConfig();
PreviewData(); await PreviewDataAsync();
} }
catch (Exception ex) 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(); var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path)) if (string.IsNullOrEmpty(path) || !File.Exists(path))
@@ -272,9 +275,10 @@ namespace TfsExcelImporter
Append("预览未执行:Excel 文件不存在", LogLevel.Warn); Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
return; return;
} }
SetBusy(true);
try try
{ {
_rows = ExcelService.ReadRows(path); _rows = await Task.Run(() => ExcelService.ReadRows(path));
_grid.DataSource = null; _grid.DataSource = null;
_grid.DataSource = _rows.Select((r, i) => new _grid.DataSource = _rows.Select((r, i) => new
{ {
@@ -305,6 +309,10 @@ namespace TfsExcelImporter
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
UpdateEmptyState(); UpdateEmptyState();
} }
finally
{
SetBusy(false);
}
} }
// ------------------------------------------------------------- Area 修正 // ------------------------------------------------------------- Area 修正
@@ -421,13 +429,15 @@ namespace TfsExcelImporter
} }
if (_rows.Count == 0) if (_rows.Count == 0)
{ {
try { _rows = ExcelService.ReadRows(path); } SetBusy(true);
try { _rows = await Task.Run(() => ExcelService.ReadRows(path)); }
catch (Exception ex) catch (Exception ex)
{ {
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
Log("读取 Excel 失败: " + FirstLine(ex.Message)); Log("读取 Excel 失败: " + FirstLine(ex.Message));
return; return;
} }
finally { SetBusy(false); }
} }
if (_rows.Count == 0) if (_rows.Count == 0)
{ {
+3 -1
View File
@@ -367,7 +367,9 @@ namespace TfsExcelImporter
{ {
return await Task.Run(() => 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.TeamProject] = '{EscapeWiql(project)}' AND " +
$"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " + $"[System.WorkItemType] = '{EscapeWiql(workItemType)}' AND " +
$"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " + $"[System.IterationPath] = '{EscapeWiql(iterationPath)}' AND " +
+5
View File
@@ -31,6 +31,11 @@
<PackageReference Include="Costura.Fody" Version="5.7.0" PrivateAssets="all" /> <PackageReference Include="Costura.Fody" Version="5.7.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<!-- DPAPI(ProtectedData):config.json 密码加密 -->
<ItemGroup>
<Reference Include="System.Security" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="config.json"> <None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>