feat: 增强数据导入功能与用户界面优化

- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。
- 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。
- 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。
- 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。
- 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
This commit is contained in:
chenshoulin
2026-11-02 10:40:52 +08:00
parent ffc210f827
commit 14f5e32262
7 changed files with 409 additions and 91 deletions
+207 -41
View File
@@ -37,6 +37,7 @@ namespace TfsExcelImporter
{
_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();
@@ -44,9 +45,10 @@ namespace TfsExcelImporter
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
foreach (var tb in new[] { _txtUrl, _txtProject, _txtType, _txtUser, _txtPwd,
_txtExcel, _txtParentPbi, _txtAreaPath })
_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 { } };
@@ -68,7 +70,7 @@ namespace TfsExcelImporter
_txtPwd.Text = _cfg.Password;
_cmbIteration.Text = _cfg.IterationPath;
_txtExcel.Text = _cfg.LastExcelPath;
_txtAreaPath.Text = _cfg.AreaPath;
_cmbArea.Text = _cfg.AreaPath;
_chkSkip.Checked = _cfg.SkipExistingTitles;
_txtParentPbi.Text = _cfg.ParentPbiId;
}
@@ -82,7 +84,7 @@ namespace TfsExcelImporter
_cfg.Password = _txtPwd.Text;
_cfg.IterationPath = _cmbIteration.Text.Trim();
_cfg.LastExcelPath = _txtExcel.Text.Trim();
_cfg.AreaPath = _txtAreaPath.Text.Trim();
_cfg.AreaPath = _cmbArea.Text.Trim();
_cfg.SkipExistingTitles = _chkSkip.Checked;
_cfg.ParentPbiId = _txtParentPbi.Text.Trim();
_cfg.Save();
@@ -153,8 +155,24 @@ namespace TfsExcelImporter
}
}
// ------------------------------------------------------------- 迭代加载
// ------------------------------------------------------------- 迭代 / 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))
@@ -164,7 +182,7 @@ namespace TfsExcelImporter
return;
}
SetBusy(true);
SetConnection("读取迭代中…", ChipKind.Info);
SetConnection($"读取 {what} 列表中…", ChipKind.Info);
try
{
using (var client = new TfsClient(_cfg))
@@ -181,37 +199,34 @@ namespace TfsExcelImporter
{
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} 个迭代路径");
var paths = await fetch(client);
FillPathCombo(combo, paths, selectFirstWhenNoMatch);
Log($"已加载 {combo.Items.Count} 条 {what} 路径");
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
ShowError(_errIteration, _fIteration, _cardData, "");
ShowError(err, field, _cardData, "");
}
}
catch (Exception ex)
{
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
Log("加载迭代失败: " + FirstLine(ex.Message));
Log("排查: 程序固定直连(不走本机系统代理);DNS 失败多为 VPN 或未连内网。");
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
{
@@ -220,6 +235,34 @@ namespace TfsExcelImporter
}
}
/// <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()
{
@@ -240,17 +283,32 @@ namespace TfsExcelImporter
private async void ExportTemplate()
{
var startDir = ExportStartDirectory();
using (var dlg = new SaveFileDialog())
{
dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx";
dlg.FileName = ExcelService.TemplateFileName;
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
{
ExcelService.CopyTemplate(dlg.FileName);
Log("模板已导出: " + dlg.FileName);
_txtExcel.Text = dlg.FileName;
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();
}
@@ -262,6 +320,99 @@ namespace TfsExcelImporter
}
}
/// <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 线程再更新表格。
@@ -304,9 +455,15 @@ namespace TfsExcelImporter
}
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
{
@@ -326,9 +483,8 @@ namespace TfsExcelImporter
}
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
OpenDetail(_cardAdv);
_txtAreaPath.Focus();
ShowError(_errArea, _fArea, _cardData, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
_cmbArea.Focus();
return;
}
var confirm = MessageBox.Show(this,
@@ -348,7 +504,7 @@ namespace TfsExcelImporter
: $"Area 修正完成,共 {r.Item1} 条";
_lblStatus.Text = summary;
Log(summary);
ShowError(_errArea, _fArea, _cardAdv, "");
ShowError(_errArea, _fArea, _cardData, "");
}
}
catch (Exception ex)
@@ -419,6 +575,14 @@ namespace TfsExcelImporter
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))
{
@@ -446,6 +610,7 @@ namespace TfsExcelImporter
}
ShowError(_errIteration, _fIteration, _cardData, "");
ShowError(_errArea, _fArea, _cardData, "");
ShowError(_errExcel, _fExcel, _cardData, "");
SetRunBadge(BadgeKind.Active);
SetBusy(true);
@@ -478,6 +643,7 @@ namespace TfsExcelImporter
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;
@@ -507,12 +673,12 @@ namespace TfsExcelImporter
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)
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(_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()));