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() { _cmbAuth.SelectedIndexChanged += (s, e) => { UpdateAuthUi(); OnFieldChanged(); }; _btnTest.Click += async (s, e) => await TestConnectionAsync(); _btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync(); _btnBrowse.Click += (s, e) => BrowseExcel(); _btnTemplate.Click += (s, e) => ExportTemplate(); _btnPreview.Click += (s, e) => PreviewData(); _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, _txtDomain, _txtExcel, _txtParentPbi, _txtAreaPath }) tb.TextChanged += (s, e) => OnFieldChanged(); _cmbIteration.TextChanged += (s, e) => OnFieldChanged(); _chkSkip.CheckedChanged += (s, e) => OnFieldChanged(); _chkBypass.CheckedChanged += (s, e) => OnFieldChanged(); _chkProxy.CheckedChanged += (s, e) => OnFieldChanged(); FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } }; } private void OnFieldChanged() { RefreshRibbon(); RefreshSummaries(); } // ------------------------------------------------------------- 认证区联动 private void UpdateAuthUi() { bool needCreds = _cmbAuth.SelectedItem != null && _cmbAuth.SelectedItem.ToString() != "default"; _txtUser.Enabled = needCreds; _txtPwd.Enabled = needCreds; _txtDomain.Enabled = needCreds; _fUser.Invalid = false; _fPwd.Invalid = false; _fDomain.Invalid = false; } // ------------------------------------------------------------- 配置互转 private void LoadConfigToUi() { _txtUrl.Text = _cfg.TfsUrl; _txtProject.Text = _cfg.Project; _txtType.Text = _cfg.WorkItemType; _cmbAuth.SelectedItem = _cfg.AuthMode; if (_cmbAuth.SelectedItem == null) _cmbAuth.SelectedItem = "ntlm"; _txtUser.Text = _cfg.Username; _txtPwd.Text = _cfg.Password; _txtDomain.Text = _cfg.Domain; _chkProxy.Checked = _cfg.UseSystemProxy; _cmbIteration.Text = _cfg.IterationPath; _txtExcel.Text = _cfg.LastExcelPath; _txtAreaPath.Text = _cfg.AreaPath; _chkSkip.Checked = _cfg.SkipExistingTitles; _chkBypass.Checked = _cfg.BypassRules; _txtParentPbi.Text = _cfg.ParentPbiId; UpdateAuthUi(); } private void SaveUiToConfig() { _cfg.TfsUrl = _txtUrl.Text.Trim(); _cfg.Project = _txtProject.Text.Trim(); _cfg.WorkItemType = _txtType.Text.Trim(); _cfg.AuthMode = _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm"; _cfg.Username = _txtUser.Text.Trim(); _cfg.Password = _txtPwd.Text; _cfg.Domain = _txtDomain.Text.Trim(); _cfg.UseSystemProxy = _chkProxy.Checked; _cfg.IterationPath = _cmbIteration.Text.Trim(); _cfg.LastExcelPath = _txtExcel.Text.Trim(); _cfg.AreaPath = _txtAreaPath.Text.Trim(); _cfg.SkipExistingTitles = _chkSkip.Checked; _cfg.BypassRules = _chkBypass.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; } } // ------------------------------------------------------------- 迭代加载 private async Task LoadIterationsAsync() { SaveUiToConfig(); if (string.IsNullOrWhiteSpace(_cfg.TfsUrl)) { Append("请先填写服务器 URL", LogLevel.Warn); OpenDetail(_cardConn); return; } SetBusy(true); SetConnection("读取迭代中…", 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 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} 个迭代路径"); SetConnection("已连接", ChipKind.Ok); SetBadgeOf(_cardConn, BadgeKind.Done); ShowError(_errIteration, _fIteration, _cardData, ""); } } catch (Exception ex) { SetConnection("连接失败", ChipKind.Danger); SetBadgeOf(_cardConn, BadgeKind.Warn); ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径"); Log("加载迭代失败: " + FirstLine(ex.Message)); Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。"); } finally { SetBusy(false); OnFieldChanged(); } } // ------------------------------------------------------------- Excel private 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(); PreviewData(); } } private void ExportTemplate() { using (var dlg = new SaveFileDialog()) { dlg.Filter = "Excel 文件 (*.xlsx)|*.xlsx"; dlg.FileName = ExcelService.TemplateFileName; dlg.Title = "导出默认模板"; if (dlg.ShowDialog(this) != DialogResult.OK) return; try { ExcelService.CopyTemplate(dlg.FileName); Log("模板已导出: " + dlg.FileName); _txtExcel.Text = dlg.FileName; SaveUiToConfig(); PreviewData(); } catch (Exception ex) { Log("导出模板失败: " + FirstLine(ex.Message)); ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message)); } } } private void PreviewData() { var path = _txtExcel.Text.Trim(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)"); Append("预览未执行:Excel 文件不存在", LogLevel.Warn); return; } try { _rows = 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) { Log("读取 Excel 失败: " + FirstLine(ex.Message)); ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); UpdateEmptyState(); } } // ------------------------------------------------------------- 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, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies"); OpenDetail(_cardAdv); _txtAreaPath.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, _cardAdv, ""); } } 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; } 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) { try { _rows = ExcelService.ReadRows(path); } catch (Exception ex) { ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message)); Log("读取 Excel 失败: " + FirstLine(ex.Message)); return; } } if (_rows.Count == 0) { ShowError(_errExcel, _fExcel, _cardData, "Excel 中没有可导入的数据行"); Append("导入取消:没有可导入的数据行", LogLevel.Warn); return; } ShowError(_errIteration, _fIteration, _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($"父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) }; if (!string.IsNullOrWhiteSpace(row.AssignedTo)) fields.Add(new KeyValuePair(assignedRef, row.AssignedTo.Trim())); if (!string.IsNullOrWhiteSpace(_cfg.AreaPath)) fields.Add(new KeyValuePair(areaRef, _cfg.AreaPath.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, _chkBypass.Checked, 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); } } } }