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.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);
}
/// <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
// (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<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)
@@ -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
{
+18 -8
View File
@@ -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)
{
+3 -1
View File
@@ -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 " +
+5
View File
@@ -31,6 +31,11 @@
<PackageReference Include="Costura.Fody" Version="5.7.0" PrivateAssets="all" />
</ItemGroup>
<!-- DPAPI(ProtectedData):config.json 密码加密 -->
<ItemGroup>
<Reference Include="System.Security" />
</ItemGroup>
<ItemGroup>
<None Update="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>