feat: 增强数据导入功能与用户界面优化
- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。 - 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。 - 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。 - 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。 - 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
This commit is contained in:
@@ -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 自动解析
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
|
||||
@@ -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); // 「浏览」已并入输入框尾部,随忙等一起禁用
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user