- 更新 README.md,强调 Area 路径为必填项,并增加加载 Area 列表的功能,确保用户在导入时不会遗漏必要信息。 - 修改 Config.cs 和 config.json,调整 SkipExistingTitles 默认值为 false,以便用户首次导入时能看到真实冲突和报错。 - 在 MainForm.cs 中添加加载 Area 列表的按钮,优化用户交互体验。 - 更新 ExcelService.cs,增强模板导出逻辑,确保用户能够更方便地使用自定义模板。 - 调整界面布局,确保 Area 路径与迭代路径的选择更加直观,提升整体用户体验。
752 lines
35 KiB
C#
752 lines
35 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|
||
}
|