Files
TFSImport/TfsExcelImporter/MainForm.cs
T
chenshoulin 8824a81758 feat: 实现密码加密存储与异步数据预览功能
- 在 Config.cs 中添加 DPAPI 加密支持,确保密码安全存储,兼容旧版配置。
- 更新 MainForm.cs,重构数据预览逻辑为异步方法,提升用户体验,避免界面冻结。
- 修改相关方法以支持异步操作,确保在处理大文件时界面响应流畅。
- 更新项目文件以引用 System.Security,支持加密功能。
2026-11-01 21:57:53 +08:00

587 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
/// </summary>
public partial class MainForm : Form
{
private AppConfig _cfg;
private List<WorkItemRow> _rows = new List<WorkItemRow>();
public MainForm()
{
// System DPI 感知(见 app.manifest):先定缩放系数,再按它摆控件
using (var g = Graphics.FromHwnd(IntPtr.Zero))
Theme.Scale = g.DpiX / 96f;
_cfg = AppConfig.Load();
Icon = AppIcon.Load();
BuildUi();
LoadConfigToUi();
WireEvents();
RefreshRibbon();
RefreshSummaries();
SetConnection("连接未验证", ChipKind.Neutral);
UpdateEmptyState();
}
private void WireEvents()
{
_btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
_btnBrowse.Click += (s, e) => BrowseExcel();
_btnTemplate.Click += (s, e) => ExportTemplate();
_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();
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd,
_txtExcel, _txtParentPbi, _txtAreaPath })
tb.TextChanged += (s, e) => OnFieldChanged();
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
_chkSkip.CheckedChanged += (s, e) => OnFieldChanged();
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
}
private void OnFieldChanged()
{
RefreshRibbon();
RefreshSummaries();
}
// ------------------------------------------------------------- 配置互转
private void LoadConfigToUi()
{
_txtUrl.Text = _cfg.TfsUrl;
_txtProject.Text = _cfg.Project;
_txtType.Text = _cfg.WorkItemType;
_txtUser.Text = _cfg.Username;
_txtPwd.Text = _cfg.Password;
_cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath;
_txtAreaPath.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_txtParentPbi.Text = _cfg.ParentPbiId;
}
private void SaveUiToConfig()
{
_cfg.TfsUrl = _txtUrl.Text.Trim();
_cfg.Project = _txtProject.Text.Trim();
_cfg.WorkItemType = _txtType.Text.Trim();
_cfg.Username = _txtUser.Text.Trim();
_cfg.Password = _txtPwd.Text;
_cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _txtAreaPath.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.Save();
}
// ------------------------------------------------------------- 日志
private void Log(string msg)
{
Append(msg, LogView.Classify(msg));
}
private static string FirstLine(string text)
{
if (string.IsNullOrEmpty(text)) return "";
var i = text.IndexOf('\n');
return i < 0 ? text : text.Substring(0, i).Trim();
}
// ------------------------------------------------------------- 连接测试
private async Task TestConnectionAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
SetConnection("URL 未填", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
Append("请先填写服务器 URL", LogLevel.Warn);
_txtUrl.Focus();
return;
}
SetConnection("连接中…", ChipKind.Info);
SetBadgeOf(_cardConn, BadgeKind.Active);
_btnTest.Enabled = false;
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
try
{
var fm = await client.GetFieldMapAsync();
Log($"工作项字段元数据可用({fm.Count} 个字段)");
}
catch (Exception ex)
{
Log("警告: 字段元数据获取失败,导入可能受影响: " + FirstLine(ex.Message));
}
}
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
OpenDetail(_cardData);
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
Log("连接失败: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
_cardConn.Expanded = true;
_cardConn.ApplyHeight();
Relayout();
}
finally
{
_btnTest.Enabled = true;
}
}
// ------------------------------------------------------------- 迭代加载
private async Task LoadIterationsAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
Append("请先填写服务器 URL", LogLevel.Warn);
OpenDetail(_cardConn);
return;
}
SetBusy(true);
SetConnection("读取迭代中…", ChipKind.Info);
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
// 提前探测导入所需字段元数据,避免导入时才发现取不到
try
{
var fm = await client.GetFieldMapAsync();
Log($"工作项字段元数据可用({fm.Count} 个字段)");
}
catch (Exception ex)
{
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} 个迭代路径");
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
ShowError(_errIteration, _fIteration, _cardData, "");
}
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
Log("加载迭代失败: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
finally
{
SetBusy(false);
OnFieldChanged();
}
}
// ------------------------------------------------------------- Excel
private async void BrowseExcel()
{
using (var dlg = new OpenFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx;*.xlsm)|*.xlsx;*.xlsm|所有文件 (*.*)|*.*";
dlg.Title = "选择要导入的 Excel 文件";
if (!string.IsNullOrEmpty(_txtExcel.Text))
{
try { dlg.InitialDirectory = Path.GetDirectoryName(_txtExcel.Text); } catch { }
}
if (dlg.ShowDialog(this) != DialogResult.OK) return;
_txtExcel.Text = dlg.FileName;
SaveUiToConfig();
await PreviewDataAsync();
}
}
private async void ExportTemplate()
{
using (var dlg = new SaveFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
dlg.FileName = ExcelService.TemplateFileName;
dlg.Title = "导出默认模板";
if (dlg.ShowDialog(this) != DialogResult.OK) return;
try
{
ExcelService.CopyTemplate(dlg.FileName);
Log("模板已导出: " + dlg.FileName);
_txtExcel.Text = dlg.FileName;
SaveUiToConfig();
await PreviewDataAsync();
}
catch (Exception ex)
{
Log("导出模板失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
}
}
}
// ------------------------------------------------------------- 预览
// ClosedXML 全量载入工作簿,大文件在 UI 线程读会冻结界面——放 Task.Run,
// 读期间 SetBusy 防止重复点击;await 后自动回到 UI 线程再更新表格。
private async Task PreviewDataAsync()
{
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
return;
}
SetBusy(true);
try
{
_rows = await Task.Run(() => ExcelService.ReadRows(path));
_grid.DataSource = null;
_grid.DataSource = _rows.Select((r, i) => new
{
行号 = i + 1,
标题 = r.Title,
状态 = r.State,
指派 = r.AssignedTo,
项目 = r.FinancialProject,
完成量 = r.Completed,
日期 = r.LogDate,
父PBI = r.PbiId,
说明 = r.Description
}).ToList();
ShowError(_errExcel, _fExcel, _cardData, "");
StyleGridColumns();
_lblStatus.Text = $"已读取 {_rows.Count} 行";
Log($"读取 {path} 共 {_rows.Count} 行");
if (_rows.Count == 0) Log("警告: 没有可导入的数据行(Title 为空的行会被忽略)");
SetBadgeOf(_cardData, BadgeKind.Done);
SetBadgeOf(_cardPreview, _rows.Count > 0 ? BadgeKind.Done : BadgeKind.Warn);
UpdateEmptyState();
RefreshSummaries();
SaveUiToConfig();
}
catch (Exception ex)
{
Log("读取 Excel 失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
UpdateEmptyState();
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- Area 修正
private async Task FixAreaAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "修 Area 需要先选择目标 Iteration");
Append("按迭代修 Area 取消:目标迭代未选", LogLevel.Warn);
return;
}
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
OpenDetail(_cardAdv);
_txtAreaPath.Focus();
return;
}
var confirm = MessageBox.Show(this,
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且 Area≠{_cfg.AreaPath} 的工作项\nArea 改为 {_cfg.AreaPath}。\n\n注意:已关闭(如 Done)的工作项会被临时重新打开,改完 Area 后自动还原状态。\n\n确认执行?",
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
SetBusy(true);
OpenDetail(_cardLog);
try
{
using (var client = new TfsClient(_cfg))
{
var r = await client.FixAreaAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType, _cfg.AreaPath, Log);
var summary = r.Item2 > 0
? $"Area 修正完成:成功 {r.Item1} 条,失败 {r.Item2} 条(详见日志)"
: $"Area 修正完成,共 {r.Item1} 条";
_lblStatus.Text = summary;
Log(summary);
ShowError(_errArea, _fArea, _cardAdv, "");
}
}
catch (Exception ex)
{
Log("Area 修正失败: " + FirstLine(ex.Message));
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- 补挂父项
private async Task LinkParentAsync()
{
SaveUiToConfig();
int parentId;
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "补挂父项需要先选择目标 Iteration");
Append("补挂父项取消:目标迭代未选", LogLevel.Warn);
return;
}
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
{
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
_txtParentPbi.Focus();
return;
}
var confirm = MessageBox.Show(this,
$"将把 迭代「{_cfg.IterationPath}」下 类型={_cfg.WorkItemType} 且尚无父项的工作项\n补挂为 #{parentId} 的子项。\n\n已有父项的自动跳过;已关闭的工作项会临时重新打开,挂完自动还原状态。\n\n确认执行?",
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
SetBusy(true);
OpenDetail(_cardLog);
try
{
using (var client = new TfsClient(_cfg))
{
var r = await client.LinkExistingAsync(_cfg.Project, _cfg.IterationPath,
_cfg.WorkItemType, parentId, Log);
var summary = $"补挂完成: 挂接 {r.Item1} / 已有父跳过 {r.Item2} / 失败 {r.Item3}";
_lblStatus.Text = summary;
Log(summary);
Log("挂接后在积压工作页展开父项即可看到子项。");
ShowError(_errParent, _fParent, _cardData, "");
}
}
catch (Exception ex)
{
Log("补挂父项失败: " + FirstLine(ex.Message));
}
finally
{
SetBusy(false);
}
}
// ------------------------------------------------------------- 导入
private async Task ImportAsync()
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
ShowError(_errIteration, _fIteration, _cardData, "请先选择目标 Iteration(可点「加载迭代列表」,也可手填完整路径)");
Append("导入取消:目标迭代未选", LogLevel.Warn);
return;
}
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("导入取消:Excel 文件不存在", LogLevel.Warn);
return;
}
if (_rows.Count == 0)
{
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)
{
ShowError(_errExcel, _fExcel, _cardData, "Excel 中没有可导入的数据行");
Append("导入取消:没有可导入的数据行", LogLevel.Warn);
return;
}
ShowError(_errIteration, _fIteration, _cardData, "");
ShowError(_errExcel, _fExcel, _cardData, "");
SetRunBadge(BadgeKind.Active);
SetBusy(true);
OpenDetail(_cardLog);
_pbar.Maximum = _rows.Count;
_pbar.Value = 0;
int ok = 0, skip = 0, fail = 0;
var report = new StringBuilder();
report.AppendLine("行号,Title,结果,WorkItemId,错误");
try
{
using (var client = new TfsClient(_cfg))
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
// 字段解析
var fieldMap = await client.GetFieldMapAsync();
var titleRef = "System.Title";
var stateRef = "System.State";
var assignedRef = "System.AssignedTo";
var iterationRef = "System.IterationPath";
var areaRef = "System.AreaPath";
var finRef = TfsClient.ResolveRef(fieldMap, "FinancialProject", _cfg.FinancialProjectRef);
var compRef = TfsClient.ResolveRef(fieldMap, "Completed", _cfg.CompletedRef);
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($"父PBI: '{(string.IsNullOrWhiteSpace(_cfg.ParentPbiId) ? "(未填)" : _cfg.ParentPbiId)}'——Excel 每行可用 PBI Id 列覆盖");
HashSet<string> existing = null;
if (_chkSkip.Checked)
{
Log("查询已有工作项标题...");
existing = await client.GetExistingTitlesAsync(_cfg.Project, _cfg.IterationPath, _cfg.WorkItemType);
Log($"服务器已有 {existing.Count} 条");
}
for (int i = 0; i < _rows.Count; i++)
{
var row = _rows[i];
var line = i + 2; // Excel 实际行号(含表头)
try
{
if (existing != null && existing.Contains(row.Title))
{
skip++;
report.AppendLine($"{line},\"{row.Title}\",SKIP,,标题已存在");
Log($"[{i + 1}/{_rows.Count}] SKIP {row.Title}");
continue;
}
var fields = new List<KeyValuePair<string, object>>
{
new KeyValuePair<string, object>(titleRef, row.Title),
new KeyValuePair<string, object>(stateRef,
string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()),
new KeyValuePair<string, object>(iterationRef, _cfg.IterationPath)
};
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.Trim()));
if (!string.IsNullOrWhiteSpace(_cfg.AreaPath))
fields.Add(new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim()));
if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null)
fields.Add(new KeyValuePair<string, object>(finRef, row.FinancialProject.Trim()));
var completed = ExcelService.ToNumber(row.Completed);
if (completed != null && compRef != null)
fields.Add(new KeyValuePair<string, object>(compRef, completed));
// Original Estimate 默认取 Completed 的值
if (completed != null && origEstRef != null)
fields.Add(new KeyValuePair<string, object>(origEstRef, completed));
if (!string.IsNullOrWhiteSpace(row.Description))
fields.Add(new KeyValuePair<string, object>("System.Description", row.Description.Trim()));
var logDate = ExcelService.ToIsoDateTime(row.LogDate);
if (logDate != null && logRef != null)
fields.Add(new KeyValuePair<string, object>(logRef, logDate));
// 父 PBI:行级 PBI Id 列优先,其次界面默认值
var pbiText = !string.IsNullOrWhiteSpace(row.PbiId) ? row.PbiId.Trim() : _cfg.ParentPbiId;
int? parentPbi = int.TryParse(pbiText, out var pbiNum) ? pbiNum : (int?)null;
var id = await client.CreateWorkItemAsync(_cfg.Project, _cfg.WorkItemType, fields, parentPbi);
ok++;
report.AppendLine($"{line},\"{row.Title}\",OK,{id},");
Log($"[{i + 1}/{_rows.Count}] OK #{id} {row.Title}");
}
catch (Exception ex)
{
fail++;
report.AppendLine($"{line},\"{row.Title}\",FAIL,,\"{ex.Message.Replace("\"", "'")}\"");
Log($"[{i + 1}/{_rows.Count}] FAIL {row.Title} -> {ex.Message}");
}
_pbar.Value = Math.Min(i + 1, _pbar.Maximum);
_lblStatus.Text = $"{i + 1}/{_rows.Count}";
SetChips(ok, skip, fail, false);
}
SetRunBadge(fail > 0 ? BadgeKind.Warn : BadgeKind.Done);
}
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetRunBadge(BadgeKind.Warn);
Log("导入中断: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
finally
{
SetBusy(false);
try
{
var reportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"import_report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv");
File.WriteAllText(reportPath, report.ToString(), Encoding.UTF8);
_lastReportPath = reportPath;
Log("报告已保存: " + reportPath);
}
catch (Exception ex) { Log("写报告失败: " + FirstLine(ex.Message)); }
_pbar.Value = _pbar.Maximum;
// 成功/跳过/失败的分布已在右侧三个胶囊里,这里只补胶囊没有的「总数」,明细仍写日志
_lblStatus.Text = $"完成 {ok + skip + fail}/{_rows.Count}";
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
SetChips(ok, skip, fail, false);
RefreshSummaries();
if (fail == 0 && ok > 0) OpenDetail(_cardLog);
}
}
}
}