feat: 实现密码加密存储与异步数据预览功能
- 在 Config.cs 中添加 DPAPI 加密支持,确保密码安全存储,兼容旧版配置。 - 更新 MainForm.cs,重构数据预览逻辑为异步方法,提升用户体验,避免界面冻结。 - 修改相关方法以支持异步操作,确保在处理大文件时界面响应流畅。 - 更新项目文件以引用 System.Security,支持加密功能。
This commit is contained in:
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 " +
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user