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
+2 -1
View File
@@ -62,7 +62,8 @@ namespace TfsExcelImporter
// 旧版本有 BypassRules 开关,已移除:TFS 2013 对应的 Client OM 14.x 没有 SaveFlags.BypassRules
// (15.x 才加,而 15.x 不兼容 TFS 2013),勾选从未生效。旧 config.json 里的残留键由
// Newtonsoft 按默认 MissingMemberHandling.Ignore 忽略,不需要手工清理。
public bool SkipExistingTitles { get; set; } = true;
// 默认不勾选:首次导入时希望先看到真实冲突/报错,而不是被静默跳过
public bool SkipExistingTitles { get; set; } = false;
public string DefaultState { get; set; } = "To Do";
// 自定义字段引用名;留空则按 DisplayName 自动解析
+110 -15
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using ClosedXML.Excel;
namespace TfsExcelImporter
@@ -45,35 +46,129 @@ namespace TfsExcelImporter
// --------------------------------------------------------- 默认模板
/// <summary>
/// 把默认模板原样复制到目标路径。优先用 exe 同目录的同名文件(可由现场替换),
/// 没有则从 exe 内嵌资源释放——单文件便携分发时无需附带模板。
/// </summary>
public static void CopyTemplate(string path)
/// <summary>exe 同目录的模板路径(现场可替换,存在时优先于内嵌模板)。</summary>
public static string LocalTemplatePath
{
var src = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName);
if (File.Exists(src))
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplateFileName); }
}
/// <summary>
/// exe 同目录的模板是否还是构建时那份(与内嵌模板内容一致)。
/// 现场替换过、或被填过数据都算"不一致",导出前应由调用方确认来源。
/// </summary>
public static bool LocalTemplateIsBuiltIn()
{
var local = LocalTemplatePath;
if (!File.Exists(local)) return true;
try
{
File.Copy(src, path, true);
return;
var embed = ReadEmbeddedTemplate();
return embed != null && SameDigest(embed, File.ReadAllBytes(local));
}
catch { return false; }
}
/// <summary>
/// 把默认模板原样写到目标路径,返回模板来源描述(用于日志)。
/// 优先用 exe 同目录的同名文件(现场可替换),forceEmbedded 或该文件不存在时用内嵌资源。
///
/// 模板源只有这两个确定来源,绝不按"相近文件名"去猜;目标就是调用方选定的那一个路径。
/// 写出后再比对一次内容,避免"报成功但文件不是模板"这类静默错。
/// </summary>
public static string CopyTemplate(string path, bool forceEmbedded = false)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("导出路径为空", nameof(path));
var dst = Path.GetFullPath(path);
var src = LocalTemplatePath;
if (!forceEmbedded && File.Exists(src))
{
var srcFull = Path.GetFullPath(src);
// 目标就是源本身:File.Copy 会抛"源和目标是同一个文件",先给出可读提示
if (string.Equals(srcFull, dst, StringComparison.OrdinalIgnoreCase))
throw new IOException(
"导出路径与模板源文件相同(" + srcFull + "),请换一个文件名或目录。");
File.Copy(srcFull, dst, true);
AssertSameContent(srcFull, dst);
return "exe 同目录 " + TemplateFileName;
}
var embed = ReadEmbeddedTemplate();
if (embed == null)
throw new FileNotFoundException(
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
// 先写临时文件再替换:中途失败不会把目标位置已有的文件写坏
var tmp = dst + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(tmp, embed);
File.Copy(tmp, dst, true);
AssertSameContent(tmp, dst);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
}
return "exe 内嵌模板";
}
private static byte[] ReadEmbeddedTemplate()
{
var asm = typeof(ExcelService).Assembly;
// 嵌入名取决于 Link 名:默认 <RootNamespace>.<文件名>
var name = asm.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith(TemplateFileName, StringComparison.OrdinalIgnoreCase));
if (name == null)
throw new FileNotFoundException(
"未找到默认模板:exe 内嵌资源与同目录 " + TemplateFileName + " 均缺失", src);
if (name == null) return null;
using (var input = asm.GetManifestResourceStream(name))
{
if (input == null) throw new FileNotFoundException("无法读取内嵌模板资源: " + name, src);
using (var output = File.Create(path))
input.CopyTo(output);
if (input == null) return null;
using (var buffer = new MemoryStream())
{
input.CopyTo(buffer);
return buffer.ToArray();
}
}
}
/// <summary>写出后比对内容,避免"导出成功但文件是旧的/被别的数据占了"这类静默错。</summary>
private static void AssertSameContent(string expected, string actual)
{
var a = new FileInfo(expected);
var b = new FileInfo(actual);
if (!b.Exists || b.Length != a.Length || !SameDigest(expected, actual))
throw new IOException("模板写出后校验不一致,目标文件内容与模板不同:" + actual);
}
private static bool SameDigest(string x, string y)
{
return SameDigest(File.ReadAllBytes(x), File.ReadAllBytes(y));
}
private static bool SameDigest(byte[] x, byte[] y)
{
if (x == null || y == null || x.Length != y.Length) return false;
using (var sha = SHA256.Create())
{
byte[] hx = sha.ComputeHash(x);
byte[] hy = sha.ComputeHash(y);
for (int i = 0; i < hx.Length; i++)
if (hx[i] != hy[i]) return false;
return true;
}
}
/// <summary>数据行数(Title 非空);文件被占用/损坏时返回 -1,调用方据此区分"读不出"与"空表"。</summary>
public static int CountDataRows(string path)
{
try { return ReadRows(path).Count; }
catch { return -1; }
}
// --------------------------------------------------------- 读取数据
/// <summary>读取 Excel(或同格式 CSV 由用户另存为 xlsx)数据行,跳过空标题行。</summary>
+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()));
+51 -16
View File
@@ -21,10 +21,11 @@ namespace TfsExcelImporter
private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
private ComboBox _cmbIteration = new ComboBox();
private ComboBox _cmbArea = new ComboBox();
private TextBox _txtExcel = new TextBox();
private TextBox _txtParentPbi = new TextBox();
private TextBox _txtAreaPath = new TextBox();
private Button _btnLoadIterations = new Button();
private Button _btnLoadAreas = new Button();
private Button _btnTemplate = new Button();
private CheckBox _chkSkip = new CheckBox();
@@ -337,34 +338,47 @@ namespace TfsExcelImporter
private void BuildCardData()
{
_cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44));
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,四项状态一眼可辨
_cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
_cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填
_fIteration = NewField(_cmbIteration, 500, false);
_fArea = NewField(_cmbArea, 500, true);
_fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_txtParentPbi, 150, true);
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
_fParent.SetCue("如 219975");
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnLoadAreas = NewButton("加载 Area 列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
// 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上,
// 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的
// 宽度变成同一个值,上面两个输入框的右边框才对得齐。
int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width);
_btnLoadIterations.Width = wBtn;
_btnLoadAreas.Width = wBtn;
_btnTemplate.Width = wBtn;
_chkSkip = NewCheck("跳过已存在标题", "");
// 三行字段对齐同一条左缘:标签列宽取最大值
int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id");
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
int wLbl = MaxLabelWidth("目标迭代", "Area 路径", "Excel 文件", "父 PBI Id")
+ Theme.Px(2) + Theme.TextWidth("*", Theme.Body);
int y = _cardData.HeaderH + Theme.S12;
y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration);
y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl, true), _fIteration);
AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top);
Track(_fIteration, _cardData, wBtn + G + M, 0);
y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl), _fExcel);
// Area 与迭代同属「写到哪儿」:紧贴迭代下方,值直接落进 System.AreaPath。
// 行尾同样挂一个列表按钮:Area 树往往比迭代树深得多,手打路径最容易写错。
y = Row(_cardData, y, 52, new Lbl("Area 路径", wLbl, true), _fArea);
AnchorRight(_btnLoadAreas, _cardData, M, _fArea.Top);
Track(_fArea, _cardData, wBtn + G + M, 0);
y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl, true), _fExcel);
// 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮
_fExcel.AddSuffix(IconKind.Folder, BrowseExcel);
AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
@@ -377,6 +391,7 @@ namespace TfsExcelImporter
y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip);
_errIteration = AddErrorUnder(_cardData, _fIteration);
_errArea = AddErrorUnder(_cardData, _fArea);
_errExcel = AddErrorUnder(_cardData, _fExcel);
_errParent = AddErrorUnder(_cardData, _fParent);
_cardData.BodyH = y - _cardData.HeaderH + Theme.S8;
@@ -554,17 +569,16 @@ namespace TfsExcelImporter
{
_cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
_cardAdv.ShowBadge = false;
_fArea = NewField(_txtAreaPath, 420, true);
_fArea.SetCue("如 Tools_Dev\\导入工具,留空则沿用工作项现有 Area");
// Area 输入框已上移到第 2 步(必填),这里只剩两个批量修正动作,
// 两者都按第 2 步填的「目标迭代 / Area 路径」筛选已有工作项。
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
int y = _cardAdv.HeaderH + Theme.S12;
y = Row(_cardAdv, y, 52, "Area 路径", _fArea,
new Hint("改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项"));
y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态");
_errArea = AddErrorUnder(_cardAdv, _fArea);
y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G,
new Hint("Area 取第 2 步的「Area 路径」,按「目标迭代」筛选;已关闭的工作项会临时重新打开,完成后自动还原状态"));
_cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
_cardAdv.SetSummary("按迭代批量修 Area / 补挂父项");
}
// ------------------------------------------------------------- 摆放助手
@@ -573,8 +587,15 @@ namespace TfsExcelImporter
{
public readonly string Text;
public readonly int MinWidth; // 运行时像素(构建时已含缩放)
/// <summary>必填项:标签后补一个红色 *,且列宽里预留它的位置。</summary>
public readonly bool Required;
public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; }
public Lbl(string text, int minWidth, bool required = false)
{
Text = text;
MinWidth = minWidth;
Required = required;
}
}
/// <summary>紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。</summary>
@@ -616,6 +637,21 @@ namespace TfsExcelImporter
};
lbl.SetBounds(x, y + (Theme.FieldH - lbl.PreferredHeight) / 2, lbl.PreferredWidth, lbl.PreferredHeight);
card.Controls.Add(lbl);
if (spec != null && spec.Required)
{
// 红色 * 紧跟标签右缘:颜色是这套界面里唯一的「必填」色板(与错误提示同色)
var star = new Label
{
Text = "*",
Font = Theme.Body,
ForeColor = Theme.Danger,
AutoSize = true,
BackColor = Theme.Card
};
star.SetBounds(x + lbl.PreferredWidth + Theme.Px(1),
y + (Theme.FieldH - star.PreferredHeight) / 2, star.PreferredWidth, star.PreferredHeight);
card.Controls.Add(star);
}
x += Math.Max(lbl.PreferredWidth, spec != null ? spec.MinWidth : 0) + Theme.Px(6);
lastLabel = label;
continue;
@@ -922,6 +958,7 @@ namespace TfsExcelImporter
_cardData.SetSummary(string.Join(" · ", new[]
{
string.IsNullOrEmpty(iter) ? "迭代未选" : iter,
string.IsNullOrWhiteSpace(_cmbArea.Text) ? "Area 未填" : "Area " + _cmbArea.Text.Trim(),
string.IsNullOrEmpty(fileName) ? "未选 Excel" : fileName,
_rows.Count > 0 ? _rows.Count + " 行" : "未预览",
string.IsNullOrWhiteSpace(_txtParentPbi.Text) ? "父 PBI 未填" : "父 PBI #" + _txtParentPbi.Text.Trim()
@@ -929,9 +966,6 @@ namespace TfsExcelImporter
_cardPreview.SetSummary(_rows.Count > 0
? "共 " + _rows.Count + " 行,可直接开始导入"
: "选择 Excel 后在这里核对将要写入的内容");
_cardAdv.SetSummary(string.IsNullOrWhiteSpace(_txtAreaPath.Text)
? "Area 未设置"
: "Area " + _txtAreaPath.Text.Trim());
}
/// <summary>第 4 步徽标:待办 / 进行中 / 完成 / 有失败。</summary>
@@ -992,6 +1026,7 @@ namespace TfsExcelImporter
_btnPreview.Enabled = !busy;
_btnTest.Enabled = !busy;
_btnLoadIterations.Enabled = !busy;
_btnLoadAreas.Enabled = !busy;
_btnFixArea.Enabled = !busy;
_btnLinkParent.Enabled = !busy;
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
+32 -12
View File
@@ -88,10 +88,30 @@ namespace TfsExcelImporter
return null;
}
// ------------------------------------------------------------- 迭代
// ------------------------------------------------------------- 迭代 / Area
/// <summary>取项目下全部迭代完整路径(Tools_Dev\2026年Sprint\2026年Sprint10 形式),按路径排序。</summary>
public async Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
public Task<List<string>> GetIterationPathsAsync(string project, Action<string> log = null)
{
return GetClassificationPathsAsync(project, "ProjectLifecycle", "Iteration", "迭代", log);
}
/// <summary>
/// 取项目下全部 Area 完整路径(Tools_Dev\DataTechnologies 形式),按路径排序。
/// Area 与迭代同属分类结构(ICommonStructureService),只是根节点不同:
/// TFS 2013 里 Area 的 StructureType 固定为 ProjectModelHierarchy,迭代是 ProjectLifecycle。
/// </summary>
public Task<List<string>> GetAreaPathsAsync(string project, Action<string> log = null)
{
return GetClassificationPathsAsync(project, "ProjectModelHierarchy", "Area", "Area", log);
}
/// <summary>
/// 通用分类树抓取:按 StructureType 挑根节点(拿不到就用名字模糊匹配兜底),
/// GetNodesXml 深度展开整棵树,再取每个节点的 Path 属性。迭代与 Area 共用这一条通道。
/// </summary>
private async Task<List<string>> GetClassificationPathsAsync(string project, string structureType,
string nameHint, string label, Action<string> log)
{
var list = await Task.Run(() =>
{
@@ -101,13 +121,13 @@ namespace TfsExcelImporter
throw new InvalidOperationException("项目不存在或无权访问: " + project);
var structures = css.ListStructures(proj.Uri);
var iterations = structures.FirstOrDefault(n => n.StructureType == "ProjectLifecycle")
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf("Iteration", StringComparison.OrdinalIgnoreCase) >= 0);
if (iterations == null)
throw new InvalidOperationException("未找到 Iterations(ProjectLifecycle) 结构");
var root = structures.FirstOrDefault(n => string.Equals(n.StructureType, structureType, StringComparison.OrdinalIgnoreCase))
?? structures.FirstOrDefault(n => (n.Name ?? "").IndexOf(nameHint, StringComparison.OrdinalIgnoreCase) >= 0);
if (root == null)
throw new InvalidOperationException($"未找到 {label} 结构(StructureType={structureType})");
// GetNodesXml 深度展开全树;Node 元素的 Path 属性为分类节点路径
var tree = css.GetNodesXml(new[] { iterations.Uri }, true);
// 深度展开全树;Node 元素的 Path 属性为分类节点路径
var tree = css.GetNodesXml(new[] { root.Uri }, true);
var raw = new List<string>();
if (tree != null)
{
@@ -120,20 +140,20 @@ namespace TfsExcelImporter
return raw;
});
var result = list.Select(p => NormalizeIterationPath(p, project))
var result = list.Select(p => NormalizeNodePath(p, project))
.Where(p => p != null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
log?.Invoke($"迭代列表来源: TFS OM ICommonStructureService({result.Count} 个)");
log?.Invoke($"{label}列表来源: TFS OM ICommonStructureService({result.Count} 个)");
return result;
}
/// <summary>
/// 节点路径归一化为工作项 IterationPath(项目\子...)。
/// 分类节点路径归一化为工作项字段值(项目\子...),迭代与 Area 共用。
/// 兼容:含/不含项目名前缀、含/不含结构根段(Iteration/Iterations/Area/Areas)。
/// </summary>
private static string NormalizeIterationPath(string nodePath, string project)
private static string NormalizeNodePath(string nodePath, string project)
{
if (string.IsNullOrWhiteSpace(nodePath)) return null;
var segs = nodePath.Split('\\').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
+2 -2
View File
@@ -3,10 +3,10 @@
"Project": "Tools_Dev",
"WorkItemType": "Task",
"IterationPath": "",
"AreaPath": "Tools_Dev\\DataTechnologies",
"AreaPath": "",
"Username": "",
"Password": "",
"SkipExistingTitles": true,
"SkipExistingTitles": false,
"DefaultState": "To Do",
"FinancialProjectRef": "",
"CompletedRef": "",