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 TextBox _txtUser = new TextBox(); private TextBox _txtPwd = new TextBox { UseSystemPasswordChar = true }; private ComboBox _cmbIteration = new ComboBox(); private ComboBox _cmbArea = new ComboBox(); private TextBox _txtExcel = new TextBox(); private TextBox _txtParentPbi = new TextBox(); private Button _btnLoadIterations = new Button(); private Button _btnLoadAreas = new Button(); private Button _btnTemplate = new Button(); private CheckBox _chkSkip = 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, _fUser, _fPwd, _fIteration, _fExcel, _fParent, _fArea; private Label _errIteration, _errExcel, _errParent, _errArea; private Label[] _connRowCaptions; // 第 1 步第二行的标签(重排时要跟着一起摆) private InputField[] _connRowFields; // 同排的四个输入框 private int _connRowY; 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 = ""; /// 上一次真正执行 Relayout 的客户区尺寸:尺寸没变就不重排(还原/激活时 WM_SIZE 会重发一次)。 private Size _laidOutSize; /// 需要在窗口拉宽时跟着变宽的控件。 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) => { // 最小化会收到 WM_SIZE(SIZE_MINIMIZED) 加一个假尺寸(实测 237x39),还原时尺寸又回到原值: // 这两次重排都白干——最小化那次把 80 多个控件重摆到 237x39 上,还原那次再摆回去, // 而还原时窗口已经可见,UI 线程却卡在重排里(实测 123ms)不出像素,屏幕上就是一片黑。 if (WindowState == FormWindowState.Minimized) return; if (ClientSize == _laidOutSize) return; 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.Border, FaceColor = Theme.Card, Height = Theme.Px(44) }; AddRibbonGroup("服务器", true); AddRibbonGroup("项目", true); AddRibbonGroup("工作项", false); _chipConn = new StatusChip { BackColor = Theme.Card }; _ribbon.Controls.Add(_chipConn); } /// 一组「标题 + 值」;值文本会变长,所以位置每次重排时按实测宽度算。 private void AddRibbonGroup(string caption, bool withSeparator) { var cap = new Label { Text = caption, Font = Theme.Caption, ForeColor = Theme.Ink3, AutoSize = true, BackColor = Theme.Card }; var val = new Label { Text = "", Font = Theme.BodyBold, ForeColor = Theme.Ink, AutoSize = true, BackColor = Theme.Card }; _ribbon.Controls.Add(cap); _ribbon.Controls.Add(val); Panel sep = null; if (withSeparator) { sep = new Panel { BackColor = Theme.Border }; _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; Theme.Place(cap, x, (_ribbon.Height - cap.PreferredHeight) / 2, cap.PreferredWidth, cap.PreferredHeight); x += cap.Width + Theme.Px(6); Theme.Place(val, x, (_ribbon.Height - val.PreferredHeight) / 2, val.PreferredWidth, val.PreferredHeight); x += val.Width + Theme.Px(12); if (sep == null) continue; Theme.Place(sep, x - Theme.Px(8), (_ribbon.Height - Theme.Px(16)) / 2, 1, Theme.Px(16)); x += Theme.Px(5); } Theme.Place(_chipConn, _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)); _fUrl = NewField(_txtUrl, 620, true); _fProject = NewField(_txtProject, 150, false); _fType = NewField(_txtType, 150, false); _fUser = NewField(_txtUser, 150, false); _fPwd = NewField(_txtPwd, 150, true); // 空态占位提示(cue banner):只在该输入框没填内容时显示。 // 用户/密码不设提示:这两格只有 150 设计px 宽,长句必被裁断,留空即可用当前 Windows 身份。 _fUrl.SetCue("如 http://server:8080/tfs/DefaultCollection"); _fProject.SetCue("如 Tools_Dev"); _fType.SetCue("如 Task"); _btnTest = NewButton("测试连接", BtnKind.Secondary, IconKind.Bolt, 130); int y = _cardConn.HeaderH + Theme.S12; y = Row(_cardConn, y, 42, new Lbl("服务器 URL", MaxLabelWidth("服务器 URL")), _fUrl); Track(_fUrl, _cardConn, M, 0); // 第二行:项目 / 类型 / 用户 / 密码 + 测试连接同排,四个输入框等宽、整行铺满卡片内容区。 // 标签自建并留引用——窗口变宽时要连标签一起重摆,Row() 建完就丢引用做不到。 _connRowY = y; var captions = new[] { "团队项目", "工作项类型", "用户", "密码" }; _connRowCaptions = new Label[captions.Length]; _connRowFields = new[] { _fProject, _fType, _fUser, _fPwd }; for (int i = 0; i < captions.Length; i++) { // 标签宽度由布局按各自文字量给(不死板地共用一条定宽列):四个标签字数不同 // (2~5 字),共用定宽列会让「用户」「密码」和自己的输入框之间空出一大截。 var lbl = new Label { Text = captions[i], Font = Theme.Body, ForeColor = Theme.Ink2, AutoSize = false, // 宽度每次重排时按文字量给 TextAlign = ContentAlignment.MiddleRight, // 文字右缘贴住输入框 BackColor = Theme.Card }; _cardConn.Controls.Add(lbl); _connRowCaptions[i] = lbl; _connRowFields[i].AccessibleName = captions[i]; _cardConn.Controls.Add(_connRowFields[i]); } _cardConn.Controls.Add(_btnTest); _layoutExtras.Add(LayoutConnRow); y = _connRowY + Theme.Px(42); _cardConn.BodyH = y - _cardConn.HeaderH + Theme.S8; } /// /// 第 1 步第二行排布:四个输入框同宽,整行(含「测试连接」)刚好铺满卡片内容区。 /// 每个标签只占自己文字的宽度,于是「文字 → 输入框」恒为 6px、「输入框 → 下一个标签」 /// 恒为 S12,四组间距完全一致;省下的宽度全给输入框,输入框因此比按定宽标签列算的更宽。 /// 每次重排按当期卡片宽度重算,缩放/拉伸下都不溢出、不留白。 /// private void LayoutConnRow() { if (_connRowFields == null || _connRowCaptions == null) return; int gap = Theme.S12; // 组与组之间 int lblGap = Theme.Px(6); // 标签文字 → 输入框(全行同一个值) int n = _connRowFields.Length; int avail = _cardConn.Width - M * 2; // 每个标签只占自己文字宽度(+2px 防末字被裁),省下的宽度全部喂给输入框, // 这样「文字→输入框」和「输入框→下一个标签」两处间距在四组上都是同一个值。 var lblW = new int[n]; int textSum = 0; for (int i = 0; i < n; i++) { lblW[i] = Theme.TextWidth(_connRowCaptions[i].Text, Theme.Body) + Theme.Px(2); textSum += lblW[i]; } int boxW = (avail - textSum - n * lblGap - (n - 1) * gap - G - _btnTest.Width) / n; boxW = Math.Max(Theme.Px(96), boxW); int x = M; for (int i = 0; i < n; i++) { Theme.Place(_connRowCaptions[i], x, _connRowY, lblW[i], Theme.FieldH); x += lblW[i] + lblGap; Theme.Place(_connRowFields[i], x, _connRowY, boxW, Theme.FieldH); x += boxW + gap; } Theme.Place(_btnTest, x - gap + G, _connRowY, _btnTest.Width, Theme.BtnH); } // --- 步骤 2:迭代与数据源 --- private void BuildCardData() { _cardData = MakeCard(2, "选择迭代与数据源", true, Theme.Px(44)); _cardData.SummaryDots = true; // 头部摘要按「圆点 + 短语」分段,五项状态一眼可辨 _cmbIteration.DropDownStyle = ComboBoxStyle.DropDown; // 允许手填完整路径 _cmbArea.DropDownStyle = ComboBoxStyle.DropDown; // 同上:列表拿不到时仍能手填 _fIteration = NewField(_cmbIteration, 500, false); _fArea = NewField(_cmbArea, 500, true); _fExcel = NewField(_txtExcel, 500, true); _fParent = NewField(_txtParentPbi, 150, true); _fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1"); // Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法 _fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies"); _fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览"); _fParent.SetCue("如 219975"); _btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130); _btnLoadAreas = NewButton("加载 Area 列表", BtnKind.Secondary, IconKind.Fetch, 130); _btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142); // 行尾两个按钮宽度取齐(取两者较宽的一个):右缘本来就都由 AnchorRight 贴在 M 上, // 宽度一致后左缘也落在同一条竖线上,两个按钮才成为一列;同时两行 Track 给右侧留的 // 宽度变成同一个值,上面两个输入框的右边框才对得齐。 int wBtn = Math.Max(_btnLoadIterations.Width, _btnTemplate.Width); _btnLoadIterations.Width = wBtn; _btnLoadAreas.Width = wBtn; _btnTemplate.Width = wBtn; _chkSkip = NewCheck("跳过已存在标题", ""); // 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里, // 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。 int wLbl = MaxLabelWidth("目标迭代", "Area 路径", "Excel 文件", "父 PBI Id") + Theme.Px(2) + Theme.TextWidth("*", Theme.Body); int y = _cardData.HeaderH + Theme.S12; y = Row(_cardData, y, 52, new Lbl("目标迭代", wLbl, true), _fIteration); AnchorRight(_btnLoadIterations, _cardData, M, _fIteration.Top); Track(_fIteration, _cardData, wBtn + G + M, 0); // Area 与迭代同属「写到哪儿」:紧贴迭代下方,值直接落进 System.AreaPath。 // 行尾同样挂一个列表按钮:Area 树往往比迭代树深得多,手打路径最容易写错。 y = Row(_cardData, y, 52, new Lbl("Area 路径", wLbl, true), _fArea); AnchorRight(_btnLoadAreas, _cardData, M, _fArea.Top); Track(_fArea, _cardData, wBtn + G + M, 0); y = Row(_cardData, y, 52, new Lbl("Excel 文件", wLbl, true), _fExcel); // 设计方案:行内操作(浏览)并入输入框尾部图标,行尾只留右对齐成一列的操作按钮 _fExcel.AddSuffix(IconKind.Folder, BrowseExcel); AnchorRight(_btnTemplate, _cardData, M, _fExcel.Top); Track(_fExcel, _cardData, wBtn + G + M, 0); y = Row(_cardData, y, 42, new Lbl("父 PBI Id", wLbl), _fParent, new Hint("Task 默认挂到该 backlog item;Excel 里加 PBI Id 列可逐行覆盖")); // 勾选项不再贴着标签起头:整体右移到上面三行输入框的左缘(跳过标签列)。 // 只有「去重」这一个开关;字段校验不可绕过(OM 14.x 无 BypassRules),失败时由 // TfsClient 输出可操作的修复提示。 y = Row(_cardData, y, 32, _fParent.Left - M, _chkSkip); _errIteration = AddErrorUnder(_cardData, _fIteration); _errArea = AddErrorUnder(_cardData, _fArea); _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); // 148 已够图标+文字(实际量出约 130),省下的宽度留给右侧状态文字与进度条 _btnImport = NewButton("开始导入 TFS", BtnKind.Primary, IconKind.ArrowUp, 148); // 主/次按钮同高(层级靠主色表达,不再用 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; Theme.Place(_badgeRun, M + Theme.Px(2), cy, badge, badge); _badgeRun.Set(4, _runBadge); Theme.Place(_btnPreview, M + Theme.Px(42), (_actionBar.Height - _btnPreview.Height) / 2, _btnPreview.Width, _btnPreview.Height); Theme.Place(_btnImport, _btnPreview.Right + G, (_actionBar.Height - _btnImport.Height) / 2, _btnImport.Width, _btnImport.Height); Theme.Place(_chipFail, _actionBar.Width - _chipFail.Width - M - Theme.Px(2), cy, _chipFail.Width, _chipFail.Height); Theme.Place(_chipSkip, _chipFail.Left - G - _chipSkip.Width, cy, _chipSkip.Width, _chipSkip.Height); Theme.Place(_chipOk, _chipSkip.Left - G - _chipOk.Width, cy, _chipOk.Width, _chipOk.Height); // 状态文字按最长常规文案实测给宽,且只给一行的高度: // 宽度够了不折行,万一更长的文案也只省略尾巴,不会把计数拆到第二行。 int statW = StatusLabelWidth(); int statH = Theme.Caption.Height + Theme.Px(4); Theme.Place(_lblStatus, _chipOk.Left - statW - G, (_actionBar.Height - statH) / 2, statW, statH); int left = _btnImport.Right + Theme.S16; int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left); Theme.Place(_pbar, left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height); } /// /// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取), /// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」) /// 只省略尾巴——它们的数字在开头,不受影响。 /// private static int StatusLabelWidth() { string[] samples = { "完成 999/999", // 导入结束:只报总数,分布交给右侧胶囊 "补挂完成: 挂接 99 / 已有父跳过 99 / 失败 0" // 补挂父项:最长的一条常规状态 }; int w = 0; foreach (var s in samples) w = Math.Max(w, Theme.TextWidth(s, Theme.Caption)); return Math.Min(Theme.Px(280), Math.Max(Theme.Px(160), w + Theme.Px(8))); } // --- 日志抽屉 --- 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; Theme.Place(_btnOpenReport, x - _btnOpenReport.Width, cy - _btnOpenReport.Height / 2, _btnOpenReport.Width, _btnOpenReport.Height); Theme.Place(_btnClearLog, _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); Theme.Place(_chkAutoScroll, _btnClearLog.Left - chkW - G, cy - chkH / 2, chkW, chkH); }); } // --- 高级工具抽屉 --- private void BuildCardAdv() { _cardAdv = MakeCard(0, "高级工具", true, Theme.Px(36)); _cardAdv.ShowBadge = false; // Area 输入框已上移到第 2 步(必填),这里只剩两个批量修正动作, // 两者都按第 2 步填的「目标迭代 / Area 路径」筛选已有工作项。 _btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158); _btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120); int y = _cardAdv.HeaderH + Theme.S12; y = Row(_cardAdv, y, 34, _btnFixArea, G, _btnLinkParent, G, new Hint("Area 取第 2 步的「Area 路径」,按「目标迭代」筛选;已关闭的工作项会临时重新打开,完成后自动还原状态")); _cardAdv.BodyH = y - _cardAdv.HeaderH + Theme.S8; _cardAdv.SetSummary("按迭代批量修 Area / 补挂父项"); } // ------------------------------------------------------------- 摆放助手 /// 带固定列宽的行内标签:同一张卡片的多行表单用它把字段左缘对齐成一列。 private class Lbl { public readonly string Text; public readonly int MinWidth; // 运行时像素(构建时已含缩放) /// 必填项:标签后补一个红色 *,且列宽里预留它的位置。 public readonly bool Required; public Lbl(string text, int minWidth, bool required = false) { Text = text; MinWidth = minWidth; Required = required; } } /// 紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。 private class Hint { public readonly string Text; public Hint(string text) { Text = text; } } /// 一组标签文字的最大实测宽度,用作对齐列宽。 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); if (spec != null && spec.Required) { // 红色 * 紧跟标签右缘:颜色是这套界面里唯一的「必填」色板(与错误提示同色) var star = new Label { Text = "*", Font = Theme.Body, ForeColor = Theme.Danger, AutoSize = true, BackColor = Theme.Card }; star.SetBounds(x + lbl.PreferredWidth + Theme.Px(1), y + (Theme.FieldH - star.PreferredHeight) / 2, star.PreferredWidth, star.PreferredHeight); card.Controls.Add(star); } 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 hint = item as Hint; if (hint != null) { var hl = new Label { Text = hint.Text, Font = Theme.Caption, ForeColor = Theme.Ink3, AutoSize = true, BackColor = Theme.Card }; hl.SetBounds(x, y + (Theme.FieldH - hl.PreferredHeight) / 2, hl.PreferredWidth, hl.PreferredHeight); card.Controls.Add(hl); x += hl.Width + Theme.S12; 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); Theme.Place(_ribbon, M, M, cw, _ribbon.Height); int y = _ribbon.Bottom + G; foreach (var card in new[] { _cardConn, _cardData }) { Theme.Place(card, M, y, cw, card.Height); y = card.Bottom + G; } // 从底部往上排 int by = ClientSize.Height - M; Theme.Place(_cardAdv, M, by - _cardAdv.Height, cw, _cardAdv.Height); by = _cardAdv.Top - G; Theme.Place(_cardLog, M, by - _cardLog.Height, cw, _cardLog.Height); by = _cardLog.Top - G; Theme.Place(_actionBar, 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; Theme.Place(_cardAdv, M, by - _cardAdv.Height, cw, _cardAdv.Height); by = _cardAdv.Top - G; Theme.Place(_cardLog, M, by - _cardLog.Height, cw, _cardLog.Height); by = _cardLog.Top - G; Theme.Place(_actionBar, M, by - _actionBar.Height, cw, _actionBar.Height); by = _actionBar.Top - G; } Theme.Place(_cardPreview, 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; Theme.Place(st.C, st.X, st.Y, w, h); } foreach (var ra in _rightAnchors) { if (ra.C == null || ra.Owner == null) continue; Theme.Place(ra.C, 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(); _laidOutSize = ClientSize; } /// /// 自绘界面平时不擦背景(AllPaintingInWmPaint + 双缓冲),窗口被重新暴露时那块像素是系统给的黑色。 /// 还原/切前台时完整首帧要一百多毫秒,这段时间就是用户看到的「黑屏一闪」; /// 这里在 WM_ERASEBKGND 上用 GDI 直接把客户区铺成面色,黑底一秒都不露,完整首帧随后补上细节。 /// protected override void WndProc(ref Message m) { if (m.Msg == 0x0014 && Theme.FastErase(m.HWnd, m.WParam, BackColor)) { m.Result = (IntPtr)1; return; } base.WndProc(ref m); } // ------------------------------------------------------------- 状态呈现 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() })); _cardData.SetSummary(string.Join(" · ", new[] { string.IsNullOrEmpty(iter) ? "迭代未选" : iter, string.IsNullOrWhiteSpace(_cmbArea.Text) ? "Area 未填" : "Area " + _cmbArea.Text.Trim(), 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 后在这里核对将要写入的内容"); } /// 第 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; _btnLoadAreas.Enabled = !busy; _btnFixArea.Enabled = !busy; _btnLinkParent.Enabled = !busy; _fExcel.SetSuffixEnabled(!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 well = Theme.Px(48); // 圆形浅灰图标底:裸图标太轻,兜一层「底座」才有空状态的分量 int icon = Theme.Px(22); int textH = Theme.Px(18); int blockH = well + Theme.Px(12) + textH; int top = Math.Max(Theme.Px(4), (Height - blockH) / 2); int wellX = (Width - well) / 2; var old = g.SmoothingMode; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; using (var b = new SolidBrush(Theme.Disabled)) g.FillEllipse(b, wellX, top, well, well); Icons.Draw(g, IconKind.Table, new Rectangle(wellX + (well - icon) / 2, top + (well - icon) / 2, icon, icon), Theme.Ink3); g.SmoothingMode = old; TextRenderer.DrawText(g, Text, Theme.Body, new Rectangle(Theme.Px(16), top + well + Theme.Px(12), 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(); } } }