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 { /// /// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。 /// public partial class MainForm : Form { private AppConfig _cfg; private List _rows = new List(); 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(); _btnLoadPbi.Click += async (s, e) => await LoadBacklogAsync(); _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 }) tb.TextChanged += (s, e) => OnFieldChanged(); _cmbIteration.TextChanged += (s, e) => OnFieldChanged(); _cmbArea.TextChanged += (s, e) => OnFieldChanged(); _cmbParent.TextChanged += (s, e) => OnFieldChanged(); // 从列表里挑的是「#219975 标题 · 状态」这种展示文本,落库/导入得是纯 Id。 // 两个事件都挂:鼠标点选主要走 SelectionChangeCommitted(下拉关闭时), // 键盘上下选择/程序置位只有 SelectedIndexChanged,重复选同一项则只有前者。 _cmbParent.SelectedIndexChanged += (s, e) => CommitPbiSelection(); _cmbParent.SelectionChangeCommitted += (s, e) => CommitPbiSelection(); // 下拉收起时再兜一次:无论键盘还是鼠标选完,这里一定能拿到最终选中项 _cmbParent.DropDownClosed += (s, e) => CommitPbiSelection(); _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; _cmbParent.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 = NormalizePbiText(_cmbParent.Text); _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>> 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) { ReportTfsFailure(ex, what, field, err); } finally { SetBusy(false); OnFieldChanged(); } } // ------------------------------------------------------------- 父 PBI 列表 private async Task LoadBacklogAsync() { SaveUiToConfig(); if (string.IsNullOrWhiteSpace(_cfg.TfsUrl)) { Append("请先填写服务器 URL", LogLevel.Warn); OpenDetail(_cardConn); return; } SetBusy(true); SetConnection("读取 PBI 列表中…", ChipKind.Info); try { using (var client = new TfsClient(_cfg)) { Log("测试连接..."); Log(await client.TestConnectionAsync()); var items = await client.GetBacklogItemsAsync(_cfg.Project, _cfg.IterationPath, Log); // 下拉项是「#219975 标题 · 状态」,但字段本身只存纯 Id: // Items.Clear 会连带清空 Text,所以先存后回填,刷新列表不丢已填的 Id。 var keep = _cmbParent.Text; _cmbParent.Items.Clear(); foreach (var it in items) _cmbParent.Items.Add(it); _cmbParent.Text = keep; Log($"已加载 {items.Count} 条候选父项(从列表选中后会自动回填 Id)"); if (items.Count == 0) Log("提示: 没有可选父项,可直接手填 Id,或先确认项目/迭代是否正确"); SetConnection("已连接", ChipKind.Ok); SetBadgeOf(_cardConn, BadgeKind.Done); ShowError(_errParent, _fParent, _cardData, ""); } } catch (Exception ex) { ReportTfsFailure(ex, "父 PBI", _fParent, _errParent); } finally { SetBusy(false); OnFieldChanged(); } } /// /// 列表项是「#219975 标题 · State」这种展示文本,字段要的是纯数字 Id: /// 选中后立刻把 Text 归位成 Id,避免展示串被写进 System.Parent 或落进 config。 /// 用 SelectedIndexChanged 而不是 SelectionChangeCommitted——后者只在「下拉关闭」时才发, /// 键盘/程序触发的选中收不到;这里以「# 开头」判定是不是列表回填,手输纯 Id 不受影响。 /// 回填 Text 会让 SelectedIndex 变 -1 再触发一次,此时文本已是数字,直接 return 不会递归。 /// private void CommitPbiSelection() { int idx = _cmbParent.SelectedIndex; if (idx < 0 || idx >= _cmbParent.Items.Count) return; // 回填后 SelectedIndex 会变 -1,这条路用于收敛 var item = _cmbParent.Items[idx] as TfsClient.WorkItemBrief; if (item == null) return; var id = item.Id.ToString(); if (string.Equals(_cmbParent.Text, id, StringComparison.Ordinal)) return; _cmbParent.Text = id; _cmbParent.SelectionStart = id.Length; Log($"父 PBI 已选: #{item.Id} {item.Title}"); } /// /// 把「#219975 标题 · State」洗成纯 Id 再落库。 /// 选中事件负责即时回填,这里是最后一道保险:事件万一没触发(比如某些自绘交互), /// 写进 config / 传给 TFS 的仍是干净 Id,不会出现「挂不上父项却看不出原因」。 /// private static string NormalizePbiText(string text) { if (string.IsNullOrWhiteSpace(text)) return ""; text = text.Trim(); if (text[0] != '#') return text; int i = 1; while (i < text.Length && text[i] >= '0' && text[i] <= '9') i++; return i > 1 ? text.Substring(1, i - 1) : text; } /// /// 连接类失败的统一呈现:TF30063(服务器拒绝当前身份)与网络不通是两回事, /// 前者该去填账号密码,后者才查 VPN/代理,提示分开写才能真正指引到动作。 /// private void ReportTfsFailure(Exception ex, string what, InputField field, Label err) { var msg = FirstLine(ex.Message); 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 或未连内网。"); } } /// /// 填充路径下拉:先保留当前手填/已选内容,刷新列表不冲掉用户的选择。 /// Items.Clear 会把 DropDown 样式的 Text 一并清空,所以要在清空前取值、匹配后回填。 /// private static void FillPathCombo(ComboBox combo, List 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)); } } } /// /// 导出对话框起始目录:优先当前 Excel 所在目录,其次桌面。 /// 显式给目录,避免对话框落到"系统记住的上次目录"而出乎意料。 /// 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; } /// /// 覆盖确认。系统默认提示只问"是否替换",看不出目标文件其实是用户已有的数据表, /// 这里把路径、大小、修改时间和已有数据行数都摆出来,避免把导入数据当模板覆盖掉。 /// 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"; } /// /// 导出前确认模板来源。exe 同目录的 TFS导入模板.xlsx 一旦被现场替换、或被填过数据, /// 导出结果就不再是空白模板——用户会看到"导出的模板里带着别的数据"。 /// 这里把选择权交出来:内置空白模板 / 仍用该文件 / 不导出。 /// 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(); _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"); _cmbParent.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; } // 父 PBI 是可选字段,但一旦填了就必须是纯数字:非数字会在挂接时被静默当成「不挂父项」, // 导入"成功"却少了层级,比直接报错更难发现。 if (!string.IsNullOrWhiteSpace(_cfg.ParentPbiId) && !int.TryParse(_cfg.ParentPbiId, out _)) { ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,可点右侧「加载 PBI 列表」选择"); Append("导入取消:父 PBI Id 不是数字", LogLevel.Warn); _cmbParent.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 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> { new KeyValuePair(titleRef, row.Title), new KeyValuePair(stateRef, string.IsNullOrWhiteSpace(row.State) ? _cfg.DefaultState : row.State.Trim()), new KeyValuePair(iterationRef, _cfg.IterationPath), // Area 已在导入前校验为必填:每行都显式写入,不再依赖服务器默认区域 new KeyValuePair(areaRef, _cfg.AreaPath.Trim()) }; if (!string.IsNullOrWhiteSpace(row.AssignedTo)) fields.Add(new KeyValuePair(assignedRef, row.AssignedTo.Trim())); if (!string.IsNullOrWhiteSpace(row.FinancialProject) && finRef != null) fields.Add(new KeyValuePair(finRef, row.FinancialProject.Trim())); var completed = ExcelService.ToNumber(row.Completed); if (completed != null && compRef != null) fields.Add(new KeyValuePair(compRef, completed)); // Original Estimate 默认取 Completed 的值 if (completed != null && origEstRef != null) fields.Add(new KeyValuePair(origEstRef, completed)); if (!string.IsNullOrWhiteSpace(row.Description)) fields.Add(new KeyValuePair("System.Description", row.Description.Trim())); var logDate = ExcelService.ToIsoDateTime(row.LogDate); if (logDate != null && logRef != null) fields.Add(new KeyValuePair(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); } } } }