feat: 添加系统 DPI 感知支持与界面优化

- 新增 app.manifest 文件以实现系统 DPI 感知,确保界面在不同缩放设置下正常显示。
- 更新 MainForm.cs 和 Theme.cs,调整控件布局和样式以适应 DPI 变化。
- 修改 README.md,增加关于 DPI 感知的说明。
- 新增 MainFormUi.cs,负责界面布局与状态呈现,优化用户体验。
- 新增 debug.log 文件以记录错误信息,便于调试和问题追踪。
This commit is contained in:
chenshoulin
2026-11-01 18:47:41 +08:00
parent c2d121d185
commit dbe8ff2c08
7 changed files with 2361 additions and 282 deletions
+208 -274
View File
@@ -9,239 +9,68 @@ using System.Windows.Forms;
namespace TfsExcelImporter
{
public class MainForm : Form
/// <summary>
/// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
/// </summary>
public partial class MainForm : Form
{
// ------------------------------------------------------------- 控件
private readonly TextBox _txtUrl = new TextBox();
private readonly TextBox _txtProject = new TextBox();
private readonly TextBox _txtType = new TextBox();
private readonly ComboBox _cmbAuth = new ComboBox();
private readonly TextBox _txtUser = new TextBox();
private readonly TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
private readonly TextBox _txtDomain = new TextBox();
private readonly CheckBox _chkProxy = new CheckBox();
private readonly ComboBox _cmbIteration = new ComboBox();
private readonly Button _btnLoadIterations = new Button();
private readonly TextBox _txtExcel = new TextBox();
private readonly Button _btnBrowse = new Button();
private readonly Button _btnTemplate = new Button();
private readonly CheckBox _chkSkip = new CheckBox();
private readonly CheckBox _chkBypass = new CheckBox();
private readonly TextBox _txtParentPbi = new TextBox();
private readonly DataGridView _grid = new DataGridView();
private readonly Button _btnPreview = new Button();
private readonly Button _btnImport = new Button();
private readonly Button _btnFixArea = new Button();
private readonly Button _btnLinkParent = new Button();
private readonly TextBox _txtLog = new TextBox();
private readonly Label _lblStatus = new Label();
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();
BuildUi();
LoadConfigToUi();
WireEvents();
RefreshRibbon();
RefreshSummaries();
SetConnection("连接未验证", ChipKind.Neutral);
UpdateEmptyState();
}
// ------------------------------------------------------------- UI 构建
private void BuildUi()
private void WireEvents()
{
Text = "TFS 2013 工作项 Excel 导入工具";
ClientSize = new Size(1060, 720);
MinimumSize = new Size(960, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9F);
int y = 12, left = 12, lblW = 84;
// --- 服务器配置区 ---
var grpServer = new GroupBox
{
Text = "TFS 连接",
Location = new Point(left, y),
Size = new Size(1036, 120),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
Controls.Add(grpServer);
AddLabel(grpServer, "服务器URL:", 12, 24, lblW);
_txtUrl.Location = new Point(100, 22); _txtUrl.Size = new Size(460, 27);
_txtUrl.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpServer.Controls.Add(_txtUrl);
_chkProxy.Text = "使用系统代理";
_chkProxy.Location = new Point(564, 24); _chkProxy.Size = new Size(110, 24);
_chkProxy.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_chkProxy.Checked = false; // 内网 TFS 默认直连
grpServer.Controls.Add(_chkProxy);
AddLabel(grpServer, "团队项目:", 680, 24, lblW);
_txtProject.Location = new Point(768, 22); _txtProject.Size = new Size(150, 27);
_txtProject.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpServer.Controls.Add(_txtProject);
AddLabel(grpServer, "工作项类型:", 12, 58, lblW);
_txtType.Location = new Point(100, 56); _txtType.Size = new Size(220, 27);
grpServer.Controls.Add(_txtType);
AddLabel(grpServer, "认证方式:", 340, 58, lblW);
_cmbAuth.Location = new Point(428, 56); _cmbAuth.Size = new Size(110, 27);
_cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" });
_cmbAuth.SelectedIndexChanged += (s, e) => UpdateAuthUi();
grpServer.Controls.Add(_cmbAuth);
AddLabel(grpServer, "用户:", 556, 58, 44);
_txtUser.Location = new Point(604, 56); _txtUser.Size = new Size(110, 27);
grpServer.Controls.Add(_txtUser);
AddLabel(grpServer, "密码:", 726, 58, 44);
_txtPwd.Location = new Point(774, 56); _txtPwd.Size = new Size(110, 27);
grpServer.Controls.Add(_txtPwd);
AddLabel(grpServer, "域:", 896, 58, 28);
_txtDomain.Location = new Point(928, 56); _txtDomain.Size = new Size(94, 27);
_txtDomain.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpServer.Controls.Add(_txtDomain);
y += 128;
// --- 导入选区 ---
var grpImport = new GroupBox
{
Text = "导入选区",
Location = new Point(left, y),
Size = new Size(1036, 120),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
Controls.Add(grpImport);
AddLabel(grpImport, "目标Iteration:", 12, 26, 96);
_cmbIteration.Location = new Point(112, 23); _cmbIteration.Size = new Size(560, 27);
_cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填
_cmbIteration.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpImport.Controls.Add(_cmbIteration);
_btnLoadIterations.Text = "加载迭代列表";
_btnLoadIterations.Location = new Point(682, 22); _btnLoadIterations.Size = new Size(110, 29);
_btnLoadIterations.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_cmbAuth.SelectedIndexChanged += (s, e) => { UpdateAuthUi(); OnFieldChanged(); };
_btnTest.Click += async (s, e) => await TestConnectionAsync();
_btnLoadIterations.Click += async (s, e) => await LoadIterationsAsync();
grpImport.Controls.Add(_btnLoadIterations);
AddLabel(grpImport, "Excel 文件:", 12, 62, 96);
_txtExcel.Location = new Point(112, 59); _txtExcel.Size = new Size(560, 27);
_txtExcel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
grpImport.Controls.Add(_txtExcel);
_btnBrowse.Text = "浏览...";
_btnBrowse.Location = new Point(682, 58); _btnBrowse.Size = new Size(70, 29);
_btnBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_btnBrowse.Click += (s, e) => BrowseExcel();
grpImport.Controls.Add(_btnBrowse);
_btnTemplate.Text = "导出Excel模板";
_btnTemplate.Location = new Point(760, 58); _btnTemplate.Size = new Size(110, 29);
_btnTemplate.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_btnTemplate.Click += (s, e) => ExportTemplate();
grpImport.Controls.Add(_btnTemplate);
_chkSkip.Text = "跳过已存在标题";
_chkSkip.Location = new Point(886, 26); _chkSkip.Size = new Size(130, 24);
_chkSkip.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpImport.Controls.Add(_chkSkip);
_chkBypass.Text = "bypassRules(绕过字段规则)";
_chkBypass.Location = new Point(886, 60); _chkBypass.Size = new Size(150, 24);
_chkBypass.Anchor = AnchorStyles.Top | AnchorStyles.Right;
grpImport.Controls.Add(_chkBypass);
// 父 PBI:全部 Task 默认挂接的 Product Backlog Item(Excel 每行 PBI Id 列可覆盖)
AddLabel(grpImport, "父PBI Id:", 12, 92, 96);
_txtParentPbi.Location = new Point(112, 89); _txtParentPbi.Size = new Size(200, 27);
grpImport.Controls.Add(_txtParentPbi);
var lblPbiHint = new Label
{
Text = "Task 将挂接为该 Product Backlog Item 的子项;Excel 加 PBI Id 列可逐行指定不同父项",
Location = new Point(320, 92), Size = new Size(560, 22),
TextAlign = ContentAlignment.MiddleLeft,
ForeColor = SystemColors.GrayText
};
grpImport.Controls.Add(lblPbiHint);
y += 128;
// --- 预览表格 ---
_grid.Location = new Point(left, y);
_grid.Size = new Size(1036, 300);
_grid.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
_grid.ReadOnly = true;
_grid.AllowUserToAddRows = false;
_grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
_grid.RowHeadersVisible = false;
_grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
Controls.Add(_grid);
y += 308;
// --- 操作按钮 ---
_btnPreview.Text = "预览数据";
_btnPreview.Location = new Point(left, y); _btnPreview.Size = new Size(110, 32);
_btnPreview.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnPreview.Click += (s, e) => PreviewData();
Controls.Add(_btnPreview);
_btnImport.Text = "开始导入 TFS";
_btnImport.Location = new Point(left + 122, y); _btnImport.Size = new Size(130, 32);
_btnImport.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnImport.BackColor = Color.FromArgb(0, 120, 215);
_btnImport.ForeColor = Color.White;
_btnImport.Click += async (s, e) => await ImportAsync();
Controls.Add(_btnImport);
_btnFixArea.Text = "按迭代修Area";
_btnFixArea.Location = new Point(left + 264, y); _btnFixArea.Size = new Size(130, 32);
_btnFixArea.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnFixArea.Click += async (s, e) => await FixAreaAsync();
Controls.Add(_btnFixArea);
_btnLinkParent.Text = "补挂父项";
_btnLinkParent.Location = new Point(left + 406, y); _btnLinkParent.Size = new Size(130, 32);
_btnLinkParent.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
_btnLinkParent.Click += async (s, e) => await LinkParentAsync();
Controls.Add(_btnLinkParent);
_lblStatus.Text = "就绪";
_lblStatus.Location = new Point(left + 548, y + 6); _lblStatus.Size = new Size(400, 22);
_lblStatus.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
Controls.Add(_lblStatus);
y += 40;
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();
// --- 日志 ---
_txtLog.Location = new Point(left, y);
_txtLog.Size = new Size(1036, 120);
_txtLog.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
_txtLog.Multiline = true;
_txtLog.ScrollBars = ScrollBars.Vertical;
_txtLog.ReadOnly = true;
Controls.Add(_txtLog);
FormClosing += (s, e) => { try { SaveUiToConfig(); } catch { } };
}
private static void AddLabel(Control parent, string text, int x, int y, int w)
private void OnFieldChanged()
{
var lbl = new Label { Text = text, Location = new Point(x, y), Size = new Size(w, 22), TextAlign = ContentAlignment.MiddleLeft };
parent.Controls.Add(lbl);
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;
}
// ------------------------------------------------------------- 配置互转
@@ -258,6 +87,7 @@ namespace TfsExcelImporter
_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;
@@ -276,6 +106,7 @@ namespace TfsExcelImporter
_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();
@@ -285,57 +116,132 @@ namespace TfsExcelImporter
// ------------------------------------------------------------- 日志
private void Log(string msg)
{
if (InvokeRequired) { BeginInvoke(new Action(() => Log(msg))); return; }
_txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}");
Append(msg, LogView.Classify(msg));
}
// ------------------------------------------------------------- 迭代加载
private async Task LoadIterationsAsync()
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();
_btnLoadIterations.Enabled = false;
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} 个)");
Log($"工作项字段元数据可用({fm.Count} 个字段)");
}
catch (Exception ex)
{
Log("⚠ 字段接口(_apis/wit/fields)不可用,导入可能受影响: " + ex.Message.Split('\n')[0]);
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 && string.IsNullOrWhiteSpace(_cmbIteration.Text))
_cmbIteration.SelectedIndex = 0;
// 优先选中配置中的迭代
for (int i = 0; i < _cmbIteration.Items.Count; i++)
if (_cmbIteration.Items.Count > 0)
{
if (string.Equals(_cmbIteration.Items[i].ToString(), _cfg.IterationPath,
StringComparison.OrdinalIgnoreCase))
int match = -1;
for (int i = 0; i < _cmbIteration.Items.Count; i++)
{
_cmbIteration.SelectedIndex = i;
break;
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)
{
Log("加载迭代失败: " + ex.Message);
MessageBox.Show(this, "加载迭代失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
SetConnection("连接失败", ChipKind.Danger);
SetBadgeOf(_cardConn, BadgeKind.Warn);
ShowError(_errIteration, _fIteration, _cardData, "迭代列表加载失败,可手填完整路径");
Log("加载迭代失败: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
}
finally
{
_btnLoadIterations.Enabled = true;
SetBusy(false);
OnFieldChanged();
}
}
@@ -369,14 +275,14 @@ namespace TfsExcelImporter
{
ExcelService.CopyTemplate(dlg.FileName);
Log("模板已导出: " + dlg.FileName);
MessageBox.Show(this, "模板已导出:\n" + dlg.FileName, "完成",
MessageBoxButtons.OK, MessageBoxIcon.Information);
_txtExcel.Text = dlg.FileName;
SaveUiToConfig();
PreviewData();
}
catch (Exception ex)
{
Log("导出模板失败: " + ex.Message);
MessageBox.Show(this, "导出模板失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
Log("导出模板失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "导出模板失败:" + FirstLine(ex.Message));
}
}
}
@@ -386,7 +292,8 @@ namespace TfsExcelImporter
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("预览未执行:Excel 文件不存在", LogLevel.Warn);
return;
}
try
@@ -396,23 +303,31 @@ namespace TfsExcelImporter
_grid.DataSource = _rows.Select((r, i) => new
{
行号 = i + 1,
r.Title,
r.State,
标题 = r.Title,
状态 = r.State,
指派 = r.AssignedTo,
财务项目 = r.FinancialProject,
r.Completed,
LogDate = r.LogDate,
r.PbiId,
r.Description
完成量 = 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 失败: " + ex.Message);
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
Log("读取 Excel 失败: " + FirstLine(ex.Message));
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
UpdateEmptyState();
}
}
@@ -422,13 +337,15 @@ namespace TfsExcelImporter
SaveUiToConfig();
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errIteration, _fIteration, _cardData, "修 Area 需要先选择目标 Iteration");
Append("按迭代修 Area 取消:目标迭代未选", LogLevel.Warn);
return;
}
if (string.IsNullOrWhiteSpace(_cfg.AreaPath))
{
MessageBox.Show(this, "请先在 config.json 中设置 AreaPath(如 Tools_Dev\\DataTechnologies)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errArea, _fArea, _cardAdv, "请填写 Area 路径,例如 Tools_Dev\\DataTechnologies");
OpenDetail(_cardAdv);
_txtAreaPath.Focus();
return;
}
var confirm = MessageBox.Show(this,
@@ -436,7 +353,8 @@ namespace TfsExcelImporter
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
_btnFixArea.Enabled = false;
SetBusy(true);
OpenDetail(_cardLog);
try
{
using (var client = new TfsClient(_cfg))
@@ -447,19 +365,16 @@ namespace TfsExcelImporter
: $"Area 修正完成,共 {r.Item1} 条";
_lblStatus.Text = summary;
Log(summary);
MessageBox.Show(this, summary, r.Item2 > 0 ? "部分完成" : "完成",
MessageBoxButtons.OK, r.Item2 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
ShowError(_errArea, _fArea, _cardAdv, "");
}
}
catch (Exception ex)
{
Log("Area 修正失败: " + ex.Message);
MessageBox.Show(this, "Area 修正失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
Log("Area 修正失败: " + FirstLine(ex.Message));
}
finally
{
_btnFixArea.Enabled = true;
SetBusy(false);
}
}
@@ -467,15 +382,17 @@ namespace TfsExcelImporter
private async Task LinkParentAsync()
{
SaveUiToConfig();
int parentId;
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errIteration, _fIteration, _cardData, "补挂父项需要先选择目标 Iteration");
Append("补挂父项取消:目标迭代未选", LogLevel.Warn);
return;
}
if (!int.TryParse(_cfg.ParentPbiId, out var parentId))
if (!int.TryParse(_cfg.ParentPbiId, out parentId))
{
MessageBox.Show(this, "请先在「父PBI Id」输入框填入父工作项 Id(纯数字)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errParent, _fParent, _cardData, "父 PBI Id 需为纯数字,例如 219975");
_txtParentPbi.Focus();
return;
}
var confirm = MessageBox.Show(this,
@@ -483,7 +400,8 @@ namespace TfsExcelImporter
"确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (confirm != DialogResult.Yes) return;
_btnLinkParent.Enabled = false;
SetBusy(true);
OpenDetail(_cardLog);
try
{
using (var client = new TfsClient(_cfg))
@@ -493,20 +411,17 @@ namespace TfsExcelImporter
var summary = $"补挂完成: 挂接 {r.Item1} / 已有父跳过 {r.Item2} / 失败 {r.Item3}";
_lblStatus.Text = summary;
Log(summary);
MessageBox.Show(this, summary + "\n挂接后在积压工作页展开父项即可看到子项。",
r.Item3 > 0 ? "部分完成" : "完成", MessageBoxButtons.OK,
r.Item3 > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
Log("挂接后在积压工作页展开父项即可看到子项。");
ShowError(_errParent, _fParent, _cardData, "");
}
}
catch (Exception ex)
{
Log("补挂父项失败: " + ex.Message);
MessageBox.Show(this, "补挂父项失败:\n" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
Log("补挂父项失败: " + FirstLine(ex.Message));
}
finally
{
_btnLinkParent.Enabled = true;
SetBusy(false);
}
}
@@ -517,14 +432,15 @@ namespace TfsExcelImporter
if (string.IsNullOrWhiteSpace(_cfg.IterationPath))
{
MessageBox.Show(this, "请先选择目标 Iteration(可点击“加载迭代列表”)", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errIteration, _fIteration, _cardData, "请先选择目标 Iteration(可点「加载迭代列表」,也可手填完整路径)");
Append("导入取消:目标迭代未选", LogLevel.Warn);
return;
}
var path = _txtExcel.Text.Trim();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
MessageBox.Show(this, "请先选择有效的 Excel 文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errExcel, _fExcel, _cardData, "请选择有效的 Excel 文件(.xlsx)");
Append("导入取消:Excel 文件不存在", LogLevel.Warn);
return;
}
if (_rows.Count == 0)
@@ -532,18 +448,25 @@ namespace TfsExcelImporter
try { _rows = ExcelService.ReadRows(path); }
catch (Exception ex)
{
MessageBox.Show(this, "读取 Excel 失败:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
ShowError(_errExcel, _fExcel, _cardData, "读取失败:" + FirstLine(ex.Message));
Log("读取 Excel 失败: " + FirstLine(ex.Message));
return;
}
}
if (_rows.Count == 0)
{
MessageBox.Show(this, "Excel 中没有可导入的数据行", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowError(_errExcel, _fExcel, _cardData, "Excel 中没有可导入的数据行");
Append("导入取消:没有可导入的数据行", LogLevel.Warn);
return;
}
_btnImport.Enabled = false;
_btnPreview.Enabled = false;
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,错误");
@@ -554,6 +477,8 @@ namespace TfsExcelImporter
{
Log("测试连接...");
Log(await client.TestConnectionAsync());
SetConnection("已连接", ChipKind.Ok);
SetBadgeOf(_cardConn, BadgeKind.Done);
// 字段解析
var fieldMap = await client.GetFieldMapAsync();
@@ -636,31 +561,40 @@ namespace TfsExcelImporter
report.AppendLine($"{line},\"{row.Title}\",FAIL,,\"{ex.Message.Replace("\"", "'")}\"");
Log($"[{i + 1}/{_rows.Count}] FAIL {row.Title} -> {ex.Message}");
}
_lblStatus.Text = $"导入中 {i + 1}/{_rows.Count}...";
_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)
{
Log("导入中断: " + ex.Message);
MessageBox.Show(this, "导入中断:\n" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
SetConnection("连接失败", ChipKind.Danger);
SetRunBadge(BadgeKind.Warn);
Log("导入中断: " + FirstLine(ex.Message));
Log("排查: 内网请确认「使用系统代理」保持关闭;DNS 失败多为 VPN 或未连内网。");
}
finally
{
_btnImport.Enabled = true;
_btnPreview.Enabled = true;
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);
Log($"报告已保存: {reportPath}");
_lastReportPath = reportPath;
Log("报告已保存: " + reportPath);
}
catch (Exception ex) { Log("写报告失败: " + ex.Message); }
_lblStatus.Text = $"完成: 成功 {ok} / 跳过 {skip} / 失败 {fail}";
Log($"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}");
MessageBox.Show(this, $"导入完成。\n成功 {ok} / 跳过 {skip} / 失败 {fail}\n详见日志与报告 CSV。",
"完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
catch (Exception ex) { Log("写报告失败: " + FirstLine(ex.Message)); }
_pbar.Value = _pbar.Maximum;
_lblStatus.Text = $"完成 {ok + skip + fail}/{_rows.Count}";
var summary = $"汇总: 成功 {ok} / 跳过 {skip} / 失败 {fail}";
_lblStatus.Text = summary;
Log(summary);
SetChips(ok, skip, fail, false);
RefreshSummaries();
if (fail == 0 && ok > 0) OpenDetail(_cardLog);
}
}
}