diff --git a/README.md b/README.md
index 52840df..71e177f 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,10 @@ TFSImport/
│ └── TFS导入模板.xlsx # 默认模板:csproj 随构建拷到 exe 目录,「导出Excel模板」原样复制它
└── TfsExcelImporter/ # C# WinForms 桌面版(主用)
├── TfsExcelImporter.csproj # net48 + ClosedXML + TFS ExtendedClient 14.*
- ├── MainForm.cs # 界面与导入/修 Area 流程
+ ├── MainForm.cs # 导入 / 修 Area / 补挂父项流程
+ ├── MainFormUi.cs # 界面外壳:步骤卡片布局、手风琴折叠、状态呈现
+ ├── Theme.cs # 设计 token(颜色/字号/间距/缩放)与自绘控件
+ ├── app.manifest # System DPI 感知,像素尺寸按缩放换算
├── TfsClient.cs # TFS Client OM 封装
├── ExcelService.cs # 读 Excel、导出默认模板、时间/数值归一
├── Config.cs # exe 同目录 config.json 读写
@@ -55,14 +58,15 @@ TfsExcelImporter\bin\Release\net48\TfsExcelImporter.exe
「导出Excel模板」复制的是 exe 同目录的 `TFS导入模板.xlsx`(构建时自动拷过去);单独分发 exe 时要带上这个文件,否则该按钮会报「未找到默认模板文件」。
-操作流程:
+界面按 4 步排:顶部状态条常驻显示服务器/项目/类型与连接状态,四张卡片一次只展开一张(展开一张会把其它收成一行摘要),预览表格永远占满剩余高度。
-1. 填「TFS 连接」区:URL、项目、工作项类型、认证方式与域账号;内网保持「使用系统代理」**不勾选**。
-2. 点「加载迭代列表」,选择目标迭代。
-3. 点「导出Excel模板」拿到默认模板,或「浏览...」选择已有 `.xlsx`;点「预览数据」核对。
-4. 需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项)。
-5. 点「开始导入 TFS」,日志区逐行输出 `OK/SKIP/FAIL` 与工作项 ID。
-6. 历史数据 Area 不对时用「按迭代修Area」批量纠正。
+1. **步骤 1 连接**:填 URL、项目、工作项类型、认证方式与域账号,点「测试连接」——右上状态灯变绿即连通;内网保持「使用系统代理」**不勾选**。
+2. **步骤 2 选数据**:点「加载迭代列表」选目标迭代(也可手填完整路径);点「导出 Excel 模板」拿默认模板,或「浏览...」选已有 `.xlsx`。需要挂父项时填「父 PBI Id」(默认全部挂同一个;Excel 里加 `PBI Id` 列可逐行指定不同父项)。
+3. **步骤 3 预览核对**:选完文件自动读表,表格里逐行核对;缺迭代/文件路径无效时红字直接标在对应输入框下方,不再弹框。
+4. **步骤 4 导入**:底部操作条点「开始导入 TFS」,进度条 + `n/m` + 成功/跳过/失败三个计数实时更新,日志抽屉逐行输出 `OK/SKIP/FAIL` 与工作项 ID;跑完点「打开报告 CSV」看逐行结果。
+5. 历史数据 Area 不对、或漏挂父项时,展开底部「高级工具」用「按迭代修 Area」「补挂父项」批量纠正;Area 路径也在这一栏里填(以前只能手改 config.json)。
+
+桌面版按 System DPI 感知实现,界面尺寸随 Windows 显示缩放换算;**改了显示缩放比例要重启程序**才生效。
### Python 版
diff --git a/TfsExcelImporter/MainForm.cs b/TfsExcelImporter/MainForm.cs
index 4767823..ee72fff 100644
--- a/TfsExcelImporter/MainForm.cs
+++ b/TfsExcelImporter/MainForm.cs
@@ -9,239 +9,68 @@ using System.Windows.Forms;
namespace TfsExcelImporter
{
- public class MainForm : Form
+ ///
+ /// 导入流程与 TFS/Excel 调用。界面摆放与状态呈现见 MainFormUi.cs,自绘控件见 Theme.cs。
+ ///
+ 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 _rows = new List();
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);
}
}
}
diff --git a/TfsExcelImporter/MainFormUi.cs b/TfsExcelImporter/MainFormUi.cs
new file mode 100644
index 0000000..a7bb14b
--- /dev/null
+++ b/TfsExcelImporter/MainFormUi.cs
@@ -0,0 +1,889 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace TfsExcelImporter
+{
+ ///
+ /// 界面外壳:ribbon 状态条 + 步骤卡片(1 连接 / 2 选数据 / 3 预览)+ 操作条 + 日志抽屉 + 高级工具抽屉。
+ /// 手风琴规则——同时只展开一个细节区,预览表格永远拿得到剩余高度。
+ /// 只负责摆放控件与状态呈现,TFS/Excel 调用逻辑仍在 MainForm.cs。
+ /// 所有字面量都是 96dpi 设计值,经 Theme.Px() 按系统缩放换算。
+ ///
+ public partial class MainForm
+ {
+ // ------------------------------------------------------------- 控件
+ private TextBox _txtUrl = new TextBox();
+ private TextBox _txtProject = new TextBox();
+ private TextBox _txtType = new TextBox();
+ private ComboBox _cmbAuth = new ComboBox();
+ private TextBox _txtUser = new TextBox();
+ private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true };
+ private TextBox _txtDomain = new TextBox();
+ private CheckBox _chkProxy = new CheckBox();
+
+ private ComboBox _cmbIteration = new ComboBox();
+ private TextBox _txtExcel = new TextBox();
+ private TextBox _txtParentPbi = new TextBox();
+ private TextBox _txtAreaPath = new TextBox();
+ private Button _btnLoadIterations = new Button();
+ private Button _btnBrowse = new Button();
+ private Button _btnTemplate = new Button();
+ private CheckBox _chkSkip = new CheckBox();
+ private CheckBox _chkBypass = new CheckBox();
+
+ private DataGridView _grid = new DataGridView();
+ private Button _btnPreview = new Button();
+ private Button _btnImport = new Button();
+ private Button _btnFixArea = new Button();
+ private Button _btnLinkParent = new Button();
+ private Button _btnTest = new Button();
+ private Button _btnClearLog = new Button();
+ private Button _btnOpenReport = new Button();
+ private CheckBox _chkAutoScroll = new CheckBox();
+
+ private LogView _log = new LogView();
+ private Label _lblStatus = new Label();
+ private ProgressTrack _pbar = new ProgressTrack();
+
+ // 容器与状态呈现
+ private Surface _ribbon;
+ private Card _cardConn, _cardData, _cardPreview, _cardLog, _cardAdv;
+ private Surface _actionBar;
+ private StepBadge _badgeRun;
+ private InputField _fUrl, _fProject, _fType, _fAuth, _fUser, _fPwd, _fDomain,
+ _fIteration, _fExcel, _fParent, _fArea;
+ private Label _errIteration, _errExcel, _errParent, _errArea;
+ private EmptyState _lblEmpty;
+ private StatusChip _chipConn, _chipOk, _chipSkip, _chipFail;
+
+ private readonly List _stretches = new List();
+ private readonly List _rightAnchors = new List();
+ private readonly List _layoutExtras = new List();
+ private BadgeKind _runBadge = BadgeKind.Pending;
+
+ // 页边距与区块间距:96dpi 设计值,取值时按缩放换算
+ private static int M { get { return Theme.Px(16); } }
+ private static int G { get { return Theme.Px(8); } }
+ private static int LogBodyH { get { return Theme.Px(150); } }
+ private string _lastReportPath = "";
+
+ /// 需要在窗口拉宽时跟着变宽的控件。
+ private class Stretch
+ {
+ public Control C;
+ public Card Owner;
+ public int X, Y, Right, Bottom;
+ public bool FillHeight;
+ }
+
+ /// 贴在卡片右缘的按钮:字段变宽后必须跟着右移,否则会压在字段上。
+ private class RightAnchor
+ {
+ public Control C;
+ public Card Owner;
+ public int FromRight;
+ public int Y;
+ }
+
+ // ------------------------------------------------------------- 构建
+ private void BuildUi()
+ {
+ Text = "TFS 2013 工作项 Excel 导入工具";
+ AutoScaleMode = AutoScaleMode.None; // 手工布局,缩放全部走 Theme.Px()
+ ClientSize = new Size(Theme.Px(1088), Theme.Px(800));
+ MinimumSize = new Size(Theme.Px(1000), Theme.Px(720));
+ StartPosition = FormStartPosition.CenterScreen;
+ Font = Theme.Body;
+ BackColor = Theme.Surface;
+ DoubleBuffered = true;
+
+ BuildRibbon();
+ BuildCardConn();
+ BuildCardData();
+ BuildCardPreview();
+ BuildActionBar();
+ BuildCardLog();
+ BuildCardAdv();
+
+ Controls.Add(_ribbon);
+ Controls.Add(_cardConn);
+ Controls.Add(_cardData);
+ Controls.Add(_cardPreview);
+ Controls.Add(_actionBar);
+ Controls.Add(_cardLog);
+ Controls.Add(_cardAdv);
+
+ Resize += (s, e) => Relayout();
+ _uiReady = true;
+ Relayout();
+
+ // 卡片 1 默认展开:首次打开就告诉用户从哪儿开始
+ _cardConn.Expanded = true;
+ _cardData.Expanded = false;
+ _cardLog.Expanded = false;
+ _cardAdv.Expanded = false;
+ foreach (var c in DetailCards()) c.ApplyHeight();
+ Relayout();
+
+ AcceptButton = _btnTest;
+ }
+
+ private IEnumerable DetailCards()
+ {
+ yield return _cardConn;
+ yield return _cardData;
+ yield return _cardLog;
+ yield return _cardAdv;
+ }
+
+ private Card MakeCard(int step, string title, bool collapsible, int headerH)
+ {
+ var card = new Card
+ {
+ StepNumber = step,
+ Title = title,
+ Collapsible = collapsible,
+ ShowBadge = step > 0,
+ HeaderH = headerH,
+ Expanded = false,
+ TabStop = false
+ };
+ card.OnToggle = expanded =>
+ {
+ if (expanded)
+ {
+ foreach (var other in DetailCards())
+ if (!ReferenceEquals(other, card)) { other.Expanded = false; other.ApplyHeight(); }
+ }
+ card.ApplyHeight();
+ Relayout();
+ };
+ return card;
+ }
+
+ // --- 顶部状态条 ---
+ private class RibGroup { public Label Cap; public Label Val; public Panel Sep; }
+ private readonly List _ribItems = new List();
+
+ private void BuildRibbon()
+ {
+ _ribbon = new Surface
+ {
+ LineColor = Theme.PrimarySoftBorder,
+ FaceColor = Theme.PrimarySoft,
+ Height = Theme.Px(44)
+ };
+ AddRibbonGroup("服务器", true);
+ AddRibbonGroup("项目", true);
+ AddRibbonGroup("工作项", false);
+
+ _chipConn = new StatusChip { BackColor = Theme.PrimarySoft };
+ _ribbon.Controls.Add(_chipConn);
+ }
+
+ /// 一组「标题 + 值」;值文本会变长,所以位置每次重排时按实测宽度算。
+ private void AddRibbonGroup(string caption, bool withSeparator)
+ {
+ var cap = new Label
+ {
+ Text = caption,
+ Font = Theme.Caption,
+ ForeColor = Theme.Ink2,
+ AutoSize = true,
+ BackColor = Theme.PrimarySoft
+ };
+ var val = new Label
+ {
+ Text = "",
+ Font = Theme.BodyBold,
+ ForeColor = Theme.Ink,
+ AutoSize = true,
+ BackColor = Theme.PrimarySoft
+ };
+ _ribbon.Controls.Add(cap);
+ _ribbon.Controls.Add(val);
+ Panel sep = null;
+ if (withSeparator)
+ {
+ sep = new Panel { BackColor = Color.FromArgb(0xC2, 0xD3, 0xEF) };
+ _ribbon.Controls.Add(sep);
+ }
+ _ribItems.Add(new RibGroup { Cap = cap, Val = val, Sep = sep });
+ }
+
+ private void LayoutRibbon()
+ {
+ int x = M + Theme.Px(6);
+ foreach (var group in _ribItems)
+ {
+ var cap = group.Cap;
+ var val = group.Val;
+ var sep = group.Sep;
+ cap.SetBounds(x, (_ribbon.Height - cap.PreferredHeight) / 2, cap.PreferredWidth, cap.PreferredHeight);
+ x += cap.Width + Theme.Px(6);
+ val.SetBounds(x, (_ribbon.Height - val.PreferredHeight) / 2, val.PreferredWidth, val.PreferredHeight);
+ x += val.Width + Theme.Px(12);
+ if (sep == null) continue;
+ sep.SetBounds(x - Theme.Px(8), (_ribbon.Height - Theme.Px(16)) / 2, 1, Theme.Px(16));
+ x += Theme.Px(5);
+ }
+ _chipConn.SetBounds(_ribbon.Width - _chipConn.Width - Theme.Px(12),
+ (_ribbon.Height - _chipConn.Height) / 2, _chipConn.Width, _chipConn.Height);
+ }
+
+ // --- 步骤 1:连接 ---
+ private void BuildCardConn()
+ {
+ _cardConn = MakeCard(1, "连接 TFS 服务器", true, Theme.Px(44));
+ _cmbAuth.DropDownStyle = ComboBoxStyle.DropDownList;
+ _cmbAuth.Items.AddRange(new object[] { "ntlm", "basic", "default" });
+
+ _fUrl = NewField(_txtUrl, 620, true);
+ _fProject = NewField(_txtProject, 150, false);
+ _fType = NewField(_txtType, 180, false);
+ _fAuth = NewField(_cmbAuth, 170, false);
+ _fDomain = NewField(_txtDomain, 150, false);
+ _fUser = NewField(_txtUser, 180, false);
+ _fPwd = NewField(_txtPwd, 200, true);
+
+ _btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130);
+ _chkProxy = NewCheck("使用系统代理", "内网 TFS 请保持关闭,勾选后常被代理拦成 502");
+
+ int y = _cardConn.HeaderH + Theme.S12;
+ // 三行表单共用一条字段左缘:标签列宽取各行最大值,同列字段宽度归一
+ int w1 = MaxLabelWidth("服务器 URL", "团队项目", "域");
+ int w2 = MaxLabelWidth("工作项类型", "用户");
+ int w3 = MaxLabelWidth("认证方式", "密码");
+ y = Row(_cardConn, y, 42, new Lbl("服务器 URL", w1), _fUrl);
+ Track(_fUrl, _cardConn, M, 0);
+ y = Row(_cardConn, y, 42, new Lbl("团队项目", w1), _fProject,
+ new Lbl("工作项类型", w2), _fType, new Lbl("认证方式", w3), _fAuth);
+ y = Row(_cardConn, y, 42, new Lbl("域", w1), _fDomain,
+ new Lbl("用户", w2), _fUser, new Lbl("密码", w3), _fPwd, G, _btnTest);
+ y = Row(_cardConn, y, 26, _chkProxy);
+ _cardConn.BodyH = y - _cardConn.HeaderH + Theme.S8;
+ }
+
+ // --- 步骤 2:迭代与数据源 ---
+ private void BuildCardData()
+ {
+ _cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44));
+ _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径
+
+ _fIteration = NewField(_cmbIteration, 500, false);
+ _fExcel = NewField(_txtExcel, 500, true);
+ _fParent = NewField(_txtParentPbi, 150, true);
+
+ _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Sync, 130);
+ _btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92);
+ _btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
+ _chkSkip = NewCheck("跳过已存在标题", "");
+ _chkBypass = NewCheck("bypassRules(绕过字段校验规则)", "");
+
+ // 三行字段对齐同一条左缘:标签列宽取最大值
+ int wLbl = MaxLabelWidth("目标迭代", "Excel 文件", "父 PBI Id");
+ int y = _cardData.HeaderH + Theme.S12;
+ y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl), _fIteration);
+ AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top);
+ Track(_fIteration, _cardData, _btnLoadIterations.Width + G + M, 0);
+ y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl), _fExcel);
+ AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top);
+ AnchorRight(_btnBrowse, _cardData, _btnTemplate.Width + G + M, _fExcel.Top);
+ Track(_fExcel, _cardData, _btnTemplate.Width + _btnBrowse.Width + G * 2 + M, 0);
+ y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent, "Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖");
+ y = Row(_cardData, y, 24, _chkSkip, 2 * G, _chkBypass);
+
+ _errIteration = AddErrorUnder(_cardData, _fIteration);
+ _errExcel = AddErrorUnder(_cardData, _fExcel);
+ _errParent = AddErrorUnder(_cardData, _fParent);
+ _cardData.BodyH = y - _cardData.HeaderH + Theme.S8;
+ }
+
+ /// 错误提示紧贴对应输入框下方;宽度随卡片重排(构建时卡片还没定宽)。
+ private Label AddErrorUnder(Card card, InputField field)
+ {
+ var err = NewError();
+ err.Height = Theme.Px(20);
+ card.Controls.Add(err);
+ TrackPlaced(err, card, field.Left, field.Bottom + Theme.Px(1), M, Theme.Px(18));
+ return err;
+ }
+
+ // --- 步骤 3:预览 ---
+ private void BuildCardPreview()
+ {
+ _cardPreview = MakeCard(3, "预览核对", false, Theme.Px(44));
+ _cardPreview.Expanded = true;
+ _cardPreview.BodyH = Theme.Px(300);
+ GridStyle.Apply(_grid);
+ _grid.AccessibleName = "Excel 预览数据";
+ _cardPreview.Controls.Add(_grid);
+
+ _lblEmpty = new EmptyState
+ {
+ Text = "还没有数据。到上面第 2 步选一个 .xlsx,或点「导出 Excel 模板」拿默认模板。",
+ Visible = false
+ };
+ _cardPreview.Controls.Add(_lblEmpty);
+ int gy = _cardPreview.HeaderH + Theme.S8;
+ TrackPlaced(_grid, _cardPreview, M, gy, M, Theme.S12);
+ TrackPlaced(_lblEmpty, _cardPreview, M, gy, M, Theme.S12);
+ _stretches[_stretches.Count - 1].FillHeight = true;
+ _stretches[_stretches.Count - 2].FillHeight = true;
+ }
+
+ // --- 步骤 4:操作条 ---
+ private void BuildActionBar()
+ {
+ _actionBar = new Surface { Height = Theme.Px(62) };
+ // 透明背景会让父容器改画自己的 BackColor(灰),这里直接给成操作条的白
+ _badgeRun = new StepBadge { BackColor = Theme.Card };
+ _btnPreview = NewButton("预览数据", BtnKind.Secondary, IconKind.Table, 120);
+ _btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 176);
+ // 主/次按钮同高(层级靠主色表达,不再用 38px 拔高),避免操作条里两个按钮大小不齐
+ _pbar = new ProgressTrack
+ {
+ Minimum = 0,
+ Maximum = 100,
+ Value = 0,
+ Height = Theme.Px(8)
+ };
+ _chipOk = new StatusChip { BackColor = Theme.Card };
+ _chipSkip = new StatusChip { BackColor = Theme.Card };
+ _chipFail = new StatusChip { BackColor = Theme.Card };
+ _lblStatus = new Label
+ {
+ Text = "就绪",
+ Font = Theme.Caption,
+ ForeColor = Theme.Ink2,
+ TextAlign = ContentAlignment.MiddleRight,
+ AutoSize = false,
+ AutoEllipsis = true
+ };
+
+ _actionBar.Controls.Add(_badgeRun);
+ _actionBar.Controls.Add(_btnPreview);
+ _actionBar.Controls.Add(_btnImport);
+ _actionBar.Controls.Add(_pbar);
+ _actionBar.Controls.Add(_chipOk);
+ _actionBar.Controls.Add(_chipSkip);
+ _actionBar.Controls.Add(_chipFail);
+ _actionBar.Controls.Add(_lblStatus);
+
+ _layoutExtras.Add(LayoutActionBar);
+ SetChips(0, 0, 0, true);
+ }
+
+ private void LayoutActionBar()
+ {
+ int badge = Theme.Px(22);
+ int cy = (_actionBar.Height - badge) / 2;
+ _badgeRun.SetBounds(M + Theme.Px(2), cy, badge, badge);
+ _badgeRun.Set(4, _runBadge);
+ _btnPreview.SetBounds(M + Theme.Px(42), (_actionBar.Height - _btnPreview.Height) / 2,
+ _btnPreview.Width, _btnPreview.Height);
+ _btnImport.SetBounds(_btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2,
+ _btnImport.Width, _btnImport.Height);
+
+ _chipFail.SetBounds(_actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height);
+ _chipSkip.SetBounds(_chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height);
+ _chipOk.SetBounds(_chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height);
+ int statW = Theme.Px(160);
+ _lblStatus.SetBounds(_chipOk.Left - statW - G, 0, statW, _actionBar.Height);
+ int left = _btnImport.Right + Theme.S16;
+ int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
+ _pbar.SetBounds(left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
+ }
+
+ // --- 日志抽屉 ---
+ private void BuildCardLog()
+ {
+ _cardLog = MakeCard(0, "导入日志", true, Theme.Px(36));
+ _cardLog.ShowBadge = false;
+ _cardLog.HeaderReserve = Theme.Px(300); // 头部右侧留给 自动滚动/清空/打开报告
+ _cardLog.BodyH = LogBodyH;
+ _cardLog.SetSummary("尚未运行");
+ _log.AutoScrollToBottom = true;
+ _cardLog.Controls.Add(_log);
+ TrackPlaced(_log, _cardLog, M, _cardLog.HeaderH + Theme.Px(6), M, Theme.Px(6)).FillHeight = true;
+
+ _chkAutoScroll = new CheckBox
+ {
+ Text = "自动滚动",
+ Font = Theme.Caption,
+ ForeColor = Theme.Ink2,
+ AutoSize = false, // AutoSize 会按内容改高度,布局里就没办法按中线精确居中了
+ BackColor = Theme.Card,
+ Checked = true
+ };
+ _chkAutoScroll.CheckedChanged += (s, e) => _log.AutoScrollToBottom = _chkAutoScroll.Checked;
+ _btnClearLog = NewButton("清空", BtnKind.Ghost, IconKind.Trash, 62);
+ _btnClearLog.Height = Theme.Px(26);
+ _btnClearLog.Click += (s, e) => { _log.ClearLog(); Append("日志已清空", LogLevel.Info); };
+ _btnOpenReport = NewButton("打开报告 CSV", BtnKind.Ghost, IconKind.Doc, 124);
+ _btnOpenReport.Height = Theme.Px(26);
+ _btnOpenReport.Click += (s, e) => OpenReport();
+
+ _cardLog.Controls.Add(_btnOpenReport);
+ _cardLog.Controls.Add(_btnClearLog);
+ _cardLog.Controls.Add(_chkAutoScroll);
+ _layoutExtras.Add(() =>
+ {
+ int x = _cardLog.Width - Theme.Px(12) - Theme.Px(30); // 让开折叠箭头
+ // 头部所有元素共用一条中线:按钮和勾选框都按 cy - 高度/2 摆,文字才齐平
+ int cy = _cardLog.HeaderH / 2;
+ _btnOpenReport.SetBounds(x - _btnOpenReport.Width, cy - _btnOpenReport.Height / 2,
+ _btnOpenReport.Width, _btnOpenReport.Height);
+ _btnClearLog.SetBounds(_btnOpenReport.Left - G - _btnClearLog.Width, cy - _btnClearLog.Height / 2,
+ _btnClearLog.Width, _btnClearLog.Height);
+ // net48 的 CheckBox 没有 PreferredWidth,只能自己量文字宽度(外加勾选框);
+ // 高度取原生 PreferredHeight,整体压在中线上
+ var pref = _chkAutoScroll.GetPreferredSize(Size.Empty);
+ int chkH = Math.Max(Theme.Px(18), pref.Height);
+ int chkW = Math.Max(Theme.TextWidth(_chkAutoScroll.Text, Theme.Caption) + Theme.Px(22), pref.Width);
+ _chkAutoScroll.SetBounds(_btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, chkH);
+ });
+ }
+
+ // --- 高级工具抽屉 ---
+ private void BuildCardAdv()
+ {
+ _cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36));
+ _cardAdv.ShowBadge = false;
+ _fArea = NewField(_txtAreaPath, 420, true);
+ _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
+ _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.ArrowUp, 120);
+
+ int y = _cardAdv.HeaderH + Theme.S12;
+ y = Row(_cardAdv, y, 52, "Area 路径", _fArea, "改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项");
+ y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, "已关闭的工作项会被临时重新打开,完成后自动还原状态");
+ _errArea = AddErrorUnder(_cardAdv, _fArea);
+ _cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8;
+ }
+
+ // ------------------------------------------------------------- 摆放助手
+ /// 带固定列宽的行内标签:同一张卡片的多行表单用它把字段左缘对齐成一列。
+ private class Lbl
+ {
+ public readonly string Text;
+ public readonly int MinWidth; // 运行时像素(构建时已含缩放)
+
+ public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; }
+ }
+
+ /// 一组标签文字的最大实测宽度,用作对齐列宽。
+ private static int MaxLabelWidth(params string[] texts)
+ {
+ int w = 0;
+ foreach (var t in texts) w = Math.Max(w, Theme.TextWidth(t, Theme.Body));
+ return w;
+ }
+
+ /// 行内元素:string=标签,Lbl=定宽标签,int=已换算好的间距,Control=控件(宽度取控件自身设置)。
+ private int Row(Card card, int y, int designStride, params object[] items)
+ {
+ int stride = Theme.Px(designStride);
+ int x = M;
+ string lastLabel = null;
+ foreach (var item in items)
+ {
+ var text = item as string;
+ var spec = item as Lbl;
+ if (text != null || spec != null)
+ {
+ string label = text ?? spec.Text;
+ var lbl = new Label
+ {
+ Text = label,
+ Font = Theme.Body,
+ ForeColor = Theme.Ink2,
+ AutoSize = true,
+ BackColor = Theme.Card
+ };
+ lbl.SetBounds(x, y + (Theme.FieldH - lbl.PreferredHeight) / 2, lbl.PreferredWidth, lbl.PreferredHeight);
+ card.Controls.Add(lbl);
+ x += Math.Max(lbl.PreferredWidth, spec != null ? spec.MinWidth : 0) + Theme.Px(6);
+ lastLabel = label;
+ continue;
+ }
+
+ if (item is int) { x += (int)item; continue; }
+
+ var c = item as Control;
+ if (c == null) continue;
+ int top = y + Math.Max(0, (Theme.FieldH - c.Height) / 2);
+ c.SetBounds(x, top, c.Width, c.Height);
+ card.Controls.Add(c);
+ if (string.IsNullOrEmpty(c.AccessibleName) && lastLabel != null)
+ c.AccessibleName = lastLabel.TrimEnd(':', ':');
+ x += c.Width + Theme.S12;
+ }
+ return y + stride;
+ }
+
+ private InputField NewField(Control inner, int designWidth, bool mono)
+ {
+ var field = new InputField { Width = Theme.Px(designWidth) };
+ field.Add(inner, mono);
+ return field;
+ }
+
+ private FlatButton NewButton(string text, BtnKind kind, IconKind glyph, int designMinWidth)
+ {
+ var btn = new FlatButton { Text = text, Kind = kind, Glyph = glyph };
+ btn.FitWidth(designMinWidth); // 宽度按文字+图标量出来,参数只是下限
+ return btn;
+ }
+
+ private CheckBox NewCheck(string text, string hint)
+ {
+ var chk = new CheckBox
+ {
+ Text = text,
+ Font = Theme.Body,
+ ForeColor = Theme.Ink2,
+ AutoSize = true,
+ BackColor = Theme.Card
+ };
+ if (!string.IsNullOrEmpty(hint))
+ {
+ var h = new Label
+ {
+ Text = " · " + hint,
+ Font = Theme.Caption,
+ ForeColor = Theme.Ink3,
+ AutoSize = true,
+ BackColor = Theme.Card
+ };
+ hintFor(chk, h);
+ }
+ return chk;
+ }
+
+ /// 复选框后跟随的灰色说明:与复选框同批显示/隐藏。
+ private void hintFor(CheckBox chk, Label hint)
+ {
+ chk.HandleCreated += (s, e) =>
+ {
+ chk.Parent.Controls.Add(hint);
+ hint.SetBounds(chk.Right + Theme.Px(2), chk.Top + (chk.Height - hint.PreferredHeight) / 2,
+ hint.PreferredWidth, hint.PreferredHeight);
+ hint.Visible = chk.Visible;
+ chk.VisibleChanged += (s2, e2) => hint.Visible = chk.Visible;
+ };
+ }
+
+ private Label NewError()
+ {
+ return new Label
+ {
+ Font = Theme.Caption,
+ ForeColor = Theme.Danger,
+ AutoSize = false,
+ Visible = false,
+ BackColor = Theme.Card,
+ TextAlign = ContentAlignment.MiddleLeft
+ };
+ }
+
+ /// 横向随窗口变宽(X/Y 取控件摆放时的位置,Right 是留给右侧按钮的固定宽度)。
+ private void Track(Control c, Card owner, int right, int bottom)
+ {
+ _stretches.Add(new Stretch { C = c, Owner = owner, X = c.Left, Y = c.Top, Right = right, Bottom = bottom });
+ }
+
+ private Stretch TrackPlaced(Control c, Card owner, int x, int y, int right, int bottom)
+ {
+ var st = new Stretch { C = c, Owner = owner, X = x, Y = y, Right = right, Bottom = bottom };
+ _stretches.Add(st);
+ return st;
+ }
+
+ /// 控件右缘贴卡片右内边距,FromRight 为距右缘的固定量。
+ private void AnchorRight(Control c, Card owner, int fromRight, int y)
+ {
+ if (c.Parent != owner) owner.Controls.Add(c);
+ _rightAnchors.Add(new RightAnchor { C = c, Owner = owner, FromRight = fromRight, Y = y });
+ }
+
+ // ------------------------------------------------------------- 重排
+ private bool _inLayout;
+ private bool _uiReady;
+
+ /// 手风琴:只展开指定细节区,其余收成一行摘要。
+ private void OpenDetail(Card card)
+ {
+ foreach (var c in DetailCards())
+ {
+ bool open = ReferenceEquals(c, card);
+ if (c.Expanded != open)
+ {
+ c.Expanded = open;
+ c.ApplyHeight();
+ }
+ }
+ Relayout();
+ }
+
+ private void Relayout()
+ {
+ // 构建过程中各区块还不齐全,过早重排会踩到 null
+ if (!_uiReady || _inLayout) return;
+ _inLayout = true;
+ try { DoLayout(); }
+ finally { _inLayout = false; }
+ }
+
+ private void DoLayout()
+ {
+ int cw = Math.Max(Theme.Px(320), ClientSize.Width - M * 2);
+
+ _ribbon.SetBounds(M, M, cw, _ribbon.Height);
+ int y = _ribbon.Bottom + G;
+
+ foreach (var card in new[] { _cardConn, _cardData })
+ {
+ card.SetBounds(M, y, cw, card.Height);
+ y = card.Bottom + G;
+ }
+
+ // 从底部往上排
+ int by = ClientSize.Height - M;
+ _cardAdv.SetBounds(M, by - _cardAdv.Height, cw, _cardAdv.Height);
+ by = _cardAdv.Top - G;
+ _cardLog.SetBounds(M, by - _cardLog.Height, cw, _cardLog.Height);
+ by = _cardLog.Top - G;
+ _actionBar.SetBounds(M, by - _actionBar.Height, cw, _actionBar.Height);
+ by = _actionBar.Top - G;
+
+ // 预览卡吃剩余高度;空间不足时先让日志收成一行
+ if (by - G - y < Theme.Px(150) && _cardLog.Expanded)
+ {
+ _cardLog.Expanded = false;
+ _cardLog.ApplyHeight();
+ by = ClientSize.Height - M;
+ _cardAdv.SetBounds(M, by - _cardAdv.Height, cw, _cardAdv.Height);
+ by = _cardAdv.Top - G;
+ _cardLog.SetBounds(M, by - _cardLog.Height, cw, _cardLog.Height);
+ by = _cardLog.Top - G;
+ _actionBar.SetBounds(M, by - _actionBar.Height, cw, _actionBar.Height);
+ by = _actionBar.Top - G;
+ }
+ _cardPreview.SetBounds(M, y, cw, Math.Max(Theme.Px(120), by - G - y));
+ _cardPreview.BodyH = _cardPreview.Height - _cardPreview.HeaderH;
+
+ foreach (var st in _stretches)
+ {
+ if (st.C == null || st.Owner == null) continue;
+ int w = Math.Max(Theme.Px(60), st.Owner.Width - st.X - st.Right);
+ int h = st.FillHeight ? Math.Max(Theme.Px(20), st.Owner.Height - st.Y - st.Bottom) : st.C.Height;
+ st.C.SetBounds(st.X, st.Y, w, h);
+ }
+ foreach (var ra in _rightAnchors)
+ {
+ if (ra.C == null || ra.Owner == null) continue;
+ ra.C.SetBounds(ra.Owner.Width - ra.FromRight - ra.C.Width, ra.Y, ra.C.Width, ra.C.Height);
+ }
+ LayoutRibbon();
+ foreach (var act in _layoutExtras) act();
+
+ _cardConn.Invalidate();
+ _cardData.Invalidate();
+ }
+
+ // ------------------------------------------------------------- 状态呈现
+ private void Append(string msg, LogLevel lvl)
+ {
+ _log.AppendLine(DateTime.Now.ToString("HH:mm:ss"), lvl, msg);
+ _cardLog.SetSummary(msg);
+ if (!_cardLog.Expanded && (lvl == LogLevel.Fail || lvl == LogLevel.Warn))
+ _cardLog.Invalidate();
+ }
+
+ private void SetConnection(string text, ChipKind kind)
+ {
+ _chipConn.Set(text, kind);
+ Relayout();
+ }
+
+ private void SetChips(int ok, int skip, int fail, bool neutral)
+ {
+ _chipOk.Set("成功 " + ok, neutral ? ChipKind.Quiet : ChipKind.Ok);
+ _chipSkip.Set("跳过 " + skip, neutral ? ChipKind.Quiet : (skip > 0 ? ChipKind.Warn : ChipKind.Ok));
+ _chipFail.Set("失败 " + fail, neutral ? ChipKind.Quiet : (fail > 0 ? ChipKind.Danger : ChipKind.Ok));
+ if (_actionBar != null) Relayout();
+ }
+
+ private void SetBadgeOf(Card card, BadgeKind kind)
+ {
+ card.SetBadge(kind);
+ card.Invalidate(new Rectangle(0, 0, card.Width, card.HeaderH));
+ }
+
+ private void ShowError(Label err, InputField field, Card card, string message)
+ {
+ if (err == null) return;
+ err.Text = message ?? "";
+ err.Visible = !string.IsNullOrEmpty(message);
+ field.Invalid = !string.IsNullOrEmpty(message);
+ card.SetInvalid(!string.IsNullOrEmpty(message));
+ if (!string.IsNullOrEmpty(message) && !card.Expanded) card.Toggle();
+ Relayout();
+ }
+
+ private void RefreshRibbon()
+ {
+ SetRibbonValue(0, ShortHost(_cfg != null ? _cfg.TfsUrl : ""));
+ SetRibbonValue(1, string.IsNullOrEmpty(_cfg.Project) ? "(未填)" : _cfg.Project);
+ SetRibbonValue(2, string.IsNullOrEmpty(_cfg.WorkItemType) ? "(未填)" : _cfg.WorkItemType);
+ Relayout();
+ }
+
+ private void SetRibbonValue(int group, string text)
+ {
+ var val = _ribItems[group].Val;
+ if (val.Text == text) return;
+ val.Text = text;
+ val.Invalidate();
+ }
+
+ private static string ShortHost(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return "(未填)";
+ try
+ {
+ var u = new Uri(url);
+ var path = (u.PathAndQuery ?? "").Trim('/');
+ var host = u.Host;
+ if (u.Port > 0 && u.Port != 80 && u.Port != 443) host += ":" + u.Port;
+ return string.IsNullOrEmpty(path) ? host : host + "/" + path.Split('/')[0];
+ }
+ catch { return url; }
+ }
+
+ private void RefreshSummaries()
+ {
+ var iter = _cmbIteration.Text.Trim();
+ var file = _txtExcel.Text.Trim();
+ var fileName = string.IsNullOrEmpty(file) ? "" : System.IO.Path.GetFileName(file);
+ _cardConn.SetSummary(string.Join(" · ", new[]
+ {
+ ShortHost(_txtUrl.Text.Trim()),
+ _txtProject.Text.Trim(),
+ _txtType.Text.Trim(),
+ _cmbAuth.SelectedItem != null ? _cmbAuth.SelectedItem.ToString() : "ntlm"
+ }));
+ _cardData.SetSummary(string.Join(" · ", new[]
+ {
+ string.IsNullOrEmpty(iter) ? "迭代未选" : iter,
+ string.IsNullOrEmpty(fileName) ? "未选 Excel" : fileName,
+ _rows.Count > 0 ? _rows.Count + " 行" : "未预览",
+ string.IsNullOrWhiteSpace(_txtParentPbi.Text) ? "父 PBI 未填" : "父 PBI #" + _txtParentPbi.Text.Trim()
+ }));
+ _cardPreview.SetSummary(_rows.Count > 0
+ ? "共 " + _rows.Count + " 行,可直接开始导入"
+ : "选择 Excel 后在这里核对将要写入的内容");
+ _cardAdv.SetSummary(string.IsNullOrWhiteSpace(_txtAreaPath.Text)
+ ? "Area 未设置"
+ : "Area " + _txtAreaPath.Text.Trim());
+ }
+
+ /// 第 4 步徽标:待办 / 进行中 / 完成 / 有失败。
+ private void SetRunBadge(BadgeKind kind)
+ {
+ _runBadge = kind;
+ _badgeRun.Set(4, kind);
+ }
+
+ /// 绑定后调一次:长文本列吃剩余宽度,数字列等宽右对齐,表头不换行。
+ private void StyleGridColumns()
+ {
+ foreach (DataGridViewColumn col in _grid.Columns)
+ {
+ col.SortMode = DataGridViewColumnSortMode.NotSortable;
+ col.HeaderCell.Style.WrapMode = DataGridViewTriState.False;
+ var head = col.HeaderText;
+ if (head == "标题" || head == "说明")
+ {
+ col.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
+ col.MinimumWidth = Theme.Px(head == "标题" ? 280 : 160);
+ }
+ else
+ {
+ col.AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells;
+ }
+ if (head == "行号" || head == "完成量" || head == "日期" || head == "父PBI")
+ {
+ col.DefaultCellStyle.Font = Theme.Mono;
+ col.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
+ }
+ }
+ }
+
+ private void UpdateEmptyState()
+ {
+ bool hasRows = _grid.RowCount > 0;
+ _lblEmpty.Visible = !hasRows;
+ _grid.Visible = hasRows;
+ if (_lblEmpty.Visible) _lblEmpty.BringToFront();
+ }
+
+ private void OpenReport()
+ {
+ if (string.IsNullOrEmpty(_lastReportPath) || !System.IO.File.Exists(_lastReportPath))
+ {
+ Append("还没有可打开的报告 CSV", LogLevel.Warn);
+ return;
+ }
+ try { System.Diagnostics.Process.Start(_lastReportPath); }
+ catch (Exception ex) { Append("打开报告失败: " + ex.Message, LogLevel.Fail); }
+ }
+
+ private void SetBusy(bool busy)
+ {
+ UseWaitCursor = busy;
+ _btnImport.Enabled = !busy;
+ _btnPreview.Enabled = !busy;
+ _btnTest.Enabled = !busy;
+ _btnLoadIterations.Enabled = !busy;
+ _btnFixArea.Enabled = !busy;
+ _btnLinkParent.Enabled = !busy;
+ _btnBrowse.Enabled = !busy;
+ _btnTemplate.Enabled = !busy;
+ }
+ }
+
+ /// 预览空状态:表格矢量图标 + 提示文字,整块在预览区居中,替代单行灰字。
+ internal class EmptyState : Control
+ {
+ public EmptyState()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ BackColor = Theme.Card;
+ TabStop = false;
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ int icon = Theme.Px(40);
+ int textH = Theme.Px(18);
+ int blockH = icon + Theme.Px(10) + textH;
+ int top = Math.Max(Theme.Px(4), (Height - blockH) / 2);
+ var old = g.SmoothingMode;
+ g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
+ Icons.Draw(g, IconKind.Table, new Rectangle((Width - icon) / 2, top, icon, icon), Theme.Ink3);
+ g.SmoothingMode = old;
+ TextRenderer.DrawText(g, Text, Theme.Body,
+ new Rectangle(Theme.Px(16), top + icon + Theme.Px(10), Width - Theme.Px(32), textH),
+ Theme.Ink3,
+ TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter
+ | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+ }
+
+ protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); Invalidate(); }
+ }
+}
diff --git a/TfsExcelImporter/TfsExcelImporter.csproj b/TfsExcelImporter/TfsExcelImporter.csproj
index 35b37b4..bf66a2e 100644
--- a/TfsExcelImporter/TfsExcelImporter.csproj
+++ b/TfsExcelImporter/TfsExcelImporter.csproj
@@ -9,6 +9,8 @@
TFS Excel 导入工具
TFS Excel 导入工具
true
+
+ app.manifest
diff --git a/TfsExcelImporter/Theme.cs b/TfsExcelImporter/Theme.cs
new file mode 100644
index 0000000..e23dec2
--- /dev/null
+++ b/TfsExcelImporter/Theme.cs
@@ -0,0 +1,1225 @@
+using System;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Windows.Forms;
+
+namespace TfsExcelImporter
+{
+ ///
+ /// 设计 token:颜色、字号、间距、圆角。界面代码只引用这里,不散落具体色值。
+ ///
+ internal static class Theme
+ {
+ // --- 缩放 ---
+ /// 系统缩放系数(96dpi = 1)。必须在 BuildUi 之前按 DeviceDpi 赋值。
+ public static float Scale = 1f;
+
+ /// 设计稿(96dpi)像素 → 当前缩放下的像素。字体用磅值,Windows 会自动跟缩放,所以只有像素量需要换算。
+ public static int Px(int designPx)
+ {
+ return (int)Math.Round(designPx * Scale);
+ }
+
+ // --- 间距刻度(4/8/12/16) ---
+ public static int S4 { get { return Px(4); } }
+ public static int S8 { get { return Px(8); } }
+ public static int S12 { get { return Px(12); } }
+ public static int S16 { get { return Px(16); } }
+ public static int Radius { get { return Px(8); } }
+ public static int RadiusCtl { get { return Px(6); } }
+ public static int FieldH { get { return Px(32); } }
+ public static int BtnH { get { return Px(32); } }
+
+ // --- 颜色 ---
+ public static readonly Color Surface = Color.FromArgb(0xF4, 0xF6, 0xFA);
+ public static readonly Color Card = Color.White;
+ public static readonly Color Border = Color.FromArgb(0xDF, 0xE3, 0xEB);
+ public static readonly Color BorderStrong = Color.FromArgb(0xC7, 0xCE, 0xDB);
+ public static readonly Color Ink = Color.FromArgb(0x1F, 0x24, 0x30);
+ public static readonly Color Ink2 = Color.FromArgb(0x59, 0x62, 0x70);
+ public static readonly Color Ink3 = Color.FromArgb(0x8A, 0x93, 0xA3);
+ public static readonly Color Primary = Color.FromArgb(0x1E, 0x40, 0xAF);
+ public static readonly Color PrimaryHover = Color.FromArgb(0x1B, 0x3A, 0x9E);
+ public static readonly Color PrimaryDown = Color.FromArgb(0x17, 0x32, 0x88);
+ public static readonly Color PrimarySoft = Color.FromArgb(0xEA, 0xF0, 0xFB);
+ public static readonly Color PrimarySoftBorder = Color.FromArgb(0xCF, 0xDD, 0xF5);
+ public static readonly Color Accent = Color.FromArgb(0xB4, 0x53, 0x09);
+ public static readonly Color AccentSoft = Color.FromArgb(0xFD, 0xF3, 0xE3);
+ public static readonly Color AccentBorder = Color.FromArgb(0xF0, 0xD9, 0xB0);
+ public static readonly Color Ok = Color.FromArgb(0x15, 0x80, 0x3D);
+ public static readonly Color OkSoft = Color.FromArgb(0xE8, 0xF6, 0xED);
+ public static readonly Color OkBorder = Color.FromArgb(0xBF, 0xE3, 0xCC);
+ public static readonly Color Danger = Color.FromArgb(0xDC, 0x26, 0x26);
+ public static readonly Color DangerSoft = Color.FromArgb(0xFD, 0xEC, 0xEC);
+ public static readonly Color DangerBorder = Color.FromArgb(0xF6, 0xC7, 0xC7);
+ public static readonly Color Hover = Color.FromArgb(0xF2, 0xF5, 0xFA);
+ public static readonly Color Disabled = Color.FromArgb(0xEE, 0xF1, 0xF6);
+ // 次按钮的浅色调底:白底卡片上靠色相而非描边区分层级
+ public static readonly Color Tonal = Color.FromArgb(0xF5, 0xF7, 0xFB);
+ public static readonly Color TonalDown = Color.FromArgb(0xE4, 0xEA, 0xF5);
+ public static readonly Color TonalBorder = Color.FromArgb(0xDC, 0xE2, 0xEB);
+ public static readonly Color RowAlt = Color.FromArgb(0xFA, 0xFB, 0xFD);
+ public static readonly Color LogBg = Color.FromArgb(0xFB, 0xFC, 0xFE);
+
+ // --- 字体(CJK 优先,缺字体时逐级回落) ---
+ public static readonly Font Body = Make("Microsoft YaHei UI", 9.75f, false, false);
+ public static readonly Font BodyBold = Make("Microsoft YaHei UI", 9.75f, true, false);
+ public static readonly Font Caption = Make("Microsoft YaHei UI", 9f, false, false);
+ public static readonly Font CaptionBold = Make("Microsoft YaHei UI", 9f, true, false);
+ public static readonly Font Mono = Make("Consolas", 9.75f, false, true);
+ public static readonly Font MonoBold = Make("Consolas", 9.75f, true, true);
+ public static readonly Font CountNum = Make("Consolas", 11.25f, true, true);
+
+ private static Font Make(string family, float size, bool bold, bool monospace)
+ {
+ var style = bold ? FontStyle.Bold : FontStyle.Regular;
+ if (monospace)
+ {
+ try { return new Font(family, size, style, GraphicsUnit.Point); }
+ catch { return new Font(FontFamily.GenericMonospace, size, style, GraphicsUnit.Point); }
+ }
+ foreach (var name in new[] { family, "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" })
+ {
+ try { return new Font(name, size, style, GraphicsUnit.Point); }
+ catch { }
+ }
+ return new Font(FontFamily.GenericSansSerif, size, style, GraphicsUnit.Point);
+ }
+
+ public static GraphicsPath RoundRect(RectangleF r, float radius)
+ {
+ var path = new GraphicsPath();
+ float d = radius * 2f;
+ if (d <= 0f || r.Width <= 0f || r.Height <= 0f)
+ {
+ path.AddRectangle(r);
+ path.CloseFigure();
+ return path;
+ }
+ d = Math.Min(d, Math.Min(r.Width, r.Height));
+ path.AddArc(r.X, r.Y, d, d, 180f, 90f);
+ path.AddArc(r.Right - d, r.Y, d, d, 270f, 90f);
+ path.AddArc(r.Right - d, r.Bottom - d, d, d, 0f, 90f);
+ path.AddArc(r.X, r.Bottom - d, d, d, 90f, 90f);
+ path.CloseFigure();
+ return path;
+ }
+
+ /// 细描边固定 1 设备像素(跟 Windows 自身控件一致),不随缩放变粗。
+ public static float Hair { get { return 1f; } }
+
+ /// 强调描边(焦点/校验失败)按缩放取整,150% 下为 2 像素。
+ public static float HairStrong { get { return Math.Max(1f, (float)Math.Round(Scale)); } }
+
+ /// 填充 + 描边一个圆角面板;borderWidth 为 0 时只填充。
+ /// Half 像素偏移 + 内嵌画笔让 1px 边线落在整像素上,避免高缩放下糊成双线。
+ /// 圆角面内切在 r 里,四角和最后 1 行列落在它外面,必须先用 backdrop 铺满整块客户区:
+ /// 自绘控件的双缓冲只清一次,真实屏幕上那些从未绘制的像素会残留成黑色描边。
+ public static void FillPanel(Graphics g, Rectangle r, Color fill, Color borderColor,
+ int radius, float borderWidth, Color backdrop)
+ {
+ if (r.Width < 2 || r.Height < 2) return;
+ var old = g.SmoothingMode;
+ var oldOffset = g.PixelOffsetMode;
+ if (backdrop.A > 0)
+ using (var bg = new SolidBrush(backdrop)) g.FillRectangle(bg, r);
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ g.PixelOffsetMode = PixelOffsetMode.Half;
+ var box = new RectangleF(r.X, r.Y, r.Width - 1f, r.Height - 1f);
+ using (var path = RoundRect(box, radius))
+ {
+ using (var b = new SolidBrush(fill)) g.FillPath(b, path);
+ if (borderColor != Color.Transparent && borderWidth > 0f)
+ using (var pen = new Pen(borderColor, borderWidth))
+ {
+ pen.Alignment = PenAlignment.Inset;
+ g.DrawPath(pen, path);
+ }
+ }
+ g.PixelOffsetMode = oldOffset;
+ g.SmoothingMode = old;
+ }
+
+ /// 控件四周应当露出的底色:优先父容器面色,透明底(自绘宿主)退到卡片白。
+ public static Color BackdropOf(Control owner)
+ {
+ var parent = owner.Parent;
+ if (parent != null && parent.BackColor.A > 0) return parent.BackColor;
+ if (owner.BackColor.A > 0) return owner.BackColor;
+ return Card;
+ }
+
+ public static int TextWidth(string text, Font font) {
+ if (string.IsNullOrEmpty(text)) return 0;
+ return TextRenderer.MeasureText(text, font, Size.Empty,
+ TextFormatFlags.NoPadding | TextFormatFlags.SingleLine).Width;
+ }
+
+ public static Color ChipInk(ChipKind kind)
+ {
+ switch (kind)
+ {
+ case ChipKind.Ok: return Ok;
+ case ChipKind.Warn: return Accent;
+ case ChipKind.Danger: return Danger;
+ case ChipKind.Info: return Primary;
+ case ChipKind.Quiet: return Ink3;
+ default: return Ink2;
+ }
+ }
+
+ public static Color ChipFace(ChipKind kind)
+ {
+ switch (kind)
+ {
+ case ChipKind.Ok: return OkSoft;
+ case ChipKind.Warn: return AccentSoft;
+ case ChipKind.Danger: return DangerSoft;
+ case ChipKind.Info: return PrimarySoft;
+ case ChipKind.Quiet: return Card;
+ default: return Disabled;
+ }
+ }
+
+ public static Color ChipBorder(ChipKind kind)
+ {
+ switch (kind)
+ {
+ case ChipKind.Ok: return OkBorder;
+ case ChipKind.Warn: return AccentBorder;
+ case ChipKind.Danger: return DangerBorder;
+ case ChipKind.Info: return PrimarySoftBorder;
+ case ChipKind.Quiet: return Border;
+ default: return Border;
+ }
+ }
+ }
+
+ internal enum ChipKind { Neutral, Info, Ok, Warn, Danger, Quiet }
+
+ internal enum IconKind
+ {
+ None, ChevronDown, ChevronRight, Check, Bolt, Folder, Doc, ArrowUp, Wrench, Trash, Table, Gear, Sync
+ }
+
+ /// GDI+ 矢量图标:统一 24 网格、约 1.6 描边、圆头。界面内不用 emoji、不用位图。
+ internal static class Icons
+ {
+ public static void Draw(Graphics g, IconKind kind, Rectangle box, Color color)
+ {
+ if (kind == IconKind.None || box.Width < 4 || box.Height < 4) return;
+ var old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ float side = Math.Min(box.Width, box.Height);
+ float k = side / 24f;
+ float ox = box.X + (box.Width - side) / 2f;
+ float oy = box.Y + (box.Height - side) / 2f;
+
+ using (var pen = new Pen(color, Math.Max(1.2f, 1.7f * k)))
+ {
+ pen.StartCap = LineCap.Round;
+ pen.EndCap = LineCap.Round;
+ pen.LineJoin = LineJoin.Round;
+ switch (kind)
+ {
+ case IconKind.ChevronDown:
+ Poly(g, pen, ox, oy, k, 6, 9, 12, 15, 18, 9);
+ break;
+ case IconKind.ChevronRight:
+ Poly(g, pen, ox, oy, k, 9, 6, 15, 12, 9, 18);
+ break;
+ case IconKind.Check:
+ Poly(g, pen, ox, oy, k, 5, 13, 10, 18, 19, 6);
+ break;
+ case IconKind.Bolt:
+ Poly(g, pen, ox, oy, k, 13, 3, 6, 13, 12, 13, 11, 21, 18, 11, 12, 11, 13, 3);
+ break;
+ case IconKind.Folder:
+ Poly(g, pen, ox, oy, k, 3, 7, 9, 7, 11, 9, 21, 9, 21, 19, 3, 19, 3, 7);
+ break;
+ case IconKind.Doc:
+ Poly(g, pen, ox, oy, k, 6, 3, 15, 3, 19, 7, 19, 21, 6, 21, 6, 3);
+ Poly(g, pen, ox, oy, k, 15, 3, 15, 7, 19, 7);
+ Line(g, pen, ox, oy, k, 9, 12, 16, 12);
+ Line(g, pen, ox, oy, k, 9, 16, 16, 16);
+ break;
+ case IconKind.ArrowUp:
+ Line(g, pen, ox, oy, k, 12, 20, 12, 5);
+ Poly(g, pen, ox, oy, k, 5, 12, 12, 5, 19, 12);
+ break;
+ case IconKind.Wrench:
+ g.DrawArc(pen, ox + 3 * k, oy + 3 * k, 8 * k, 8 * k, 300f, 250f);
+ Line(g, pen, ox, oy, k, 10, 14, 19, 21);
+ break;
+ case IconKind.Trash:
+ Poly(g, pen, ox, oy, k, 4, 7, 20, 7);
+ Poly(g, pen, ox, oy, k, 8, 7, 8, 20, 16, 20, 16, 7);
+ Line(g, pen, ox, oy, k, 11, 4, 13, 4);
+ break;
+ case IconKind.Table:
+ g.DrawRectangle(pen, ox + 3 * k, oy + 5 * k, 18 * k, 14 * k);
+ Line(g, pen, ox, oy, k, 3, 10, 21, 10);
+ Line(g, pen, ox, oy, k, 10, 10, 10, 19);
+ break;
+ case IconKind.Gear:
+ g.DrawEllipse(pen, ox + 8 * k, oy + 8 * k, 8 * k, 8 * k);
+ g.DrawEllipse(pen, ox + 10.5f * k, oy + 10.5f * k, 3 * k, 3 * k);
+ break;
+ case IconKind.Sync: // 拉取服务器数据:带缺口的圆 + 箭头
+ g.DrawArc(pen, ox + 4 * k, oy + 4 * k, 16 * k, 16 * k, 300f, 250f);
+ Poly(g, pen, ox, oy, k, 19, 4, 20, 9, 15, 8);
+ break;
+ }
+ }
+ g.SmoothingMode = old;
+ }
+
+ private static void Poly(Graphics g, Pen pen, float ox, float oy, float k, params float[] xy)
+ {
+ var pts = new PointF[xy.Length / 2];
+ for (int i = 0; i < pts.Length; i++)
+ pts[i] = new PointF(ox + xy[i * 2] * k, oy + xy[i * 2 + 1] * k);
+ g.DrawLines(pen, pts);
+ }
+
+ private static void Line(Graphics g, Pen pen, float ox, float oy, float k, float x1, float y1, float x2, float y2)
+ {
+ g.DrawLine(pen, ox + x1 * k, oy + y1 * k, ox + x2 * k, oy + y2 * k);
+ }
+ }
+
+ internal enum BadgeKind { Pending, Active, Done, Warn }
+
+ /// 独立编号徽标:用于操作条这类没有卡片头部的区域,与 Card 内的徽标同一套语义。
+ internal class StepBadge : Control
+ {
+ public int Number;
+ public BadgeKind Kind = BadgeKind.Pending;
+
+ public StepBadge()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
+ | ControlStyles.SupportsTransparentBackColor, true);
+ Size = new Size(Theme.Px(22), Theme.Px(22));
+ BackColor = Color.Transparent;
+ TabStop = false;
+ }
+
+ public void Set(int number, BadgeKind kind)
+ {
+ Number = number;
+ Kind = kind;
+ Invalidate();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ Color fill, ink;
+ switch (Kind)
+ {
+ case BadgeKind.Active: fill = Theme.Primary; ink = Color.White; break;
+ case BadgeKind.Done: fill = Theme.Ok; ink = Color.White; break;
+ case BadgeKind.Warn: fill = Theme.Danger; ink = Color.White; break;
+ default: fill = Theme.Disabled; ink = Theme.Ink2; break;
+ }
+ var old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ var r = new RectangleF(0.5f, 0.5f, Width - 1f, Height - 1f);
+ using (var b = new SolidBrush(fill)) g.FillEllipse(b, r);
+ g.SmoothingMode = old;
+ if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, ClientRectangle, ink);
+ else
+ TextRenderer.DrawText(g, Number.ToString(), Theme.CaptionBold, ClientRectangle, ink,
+ TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
+ }
+ }
+
+ /// 白底圆角承载面板:状态条与操作条这类不需要步骤头部的区域。
+ /// BackColor 跟着面色走,否则子 Label/CheckBox 会按环境属性继承到灰底。
+ internal class Surface : Panel
+ {
+ private Color _face = Theme.Card;
+ public Color LineColor = Theme.Border;
+
+ public Color FaceColor
+ {
+ get { return _face; }
+ set { _face = value; BackColor = value; Invalidate(); }
+ }
+
+ public Surface()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ BackColor = Theme.Card;
+ TabStop = false;
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ Theme.FillPanel(e.Graphics, new Rectangle(0, 0, Width, Height), _face, LineColor,
+ Theme.Radius, 1f, Theme.BackdropOf(this));
+ }
+ }
+
+ ///
+ /// 步骤卡片:编号徽标 + 标题 + 摘要 + 折叠箭头。折叠后只留一行摘要,把高度让给预览表格。
+ /// 内容子控件由外部按 HeaderH 往下的坐标摆放;展开高度 = HeaderH + BodyH。
+ ///
+ internal class Card : Panel
+ {
+ public int StepNumber;
+ public string Title = "";
+ public string Summary = "";
+ public BadgeKind Badge = BadgeKind.Pending;
+ public bool Collapsible = true;
+ public bool Expanded = true;
+ public bool Invalid = false;
+ public bool ShowBadge = true;
+ /// 卡片头部右侧留给按钮的宽度,摘要文字不越过它。
+ public int HeaderReserve = 0;
+ public int HeaderH = 44;
+ public int BodyH = 0;
+ public Action OnToggle;
+
+ private bool _hoverHeader;
+
+ public Card()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ BackColor = Theme.Card; // 子 Label 通过环境属性继承白底
+ TabStop = false;
+ HeaderH = Theme.Px(44);
+ }
+
+ public Rectangle BodyRect { get { return new Rectangle(0, HeaderH, Width, BodyH); } }
+
+ private Rectangle HeaderRect { get { return new Rectangle(0, 0, Width, HeaderH); } }
+
+ public void SetSummary(string text)
+ {
+ if (Summary == text) return;
+ Summary = text ?? "";
+ Invalidate(HeaderRect);
+ }
+
+ public void SetBadge(BadgeKind kind)
+ {
+ if (Badge == kind) return;
+ Badge = kind;
+ Invalidate(HeaderRect);
+ }
+
+ public void SetInvalid(bool invalid)
+ {
+ if (Invalid == invalid) return;
+ Invalid = invalid;
+ Invalidate(HeaderRect);
+ }
+
+ public void ApplyHeight()
+ {
+ Height = Expanded ? HeaderH + BodyH : HeaderH;
+ // 折叠时靠父面板裁剪隐藏正文,不去改子控件的 Visible——
+ // 否则会把「按校验状态自己决定显隐」的行内错误提示强行显示出来。
+ // 但要把 TabStop 关掉,避免键盘 Tab 进不可见的输入框。
+ SetTabStop(Controls, Expanded);
+ Invalidate();
+ }
+
+ /// 输入控件可能嵌在 InputField 容器里,递归切换 TabStop。
+ private static void SetTabStop(Control.ControlCollection controls, bool enabled)
+ {
+ foreach (Control c in controls)
+ {
+ if (c is TextBoxBase || c is ComboBox || c is Button || c is CheckBox)
+ c.TabStop = enabled;
+ SetTabStop(c.Controls, enabled);
+ }
+ }
+
+ public void Toggle()
+ {
+ Expanded = !Expanded;
+ if (OnToggle != null) OnToggle(Expanded);
+ else ApplyHeight();
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ base.OnMouseMove(e);
+ bool h = Collapsible && HeaderRect.Contains(e.Location);
+ if (h != _hoverHeader)
+ {
+ _hoverHeader = h;
+ Cursor = h ? Cursors.Hand : Cursors.Default;
+ Invalidate(HeaderRect);
+ }
+ }
+
+ protected override void OnMouseLeave(EventArgs e)
+ {
+ base.OnMouseLeave(e);
+ if (_hoverHeader)
+ {
+ _hoverHeader = false;
+ Cursor = Cursors.Default;
+ Invalidate(HeaderRect);
+ }
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+ if (Collapsible && ButtonPressed(e) && HeaderRect.Contains(e.Location)) Toggle();
+ }
+
+ private static bool ButtonPressed(MouseEventArgs e)
+ {
+ return e.Button == MouseButtons.Left;
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ var r = new Rectangle(0, 0, Width, Height);
+ Theme.FillPanel(g, r, Theme.Card,
+ Invalid ? Theme.Danger : (Expanded ? Theme.PrimarySoftBorder : Theme.Border),
+ Theme.Radius, Invalid || Expanded ? Theme.HairStrong : Theme.Hair, Theme.BackdropOf(this));
+
+ var badge = new Rectangle(Theme.Px(14), (HeaderH - Theme.Px(22)) / 2, Theme.Px(22), Theme.Px(22));
+ if (ShowBadge)
+ {
+ Color badgeFill, badgeInk;
+ switch (Badge)
+ {
+ case BadgeKind.Active: badgeFill = Theme.Primary; badgeInk = Color.White; break;
+ case BadgeKind.Done: badgeFill = Theme.Ok; badgeInk = Color.White; break;
+ case BadgeKind.Warn: badgeFill = Theme.Danger; badgeInk = Color.White; break;
+ default: badgeFill = Theme.Disabled; badgeInk = Theme.Ink2; break;
+ }
+ var old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ using (var b = new SolidBrush(badgeFill)) g.FillEllipse(b, badge);
+ g.SmoothingMode = old;
+ if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, badge, badgeInk);
+ else
+ TextRenderer.DrawText(g, StepNumber.ToString(), Theme.CaptionBold, badge, badgeInk,
+ TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
+ }
+
+ int tx = ShowBadge ? badge.Right + Theme.Px(10) : Theme.Px(16);
+ int chevW = (Collapsible ? Theme.Px(30) : 0) + HeaderReserve;
+ int maxTitle = Math.Max(Theme.Px(40), Width - tx - chevW - Theme.Px(36));
+ int titleW = Math.Min(Theme.TextWidth(Title, Theme.BodyBold), maxTitle);
+ var titleRect = new Rectangle(tx, 0, titleW, HeaderH);
+ TextRenderer.DrawText(g, Title, Theme.BodyBold, titleRect,
+ Invalid ? Theme.Danger : Theme.Ink,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
+ | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+
+ int sx = titleRect.Right + Theme.Px(12);
+ var sumRect = new Rectangle(sx, 0, Math.Max(0, Width - chevW - sx - Theme.Px(12)), HeaderH);
+ TextRenderer.DrawText(g, Summary, Theme.Caption, sumRect,
+ Invalid ? Theme.Danger : Theme.Ink2,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
+ | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+
+ if (Collapsible)
+ {
+ var chev = new Rectangle(Width - Theme.Px(30), (HeaderH - Theme.Px(16)) / 2, Theme.Px(16), Theme.Px(16));
+ Icons.Draw(g, Expanded ? IconKind.ChevronDown : IconKind.ChevronRight, chev,
+ _hoverHeader ? Theme.Ink : Theme.Ink3);
+ }
+
+ if (Expanded)
+ using (var p = new Pen(Theme.Border))
+ g.DrawLine(p, Theme.S16, HeaderH, Width - Theme.S16, HeaderH);
+ }
+ }
+
+ internal enum BtnKind { Secondary, Primary, Ghost }
+
+ /// 扁平按钮:主/次/幽灵三档,悬停与按下只换色不换尺寸;键盘焦点有描边,宽度按内容量出来。
+ internal class FlatButton : Button
+ {
+ public BtnKind Kind = BtnKind.Secondary;
+ public IconKind Glyph = IconKind.None;
+
+ private bool _hover;
+ private bool _pressed;
+
+ public FlatButton()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ FlatStyle = FlatStyle.Flat;
+ FlatAppearance.BorderSize = 0;
+ Cursor = Cursors.Hand;
+ Font = Theme.Body;
+ Height = Theme.BtnH;
+ Margin = Padding.Empty;
+ TabStop = true;
+ UseVisualStyleBackColor = false;
+ }
+
+ /// 按「图标 + 文字 + 左右内边距」定宽,designMin 是 96dpi 下的最小宽度。
+ public void FitWidth(int designMin)
+ {
+ int pad = Theme.Px(13);
+ int gw = GlyphWidth;
+ int gap = gw > 0 && !string.IsNullOrEmpty(Text) ? Theme.Px(6) : 0;
+ Width = Math.Max(Theme.Px(designMin), pad * 2 + gw + gap + Theme.TextWidth(Text, Font));
+ }
+
+ private int GlyphWidth { get { return Glyph == IconKind.None ? 0 : Theme.Px(14); } }
+
+ private Color FaceColor
+ {
+ get
+ {
+ if (!Enabled) return Theme.Disabled;
+ switch (Kind)
+ {
+ case BtnKind.Primary:
+ return _pressed ? Theme.PrimaryDown : (_hover ? Theme.PrimaryHover : Theme.Primary);
+ case BtnKind.Ghost:
+ return _pressed ? Theme.Disabled : (_hover ? Theme.Hover : Theme.Card);
+ default:
+ return _pressed ? Theme.TonalDown : (_hover ? Theme.PrimarySoft : Theme.Tonal);
+ }
+ }
+ }
+
+ private Color InkColor
+ {
+ get
+ {
+ if (!Enabled) return Theme.Ink3;
+ if (Kind == BtnKind.Primary) return Color.White;
+ return Kind == BtnKind.Ghost ? Theme.Ink2 : Theme.Ink;
+ }
+ }
+
+ private Color BorderColor
+ {
+ get
+ {
+ if (!Enabled) return Theme.Border;
+ if (Kind != BtnKind.Secondary) return Color.Transparent;
+ return Focused && ShowFocusCues ? Theme.Primary : Theme.TonalBorder;
+ }
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ var r = new Rectangle(0, 0, Width, Height);
+ Theme.FillPanel(g, r, FaceColor, BorderColor, Theme.RadiusCtl,
+ BorderColor == Color.Transparent ? 0f : Theme.Hair, Theme.BackdropOf(this));
+
+ string text = Text ?? "";
+ int tw = Theme.TextWidth(text, Font);
+ int gw = GlyphWidth;
+ int gap = gw > 0 && tw > 0 ? Theme.Px(6) : 0;
+ int x = (Width - (gw + gap + tw)) / 2;
+
+ if (gw > 0)
+ {
+ Icons.Draw(g, Glyph, new Rectangle(x, (Height - gw) / 2, gw, gw), InkColor);
+ x += gw + gap;
+ }
+ if (tw > 0)
+ TextRenderer.DrawText(g, text, Font, new Rectangle(x, 0, tw + Theme.Px(2), Height), InkColor,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
+ | TextFormatFlags.SingleLine | TextFormatFlags.NoPrefix);
+
+ if (Focused && ShowFocusCues && Enabled)
+ {
+ var old = g.SmoothingMode;
+ var oldOffset = g.PixelOffsetMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ g.PixelOffsetMode = PixelOffsetMode.Half;
+ int inset = Theme.Px(2);
+ using (var pen = new Pen(Kind == BtnKind.Primary ? Color.White : Theme.Primary, Theme.HairStrong))
+ using (var p = Theme.RoundRect(new RectangleF(r.X + inset, r.Y + inset,
+ r.Width - 1 - inset * 2, r.Height - 1 - inset * 2), Math.Max(1, Theme.RadiusCtl - inset)))
+ {
+ pen.Alignment = PenAlignment.Inset;
+ g.DrawPath(pen, p);
+ }
+ g.PixelOffsetMode = oldOffset;
+ g.SmoothingMode = old;
+ }
+ }
+
+ protected override void OnMouseEnter(EventArgs e) { base.OnMouseEnter(e); _hover = true; Invalidate(); }
+ protected override void OnMouseLeave(EventArgs e) { base.OnMouseLeave(e); _hover = false; _pressed = false; Invalidate(); }
+ protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); _pressed = true; Invalidate(); }
+ protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _pressed = false; Invalidate(); }
+ protected override void OnGotFocus(EventArgs e) { base.OnGotFocus(e); Invalidate(); }
+ protected override void OnLostFocus(EventArgs e) { base.OnLostFocus(e); Invalidate(); }
+ protected override void OnEnabledChanged(EventArgs e) { base.OnEnabledChanged(e); Invalidate(); }
+ protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); Invalidate(); }
+ }
+
+ /// 状态 chip:圆点 + 文字,宽度自适应。
+ internal class StatusChip : Control
+ {
+ private string _text = "";
+ private ChipKind _kind = ChipKind.Neutral;
+
+ public StatusChip()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
+ | ControlStyles.SupportsTransparentBackColor, true);
+ Font = Theme.Caption;
+ Height = Theme.Px(24);
+ BackColor = Color.Transparent;
+ TabStop = false;
+ }
+
+ public void Set(string text, ChipKind kind)
+ {
+ _text = text ?? "";
+ _kind = kind;
+ Width = Theme.TextWidth(_text, Font) + Theme.Px(28);
+ Invalidate();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ var ink = Theme.ChipInk(_kind);
+ Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
+ Theme.ChipFace(_kind), Theme.ChipBorder(_kind), Height / 2, 1f, Theme.BackdropOf(this));
+ var old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ using (var b = new SolidBrush(ink)) g.FillEllipse(b, Theme.Px(9), (Height - Theme.Px(7)) / 2, Theme.Px(7), Theme.Px(7));
+ g.SmoothingMode = old;
+ TextRenderer.DrawText(g, _text, Font, new Rectangle(Theme.Px(22), 0, Width - Theme.Px(24), Height), ink,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding | TextFormatFlags.SingleLine);
+ }
+ }
+
+ /// 细进度条:原生 ProgressBar 在浅色主题里是个带灰框的方块,
+ /// 换成圆角轨道 + 主色填充,Minimum/Maximum/Value 与原生同名,可直接替换。
+ internal class ProgressTrack : Control
+ {
+ public int Minimum = 0;
+ public int Maximum = 100;
+
+ private int _value;
+ public int Value
+ {
+ get { return _value; }
+ set
+ {
+ int v = Math.Max(Minimum, Math.Min(value, Maximum));
+ if (v == _value) return;
+ _value = v;
+ Invalidate();
+ }
+ }
+
+ public ProgressTrack()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
+ | ControlStyles.SupportsTransparentBackColor, true);
+ BackColor = Color.Transparent;
+ TabStop = false;
+ Height = Theme.Px(8);
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ int radius = Math.Max(1, Height / 2);
+ Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
+ Theme.Disabled, Theme.Border, radius, Theme.Hair, Theme.BackdropOf(this));
+ int span = Math.Max(1, Maximum - Minimum);
+ int filled = (int)Math.Round((Value - Minimum) * (double)Width / span);
+ if (filled <= 0) return;
+ var old = g.SmoothingMode;
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ using (var path = Theme.RoundRect(new RectangleF(0, 0, Width - 1f, Height - 1f), radius))
+ using (var clip = new Region(path))
+ {
+ g.SetClip(clip, CombineMode.Replace);
+ using (var b = new SolidBrush(Theme.Primary)) g.FillRectangle(b, 0, 0, filled, Height);
+ }
+ g.ResetClip();
+ g.SmoothingMode = old;
+ }
+ }
+
+ /// 圆角输入宿主:装无边框 TextBox/ComboBox,焦点与校验态换描边色。
+ /// 命名避开 Microsoft.TeamFoundation.WorkItemTracking.Client.Field。
+ internal class InputField : Panel
+ {
+ private bool _invalid;
+ private ComboBox _combo;
+ private Chevron _chevron;
+ // 内容容器:整体内缩 2px 并按圆角 Region 裁剪——恰好让开聚焦态 2px 描边带,
+ // 否则白底会切掉描边的内半圈,只剩外圈 AA 残边(视觉上就是一圈毛刺)。
+ // 子控件(尤其被挤出宿主藏箭头的 ComboBox)再怎么越界,也只会被裁在描边圈以内。
+ private Panel _box;
+
+ /// 宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。
+ private int ArrowZone { get { return Theme.Px(26); } }
+
+ public InputField()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
+ // _box 必须先于 Height 创建:设置高度会触发 OnSizeChanged → LayoutInner
+ _box = new Panel { BackColor = Theme.Card, TabStop = false };
+ _box.MouseDown += BoxClicked;
+ Controls.Add(_box);
+ BackColor = Theme.Card;
+ Height = Theme.FieldH;
+ TabStop = false;
+ }
+
+ /// 点在容器空白处(输入框内边距)时把焦点交给内容控件,与原生输入框手感一致。
+ private void BoxClicked(object sender, MouseEventArgs e)
+ {
+ foreach (Control c in _box.Controls)
+ {
+ if (c is TextBoxBase || c is ComboBox) { c.Focus(); break; }
+ }
+ }
+
+ public bool Invalid
+ {
+ get { return _invalid; }
+ set { if (_invalid != value) { _invalid = value; Invalidate(); } }
+ }
+
+ public T Add(T inner, bool mono) where T : Control
+ {
+ var tb = inner as TextBoxBase;
+ if (tb != null)
+ {
+ tb.BorderStyle = BorderStyle.None;
+ tb.Multiline = false;
+ }
+ var cb = inner as ComboBox;
+ if (cb != null)
+ {
+ // net48 的 ComboBox 没有 BorderStyle(.NET Core 才加),只能用 FlatStyle;
+ // 系统那个灰色箭头方块靠不住(Paint 覆盖不掉),改成把控件加宽挤出容器、
+ // 由容器 Region 裁掉它并自绘箭头,点击右区展开下拉。
+ _combo = cb;
+ cb.FlatStyle = FlatStyle.Flat;
+ cb.DrawMode = DrawMode.OwnerDrawFixed;
+ cb.ItemHeight = Theme.Px(22);
+ cb.DropDown += (s, e) => BeginAlignDropDown();
+ cb.HandleCreated += (s, e) => HookComboList();
+ cb.DrawItem += DrawComboItem;
+ _chevron = new Chevron();
+ _chevron.MouseDown += (s, e) => { if (e.Button == MouseButtons.Left) ToggleComboDropDown(); };
+ _box.Controls.Add(_chevron);
+ }
+ inner.BackColor = Theme.Card;
+ inner.Font = mono ? Theme.Mono : Theme.Body;
+ inner.ForeColor = Theme.Ink;
+ _box.Controls.Add(inner);
+ inner.Enter += (s, e) => Invalidate();
+ inner.Leave += (s, e) => Invalidate();
+ LayoutInner();
+ return inner;
+ }
+
+ protected override void OnSizeChanged(EventArgs e) { base.OnSizeChanged(e); LayoutInner(); }
+
+ private void DrawComboItem(object sender, DrawItemEventArgs e)
+ {
+ var cb = (ComboBox)sender;
+ if (e.Index < 0 || e.Index >= cb.Items.Count) return;
+ bool selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
+ bool inBox = (e.State & DrawItemState.ComboBoxEdit) == DrawItemState.ComboBoxEdit;
+ var full = new Rectangle(e.Bounds.X, e.Bounds.Y,
+ Math.Max(e.Bounds.Width, cb.Width - Theme.Px(2)), e.Bounds.Height);
+ if (inBox)
+ {
+ // 闭合态就是普通输入框:白底黑字。焦点/校验态由宿主描边表达,
+ // 不响应 Selected 标记——否则一聚焦整块变浅蓝,还会把旁边的箭头衬成白色方块。
+ using (var b = new SolidBrush(Theme.Card)) e.Graphics.FillRectangle(b, full);
+ TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font,
+ new Rectangle(full.X, full.Y, Math.Max(0, full.Width - ArrowZone), full.Height),
+ Theme.Ink,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
+ | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+ return;
+ }
+ using (var b = new SolidBrush(selected ? Theme.PrimarySoft : Theme.Card))
+ e.Graphics.FillRectangle(b, full);
+ // 列表项贴着自己窗口的左沿,要补一段缩进才和输入框里的文字对齐;
+ // 闭合态的组合框本身就摆在宿主内缩处,原生 bounds 已经带了这个缩进。
+ int pad = inBox ? 0 : Theme.Px(10);
+ int right = inBox ? ArrowZone : Theme.Px(6);
+ var text = new Rectangle(full.X + pad, full.Y,
+ Math.Max(0, full.Width - pad - right), full.Height);
+ TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font, text,
+ selected ? Theme.Primary : Theme.Ink,
+ TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
+ | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
+ }
+
+ private void LayoutInner()
+ {
+ if (_box == null) return; // 构造中途触发 OnSizeChanged 时容器可能还没建好
+ _box.SetBounds(2, 2, Width - 4, Height - 4);
+ SetBoxRegion();
+ foreach (Control c in _box.Controls)
+ {
+ if (c is Chevron) continue;
+ var tb = c as TextBox;
+ var cb = c as ComboBox;
+ if (cb != null)
+ {
+ // 右端挤出容器、原生箭头由容器 Region 裁掉并由 Chevron 接管;
+ // 弹出列表的位置与宽度由 ComboListHook 在定位瞬间改写,不受这里加宽影响。
+ int h = Math.Min(Math.Max(cb.PreferredHeight, Theme.Px(18)), _box.Height - Theme.Px(4));
+ cb.SetBounds(Theme.Px(7), Math.Max(Theme.Px(2), (_box.Height - h) / 2),
+ _box.Width - Theme.Px(7) + ArrowZone, h);
+ continue;
+ }
+ int natural = tb != null ? tb.PreferredHeight : Theme.Px(19);
+ int th = Math.Min(Math.Max(natural, Theme.Px(18)), _box.Height - Theme.Px(4));
+ c.SetBounds(Theme.Px(7), Math.Max(Theme.Px(2), (_box.Height - th) / 2), _box.Width - Theme.Px(14), th);
+ }
+ if (_chevron != null)
+ {
+ int size = Theme.Px(18);
+ _chevron.SetBounds(_box.Width - Theme.Px(7) - size, Theme.Px(2), size, _box.Height - Theme.Px(4));
+ _chevron.BringToFront(); // 后加的兄弟控件在底层,必须提到组合框上方才看得见
+ }
+ Invalidate();
+ }
+
+ /// 圆角 Region 跟着尺寸重建。Region 比容器再内缩 1px:聚焦描边是 2px 内嵌线,
+ /// AA 过渡会落到距宿主边缘第 3 列上,白底必须留出这条过渡带,否则内沿被切成硬边(毛刺)。
+ /// 内容控件摆放在第 9 列起,不受这条内缩影响。
+ private void SetBoxRegion()
+ {
+ using (var path = Theme.RoundRect(
+ new RectangleF(1, 1, _box.Width - 2f, _box.Height - 2f), Math.Max(2, Theme.RadiusCtl - 3)))
+ {
+ if (_box.Region != null) _box.Region.Dispose();
+ _box.Region = new Region(path);
+ }
+ }
+
+ /// 宿主自绘的下拉箭头,同时是点击展开的热区(系统的灰箭头已被裁掉)。
+ private class Chevron : Control
+ {
+ public Chevron()
+ {
+ SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
+ | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw
+ | ControlStyles.SupportsTransparentBackColor, true);
+ BackColor = Theme.Card;
+ TabStop = false;
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ int size = Theme.Px(11);
+ Icons.Draw(e.Graphics, IconKind.ChevronDown,
+ new Rectangle((Width - size) / 2, (Height - size) / 2, size, size), Theme.Ink2);
+ }
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+ if (_combo == null || e.Button != MouseButtons.Left) return;
+ if (e.X < Width - ArrowZone) return;
+ ToggleComboDropDown();
+ }
+
+ // net48 没有 ComboBox.Dropped(.NET Core 才有),只能走 CB_SHOWDROPDOWN
+ private const int CB_GETDROPPEDSTATE = 0x108B;
+ private const int CB_SHOWDROPDOWN = 0x014F;
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto)]
+ private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
+
+ private void ToggleComboDropDown()
+ {
+ if (!_combo.IsHandleCreated) return;
+ bool dropped = SendMessage(_combo.Handle, CB_GETDROPPEDSTATE, IntPtr.Zero, IntPtr.Zero) != IntPtr.Zero;
+ SendMessage(_combo.Handle, CB_SHOWDROPDOWN, new IntPtr(dropped ? 0 : 1), IntPtr.Zero);
+ }
+
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
+ private struct RECT
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
+ private struct ComboInfo
+ {
+ public int cbSize;
+ public RECT rcItem;
+ public RECT rcButton;
+ public int cbState;
+ public IntPtr hwndCombo;
+ public IntPtr hwndEdit;
+ public IntPtr hwndList;
+ }
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool GetComboBoxInfo(IntPtr hWnd, ref ComboInfo info);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern bool SetWindowPos(IntPtr hWnd, IntPtr after,
+ int x, int y, int cx, int cy, uint flags);
+
+ private const uint SWP_NOZORDER = 0x4;
+ private const uint SWP_NOACTIVATE = 0x10;
+ private const uint SWP_FRAMECHANGED = 0x20;
+
+ private const int GWL_STYLE = -16;
+ private const int GWL_EXSTYLE = -20;
+ private const int WS_BORDER = 0x00800000;
+ private const int WS_EX_CLIENTEDGE = 0x200;
+
+ private const int WM_WINDOWPOSCHANGING = 0x0046;
+ private const uint SWP_NOSIZE_FLAG = 0x1;
+ private const uint SWP_NOMOVE_FLAG = 0x2;
+
+ [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
+ private struct WINDOWPOS
+ {
+ public IntPtr hwnd;
+ public IntPtr hwndInsertAfter;
+ public int x;
+ public int y;
+ public int cx;
+ public int cy;
+ public uint flags;
+ }
+
+ /// 挂在弹出列表窗口上,在系统定位的瞬间(WM_WINDOWPOSCHANGING)就把矩形改写成
+ /// 贴齐宿主——原生代码按组合框自身矩形定位,而组合框为藏箭头是挤出宿主的,
+ /// 不拦截的话下拉会先出现在偏右偏宽的位置、等 BeginInvoke 再左移,肉眼可见地跳一下。
+ private sealed class ComboListHook : NativeWindow
+ {
+ private readonly InputField _owner;
+
+ public ComboListHook(InputField owner) { _owner = owner; }
+
+ protected override void WndProc(ref Message m)
+ {
+ if (m.Msg == WM_WINDOWPOSCHANGING && _owner != null && _owner.IsHandleCreated)
+ {
+ var wp = (WINDOWPOS)System.Runtime.InteropServices.Marshal.PtrToStructure(
+ m.LParam, typeof(WINDOWPOS));
+ var host = _owner.RectangleToScreen(_owner.ClientRectangle);
+ if ((wp.flags & SWP_NOMOVE_FLAG) == 0) { wp.x = host.Left; wp.y = host.Bottom; }
+ if ((wp.flags & SWP_NOSIZE_FLAG) == 0) wp.cx = host.Width;
+ System.Runtime.InteropServices.Marshal.StructureToPtr(wp, m.LParam, false);
+ }
+ base.WndProc(ref m);
+ }
+ }
+
+ private ComboListHook _listHook;
+
+ /// ComboLBox 随组合框句柄一起创建,尽早挂上钩子,第一次下拉就不跳。
+ private void HookComboList()
+ {
+ if (_listHook != null || _combo == null || !_combo.IsHandleCreated) return;
+ var info = new ComboInfo();
+ info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
+ if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
+ _listHook = new ComboListHook(this);
+ _listHook.AssignHandle(info.hwndList);
+ }
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLongW")]
+ private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "SetWindowLongW")]
+ private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
+
+ /// 把弹出列表重摆到宿主的外沿:列表窗口默认跟着组合框自己的矩形走,
+ /// 而组合框为了藏原生箭头是加宽挤出宿主的,不重摆就会右移并比输入框宽一截。
+ /// 顺带剥掉列表窗口的系统边框(WS_BORDER / WS_EX_CLIENTEDGE),
+ /// 让下拉跟扁平输入框同一套视觉,宽度与宿主完全一致。
+ /// DropDown 事件里列表窗口还没摆好(原生代码随后才定位),投递到本条消息之后再改。
+ private void BeginAlignDropDown()
+ {
+ if (!IsHandleCreated || _combo == null) return;
+ BeginInvoke((Action)AlignDropDown);
+ }
+
+ private void AlignDropDown()
+ {
+ if (_combo == null || !_combo.IsHandleCreated) return;
+ HookComboList(); // 兜底:钩子没挂上时先补挂
+ var info = new ComboInfo();
+ info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
+ if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
+ int style = GetWindowLong(info.hwndList, GWL_STYLE);
+ if ((style & WS_BORDER) != 0)
+ SetWindowLong(info.hwndList, GWL_STYLE, style & ~WS_BORDER);
+ int ex = GetWindowLong(info.hwndList, GWL_EXSTYLE);
+ if ((ex & WS_EX_CLIENTEDGE) != 0)
+ SetWindowLong(info.hwndList, GWL_EXSTYLE, ex & ~WS_EX_CLIENTEDGE);
+ RECT r;
+ if (!GetWindowRect(info.hwndList, out r)) return;
+ var host = RectangleToScreen(ClientRectangle);
+ SetWindowPos(info.hwndList, IntPtr.Zero, host.Left, host.Bottom,
+ host.Width, r.Bottom - r.Top, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ var g = e.Graphics;
+ bool focused = ContainsFocus; // 输入控件嵌在 _box 里,必须递归判断
+ var color = _invalid ? Theme.Danger : (focused ? Theme.Primary : Theme.BorderStrong);
+ Theme.FillPanel(g, new Rectangle(0, 0, Width, Height),
+ Theme.Card, color, Theme.RadiusCtl, focused || _invalid ? Theme.HairStrong : Theme.Hair,
+ Theme.BackdropOf(this));
+ }
+ }
+
+ internal enum LogLevel { Info, Ok, Skip, Warn, Fail }
+
+ ///
+ /// 分级着色日志控制台。级别按文本内容自动判定,所以 TfsClient 里传进来的 Action<string> 不用改签名。
+ /// 继承 RichTextBox 但不改其窗口样式:原生控件自绘会破坏滚动与选区。
+ ///
+ internal class LogView : RichTextBox
+ {
+ public bool AutoScrollToBottom = true;
+
+ public LogView()
+ {
+ ReadOnly = true;
+ BorderStyle = BorderStyle.None;
+ BackColor = Theme.LogBg;
+ ForeColor = Theme.Ink;
+ Font = Theme.Mono;
+ DetectUrls = false;
+ ScrollBars = RichTextBoxScrollBars.Vertical;
+ Multiline = true;
+ TabStop = false;
+ WordWrap = true;
+ }
+
+ public static LogLevel Classify(string msg)
+ {
+ if (string.IsNullOrEmpty(msg)) return LogLevel.Info;
+ if (msg.IndexOf("FAIL", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
+ if (msg.IndexOf("失败", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
+ if (msg.IndexOf("错误", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
+ if (msg.IndexOf("中断", StringComparison.Ordinal) >= 0) return LogLevel.Fail;
+ if (msg.IndexOf("SKIP", StringComparison.Ordinal) >= 0) return LogLevel.Skip;
+ if (msg.IndexOf("警告", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
+ if (msg.IndexOf("不可用", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
+ if (msg.IndexOf("部分", StringComparison.Ordinal) >= 0) return LogLevel.Warn;
+ if (msg.IndexOf("] OK", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
+ if (msg.StartsWith("连接成功", StringComparison.Ordinal)) return LogLevel.Ok;
+ if (msg.IndexOf("完成", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
+ if (msg.IndexOf("已加载", StringComparison.Ordinal) >= 0) return LogLevel.Ok;
+ return LogLevel.Info;
+ }
+
+ private Color InkOf(LogLevel lvl)
+ {
+ switch (lvl)
+ {
+ case LogLevel.Ok: return Theme.Ok;
+ case LogLevel.Skip: return Theme.Accent;
+ case LogLevel.Warn: return Theme.Accent;
+ case LogLevel.Fail: return Theme.Danger;
+ default: return Theme.Ink;
+ }
+ }
+
+ /// 最后一行当前是否可见——可见才跟随滚动,用户往上翻看时不打断。
+ private bool LastLineVisible()
+ {
+ if (TextLength == 0) return true;
+ int idx = GetCharIndexFromPosition(new Point(0, ClientSize.Height - 4));
+ return GetLineFromCharIndex(Math.Max(0, idx)) >= GetLineFromCharIndex(TextLength);
+ }
+
+ public void AppendLine(string time, LogLevel lvl, string msg)
+ {
+ if (InvokeRequired) { BeginInvoke(new Action(() => AppendLine(time, lvl, msg))); return; }
+ bool follow = AutoScrollToBottom && LastLineVisible();
+ SelectionStart = TextLength;
+ SelectionLength = 0;
+ SelectionColor = Theme.Ink3;
+ SelectedText = "[" + time + "] ";
+ SelectionColor = InkOf(lvl);
+ SelectedText = msg;
+ SelectionColor = Theme.Ink;
+ SelectedText = Environment.NewLine;
+ if (follow) { SelectionStart = TextLength; ScrollToCaret(); }
+ }
+
+ public void ClearLog()
+ {
+ if (InvokeRequired) { BeginInvoke(new Action(ClearLog)); return; }
+ Clear();
+ }
+ }
+
+ internal static class GridStyle
+ {
+ public static void Apply(DataGridView g)
+ {
+ g.BackgroundColor = Theme.Card;
+ g.BorderStyle = BorderStyle.None;
+ g.GridColor = Color.FromArgb(0xEE, 0xF1, 0xF6);
+ g.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal;
+ g.EnableHeadersVisualStyles = false;
+ g.RowHeadersVisible = false;
+ g.AllowUserToAddRows = false;
+ g.AllowUserToDeleteRows = false;
+ g.AllowUserToResizeRows = false;
+ g.ReadOnly = true;
+ g.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+ g.MultiSelect = false;
+ g.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells;
+ g.RowTemplate.Height = Theme.Px(28);
+ g.ColumnHeadersHeight = Theme.Px(32);
+ g.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
+ g.DefaultCellStyle.BackColor = Theme.Card;
+ g.DefaultCellStyle.ForeColor = Theme.Ink;
+ g.DefaultCellStyle.SelectionBackColor = Theme.PrimarySoft;
+ g.DefaultCellStyle.SelectionForeColor = Theme.Ink;
+ g.DefaultCellStyle.Font = Theme.Body;
+ g.DefaultCellStyle.Padding = new Padding(Theme.Px(6), 0, Theme.Px(6), 0);
+ g.AlternatingRowsDefaultCellStyle.BackColor = Theme.RowAlt;
+ g.AlternatingRowsDefaultCellStyle.SelectionBackColor = Theme.PrimarySoft;
+ g.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(0xF7, 0xF9, 0xFC);
+ g.ColumnHeadersDefaultCellStyle.ForeColor = Theme.Ink2;
+ g.ColumnHeadersDefaultCellStyle.Font = Theme.CaptionBold;
+ g.ColumnHeadersDefaultCellStyle.SelectionBackColor = Color.FromArgb(0xF7, 0xF9, 0xFC);
+ g.ColumnHeadersDefaultCellStyle.SelectionForeColor = Theme.Ink2;
+ g.ColumnHeadersDefaultCellStyle.Padding = new Padding(Theme.Px(6), 0, Theme.Px(6), 0);
+ g.ColumnHeadersDefaultCellStyle.WrapMode = DataGridViewTriState.False;
+ g.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None;
+ }
+ }
+}
diff --git a/TfsExcelImporter/app.manifest b/TfsExcelImporter/app.manifest
new file mode 100644
index 0000000..ca13365
--- /dev/null
+++ b/TfsExcelImporter/app.manifest
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+ true
+ System
+
+
+
+
+
+
+
+
+
+
diff --git a/debug.log b/debug.log
new file mode 100644
index 0000000..ebc7015
--- /dev/null
+++ b/debug.log
@@ -0,0 +1,2 @@
+[1101/171941.143:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)
+[1101/173900.093:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 系统找不到指定的文件。 (0x2)