Files
TFSImport/TfsExcelImporter/MainForm.cs
T
chenshoulin 14f5e32262 feat: 增强数据导入功能与用户界面优化
- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。
- 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。
- 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。
- 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。
- 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
2026-11-02 10:40:52 +08:00

752 lines
35 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();
_btnLoadAreas.Click += async (s, e) => await LoadAreasAsync();
_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 })
tb.TextChanged += (s, e) => OnFieldChanged();
_cmbIteration.TextChanged += (s, e) => OnFieldChanged();
_cmbArea.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;
_cmbArea.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 = _cmbArea.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;
}
}
// ------------------------------------------------------------- 迭代 / Area 列表
// 两者都是 TFS 分类结构(ICommonStructureService),取数、填表、报错完全同构,
// 只有「结构根节点」「是否自动选第一条」不同,所以共用一套加载流程。
private async Task LoadIterationsAsync()
{
await LoadPathListAsync("迭代", c => c.GetIterationPathsAsync(_cfg.Project, Log),
_cmbIteration, _fIteration, _errIteration, true);
}
private async Task LoadAreasAsync()
{
// Area 不自动选第一条:选错区域会静默写到别人的团队视图下,让用户自己挑更安全
await LoadPathListAsync("Area", c => c.GetAreaPathsAsync(_cfg.Project, Log),
_cmbArea, _fArea, _errArea, false);
}
private async Task LoadPathListAsync(string what, Func<TfsClient, Task<List<string>>> fetch,
ComboBox combo, InputField field, Label err, bool selectFirstWhenNoMatch)
{
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.TfsUrl))
{
Append("请先填写服务器 URL", LogLevel.Warn);
OpenDetail(_cardConn);
return;
}
SetBusy(true);
SetConnection($"读取 {what} 列表中…", 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 fetch(client);
FillPathCombo(combo, paths, selectFirstWhenNoMatch);
Log($"已加载 {combo.Items.Count} 条 {what} 路径");
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
ShowError(err, field, _cardData, "");
}
}
catch (Exception ex)
{
var msg = FirstLine(ex.Message);
// TF30063 = 服务器拒绝当前身份:与「连不上」是两回事,提示要能让用户知道该去填账号密码
if (msg.IndexOf("TF30063", StringComparison.OrdinalIgnoreCase) >= 0)
{
SetConnection("认证失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(err, field, _cardData, "认证失败:请在步骤 1 填写可访问该项目的用户名/密码");
Log($"加载 {what} 失败: 未授权(TF30063),当前 Windows 身份无权访问该项目");
Log("排查: 用户名可写 user 或 DOMAIN\\user;程序固定走 NTLM 直连。");
}
else
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(err, field, _cardData, $"{what} 列表加载失败,可手填完整路径");
Log($"加载 {what} 失败: " + msg);
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
}
}
finally
{
SetBusy(false);
OnFieldChanged();
}
}
/// <summary>
/// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。
/// Items.Clear 会把 DropDown 样式的 Text 一并清空,所以要在清空前取值、匹配后回填。
/// </summary>
private static void FillPathCombo(ComboBox combo, List<string> paths, bool selectFirstWhenNoMatch)
{
var keep = combo.Text;
combo.Items.Clear();
foreach (var p in paths) combo.Items.Add(p);
if (combo.Items.Count == 0)
{
combo.Text = keep;
return;
}
int match = -1;
for (int i = 0; i < combo.Items.Count; i++)
{
if (string.Equals(combo.Items[i].ToString(), keep, StringComparison.OrdinalIgnoreCase))
{
match = i;
break;
}
}
if (match >= 0) combo.SelectedIndex = match;
else if (selectFirstWhenNoMatch) combo.SelectedIndex = 0;
else combo.Text = keep;
}
// ------------------------------------------------------------- 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()
{
var startDir = ExportStartDirectory();
using (var dlg = new SaveFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
dlg.Title = "导出默认模板";
dlg.DefaultExt = "xlsx";
dlg.AddExtension = true;
// 关掉系统自带的"是否替换",换成能看清目标文件是什么的确认框(见 ConfirmOverwriteTemplate)
dlg.OverwritePrompt = false;
dlg.InitialDirectory = startDir;
// 关键:FileName 给完整路径。只给文件名时对话框会沿用系统"上次目录",
// 那个目录里往往躺着 TFS导入模板11.xlsx 这类近似名,容易选错/覆盖到已有数据表
dlg.FileName = Path.Combine(startDir, ExcelService.TemplateFileName);
if (dlg.ShowDialog(this) != DialogResult.OK) return;
var path = dlg.FileName;
try
{
if (!ConfirmOverwriteTemplate(path)) return;
bool embeddedOnly;
if (!ConfirmTemplateSource(out embeddedOnly)) return;
var srcDesc = ExcelService.CopyTemplate(path, embeddedOnly);
Log("模板已导出(来源:" + srcDesc + "): " + path);
_txtExcel.Text = path;
SaveUiToConfig();
await PreviewDataAsync();
}
catch (Exception ex)
{
Log("导出模板失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
}
}
}
/// <summary>
/// 导出对话框起始目录:优先当前 Excel 所在目录,其次桌面。
/// 显式给目录,避免对话框落到"系统记住的上次目录"而出乎意料。
/// </summary>
private string ExportStartDirectory()
{
var last = _txtExcel.Text.Trim();
if (!string.IsNullOrEmpty(last))
{
try
{
var dir = Path.GetDirectoryName(last);
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) return dir;
}
catch { }
}
try
{
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (!string.IsNullOrEmpty(desktop) && Directory.Exists(desktop)) return desktop;
}
catch { }
return AppDomain.CurrentDomain.BaseDirectory;
}
/// <summary>
/// 覆盖确认。系统默认提示只问"是否替换",看不出目标文件其实是用户已有的数据表,
/// 这里把路径、大小、修改时间和已有数据行数都摆出来,避免把导入数据当模板覆盖掉。
/// </summary>
private bool ConfirmOverwriteTemplate(string path)
{
FileInfo fi;
try { fi = new FileInfo(path); }
catch { return true; }
if (!fi.Exists) return true;
// 大文件数行会卡住界面:超过 20MB 就只报大小/时间,不数行
int rows = fi.Length <= 20 * 1024 * 1024 ? ExcelService.CountDataRows(path) : 0;
var sb = new StringBuilder();
sb.AppendLine("目标位置已存在文件,继续将用空白模板覆盖它:");
sb.AppendLine(path);
sb.AppendLine();
sb.AppendLine("大小 " + FormatSize(fi.Length) + " · 修改时间 " + fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm"));
if (rows > 0)
sb.AppendLine("该文件已有 " + rows + " 行数据,覆盖后无法恢复!");
else if (rows < 0)
sb.AppendLine("该文件读不出内容(可能正被 Excel 打开,或不是 Excel 文件)。");
return MessageBox.Show(this, sb.ToString(), "确认覆盖已有文件",
MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK;
}
private static string FormatSize(long bytes)
{
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("0.#") + " KB";
return (bytes / 1024.0 / 1024.0).ToString("0.#") + " MB";
}
/// <summary>
/// 导出前确认模板来源。exe 同目录的 TFS导入模板.xlsx 一旦被现场替换、或被填过数据,
/// 导出结果就不再是空白模板——用户会看到"导出的模板里带着别的数据"。
/// 这里把选择权交出来:内置空白模板 / 仍用该文件 / 不导出。
/// </summary>
private bool ConfirmTemplateSource(out bool embeddedOnly)
{
embeddedOnly = false;
var local = ExcelService.LocalTemplatePath;
if (!File.Exists(local) || ExcelService.LocalTemplateIsBuiltIn()) return true;
int rows = ExcelService.CountDataRows(local);
var sb = new StringBuilder();
sb.AppendLine("exe 同目录的模板文件与内置模板不一致:");
sb.AppendLine(local);
sb.AppendLine();
if (rows > 0)
sb.AppendLine("它当前有 " + rows + " 行内容,用它导出的模板会带上这些行。");
else if (rows < 0)
sb.AppendLine("它读不出数据(可能正被 Excel 打开,或不是模板文件)。");
else
sb.AppendLine("它已被替换为自定义模板。");
sb.AppendLine();
sb.AppendLine("是(Y):改用内置空白模板");
sb.AppendLine("否(N):仍用这个文件");
sb.AppendLine("取消:不导出");
var r = MessageBox.Show(this, sb.ToString(), "模板来源确认",
MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning);
if (r == DialogResult.Cancel) return false;
embeddedOnly = r == DialogResult.Yes;
return true;
}
// ------------------------------------------------------------- 预览
// 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)
{
// 读取失败必须清掉上一次的数据,否则表格会继续显示旧文件的行,
// 看起来就像"刚导出的文件里装的是别的文件的数据"
_rows = new List<WorkItemRow>();
_grid.DataSource = null;
_lblStatus.Text = "读取失败";
Log("读取 Excel 失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
UpdateEmptyState();
RefreshSummaries();
}
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, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
_cmbArea.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, _cardData, "");
}
}
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;
}
// Area 必填:不写 AreaPath 的工作项落在项目根区域,团队视图里看不到(历史坑)
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
Append("导入取消:Area 路径未填", LogLevel.Warn);
_cmbArea.Focus();
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(_errArea, _fArea, _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($"目标: 迭代 {_cfg.IterationPath} Area {_cfg.AreaPath}");
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),
// Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域
new KeyValuePair<string, object>(areaRef, _cfg.AreaPath.Trim())
};
if (!string.IsNullOrWhiteSpace(row.AssignedTo))
fields.Add(new KeyValuePair<string, object>(assignedRef, row.AssignedTo.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);
}
}
}
}