From dbe8ff2c082be5a81d054beec92b0fa3f68f74a9 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 1 Nov 2026 18:47:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=B3=BB=E7=BB=9F=20?= =?UTF-8?q?DPI=20=E6=84=9F=E7=9F=A5=E6=94=AF=E6=8C=81=E4=B8=8E=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app.manifest 文件以实现系统 DPI 感知,确保界面在不同缩放设置下正常显示。 - 更新 MainForm.cs 和 Theme.cs,调整控件布局和样式以适应 DPI 变化。 - 修改 README.md,增加关于 DPI 感知的说明。 - 新增 MainFormUi.cs,负责界面布局与状态呈现,优化用户体验。 - 新增 debug.log 文件以记录错误信息,便于调试和问题追踪。 --- README.md | 20 +- TfsExcelImporter/MainForm.cs | 482 ++++----- TfsExcelImporter/MainFormUi.cs | 889 ++++++++++++++++ TfsExcelImporter/TfsExcelImporter.csproj | 2 + TfsExcelImporter/Theme.cs | 1225 ++++++++++++++++++++++ TfsExcelImporter/app.manifest | 23 + debug.log | 2 + 7 files changed, 2361 insertions(+), 282 deletions(-) create mode 100644 TfsExcelImporter/MainFormUi.cs create mode 100644 TfsExcelImporter/Theme.cs create mode 100644 TfsExcelImporter/app.manifest create mode 100644 debug.log 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)