Files
TFSImport/TfsExcelImporter/MainForm.cs
T
chenshoulin 8a6f9d55bc refactor: 移除不再使用的配置文件与代码,优化用户界面
- 删除 config.json 和 requirements.txt 文件,移除 Python 版相关代码,简化项目结构。
- 更新 README.md,调整工具说明,强调仅保留 C# WinForms 版本。
- 在 MainForm.cs 中添加输入框的占位提示,提升用户体验。
- 更新 .gitignore,添加 debug.log 文件以记录运行时日志。
- 移除与 Python 版相关的示例 CSV 文件,确保数据源一致性。
2026-11-01 21:44:31 +08:00

577 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 += (s, e) => PreviewData();
_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 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();
PreviewData();
}
}
private 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();
PreviewData();
}
catch (Exception ex)
{
Log("导出模板失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
}
}
}
private void PreviewData()
{
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
return;
}
try
{
_rows = 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();
}
}
// ------------------------------------------------------------- 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)
{
try { _rows = ExcelService.ReadRows(path); }
catch (Exception ex)
{
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
Log("读取 Excel 失败: " + FirstLine(ex.Message));
return;
}
}
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);
}
}
}
}