Files
TFSImport/TfsExcelImporter/MainFormUi.cs
T
chenshoulin fe23e1c64a feat: 增加父 PBI 列表加载功能与界面优化
- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。
- 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。
- 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。
- 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。
- 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
2026-11-02 13:07:58 +08:00

1105 lines
51 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace TfsExcelImporter
{
/// <summary>
/// 界面外壳:ribbon 状态条 + 步骤卡片(1 连接 / 2 选数据 / 3 预览)+ 操作条 + 日志抽屉 + 高级工具抽屉。
/// 手风琴规则——同时只展开一个细节区,预览表格永远拿得到剩余高度。
/// 只负责摆放控件与状态呈现,TFS/Excel 调用逻辑仍在 MainForm.cs。
/// 所有字面量都是 96dpi 设计值,经 Theme.Px() 按系统缩放换算。
/// </summary>
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 ComboBox _cmbParent = new ComboBox();
private Button _btnLoadIterations = new Button();
private Button _btnLoadAreas = new Button();
private Button _btnLoadPbi = 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<Stretch> _stretches = new List<Stretch>();
private readonly List<RightAnchor> _rightAnchors = new List<RightAnchor>();
private readonly List<Action> _layoutExtras = new List<Action>();
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 = "";
/// <summary>上一次真正执行 Relayout 的客户区尺寸:尺寸没变就不重排(还原/激活时 WM_SIZE 会重发一次)。</summary>
private Size _laidOutSize;
/// <summary>需要在窗口拉宽时跟着变宽的控件。</summary>
private class Stretch
{
public Control C;
public Card Owner;
public int X, Y, Right, Bottom;
public bool FillHeight;
}
/// <summary>贴在卡片右缘的按钮:字段变宽后必须跟着右移,否则会压在字段上。</summary>
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<Card> 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<RibGroup> _ribItems = new List<RibGroup>();
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);
}
/// <summary>一组「标题 + 值」;值文本会变长,所以位置每次重排时按实测宽度算。</summary>
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;
}
/// <summary>
/// 第 1 步第二行排布:四个输入框同宽,整行(含「测试连接」)刚好铺满卡片内容区。
/// 每个标签只占自己文字的宽度,于是「文字 → 输入框」恒为 6px、「输入框 → 下一个标签」
/// 恒为 S12,四组间距完全一致;省下的宽度全给输入框,输入框因此比按定宽标签列算的更宽。
/// 每次重排按当期卡片宽度重算,缩放/拉伸下都不溢出、不留白。
/// </summary>
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; // 同上:列表拿不到时仍能手填
_cmbParent.DropDownStyle = ComboBoxStyle.DropDown; // 可直接输 Id,也可从列表挑
// 不做自定义 DropDownWidth:字段已和迭代同宽,下拉面板跟着控件宽走,
// 形态与「目标迭代」完全一致,也不再有宽度写死后与实际布局对不上的问题。
_fIteration = NewField(_cmbIteration, 500, false);
_fArea = NewField(_cmbArea, 500, true);
_fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_cmbParent, 500, true);
_fIteration.SetCue("选择或手填完整迭代路径,如 Tools_Dev\\Sprint 1");
// Area 现在是必填:留空会导致团队视图看不到新导入的工作项,所以提示里直接给写法
_fArea.SetCue("选择或手填完整 Area 路径,如 Tools_Dev\\DataTechnologies");
_fExcel.SetCue("选择 .xlsx 文件,或点右侧文件夹图标浏览");
// 行尾不再放说明文字,规则并进 cue:字段宽度与迭代一致后,这里放得下完整提示
_fParent.SetCue("选择或手填父 PBI Id,如 219975;Excel 里加 PBI Id 列可逐行覆盖");
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnLoadAreas = NewButton("加载 Area 列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnLoadPbi = NewButton("加载 PBI 列表", 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;
_btnLoadPbi.Width = wBtn;
_btnTemplate.Width = wBtn;
// 四行字段对齐同一条左缘:标签列宽取最大值;必填项的红色 * 也留进列宽里,
// 否则带 * 的行会把输入框往右推,四行左缘就不再是一条竖线。
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);
// 父项行结构与上面三行完全一致:字段吃满宽度 + 行尾按钮右对齐。
// Id 本身不可读,列出来才敢确认挂的是哪条。
y = Row(_cardData, y, 52, new Lbl("父 PBI Id", wLbl), _fParent);
AnchorRight(_btnLoadPbi, _cardData, M, _fParent.Top);
Track(_fParent, _cardData, wBtn + G + M, 0);
// 「跳过已存在标题」不在这张卡片里:它是「开始导入」这个动作的开关,已挪到步骤 4 操作条
// 紧贴主按钮(卡片收起后只看得到一行摘要,勾没勾都看不见,容易被当成「没勾」)。
// 字段校验不可绕过(OM 14.x 无 BypassRules),失败时由 TfsClient 输出可操作的修复提示。
_errIteration = AddErrorUnder(_cardData, _fIteration);
_errArea = AddErrorUnder(_cardData, _fArea);
_errExcel = AddErrorUnder(_cardData, _fExcel);
_errParent = AddErrorUnder(_cardData, _fParent);
_cardData.BodyH = y - _cardData.HeaderH + Theme.S8;
}
/// <summary>错误提示紧贴对应输入框下方;宽度随卡片重排(构建时卡片还没定宽)。</summary>
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 拔高),避免操作条里两个按钮大小不齐
// 去重开关紧贴主按钮右侧:它是「开始导入」这一个动作的开关(一次导入只在开始时读一次),
// 挂在按钮旁边才不会被误读成某个字段的属性。
_chkSkip = NewCheck("跳过已存在标题", "");
_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(_chkSkip);
_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);
// 去重开关:与两个按钮同一条中线,纵向按勾选框自身高度居中
int chkW, chkH;
CheckBoxSize(_chkSkip, Theme.Body, out chkW, out chkH);
Theme.Place(_chkSkip, _btnImport.Right + Theme.S16, (_actionBar.Height - chkH) / 2, chkW, chkH);
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);
// 进度条至少要 120px 才像一条进度条(再窄就只是一段杂线):先按最长常规文案给状态文字留宽,
// 宽度不够时再收窄状态文字(下限 120)——长文案本来就靠省略尾巴兜底,关键是别把进度条挤没。
int left = _chkSkip.Right + Theme.S16;
int statRoom = _chipOk.Left - G - left - Theme.Px(120) - G;
int statW = Math.Min(StatusLabelWidth(), Math.Max(Theme.Px(120), statRoom));
int statH = Theme.Caption.Height + Theme.Px(4);
Theme.Place(_lblStatus, _chipOk.Left - G - statW, (_actionBar.Height - statH) / 2, statW, statH);
int barW = Math.Max(Theme.Px(40), _lblStatus.Left - G - left);
Theme.Place(_pbar, left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
}
/// <summary>
/// net48 的 CheckBox 没有 PreferredWidth/Height 的稳定值:宽度自己量(文字 + 勾选框),
/// 高度取原生 PreferredHeight 并留 18px 下限,调用方据此按中线精确居中。
/// </summary>
private static void CheckBoxSize(CheckBox chk, Font font, out int width, out int height)
{
var pref = chk.GetPreferredSize(Size.Empty);
height = Math.Max(Theme.Px(18), pref.Height);
width = Math.Max(Theme.TextWidth(chk.Text, font) + Theme.Px(22), pref.Width);
}
/// <summary>
/// 操作条右侧状态文字的最大显示宽度:取各操作最长文案的实测宽度(160~280 之间夹取),
/// 保证常规状态既不折行、也不省略尾部计数。更长的文案(如「Area 修正完成…(详见日志)」)
/// 只省略尾巴——它们的数字在开头,不受影响。
/// </summary>
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:宽高都由 CheckBoxSize 量出来,整体压在中线上
int chkW, chkH;
CheckBoxSize(_chkAutoScroll, Theme.Caption, out chkW, out chkH);
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 / 补挂父项");
}
// ------------------------------------------------------------- 摆放助手
/// <summary>带固定列宽的行内标签:同一张卡片的多行表单用它把字段左缘对齐成一列。</summary>
private class Lbl
{
public readonly string Text;
public readonly int MinWidth; // 运行时像素(构建时已含缩放)
/// <summary>必填项:标签后补一个红色 *,且列宽里预留它的位置。</summary>
public readonly bool Required;
public Lbl(string text, int minWidth, bool required = false)
{
Text = text;
MinWidth = minWidth;
Required = required;
}
}
/// <summary>紧跟在字段后面的灰色说明(Caption 字号 + Ink3):非标签、不参与标签列宽计算。</summary>
private class Hint
{
public readonly string Text;
public Hint(string text) { Text = text; }
}
/// <summary>一组标签文字的最大实测宽度,用作对齐列宽。</summary>
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;
}
/// <summary>行内元素:string=标签,Lbl=定宽标签,int=已换算好的间距,Control=控件(宽度取控件自身设置)。</summary>
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;
}
/// <summary>复选框后跟随的灰色说明:与复选框同批显示/隐藏。</summary>
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
};
}
/// <summary>横向随窗口变宽(X/Y 取控件摆放时的位置,Right 是留给右侧按钮的固定宽度)。</summary>
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;
}
/// <summary>控件右缘贴卡片右内边距,FromRight 为距右缘的固定量。</summary>
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;
/// <summary>手风琴:只展开指定细节区,其余收成一行摘要。</summary>
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;
}
/// <summary>
/// 自绘界面平时不擦背景(AllPaintingInWmPaint + 双缓冲),窗口被重新暴露时那块像素是系统给的黑色。
/// 还原/切前台时完整首帧要一百多毫秒,这段时间就是用户看到的「黑屏一闪」;
/// 这里在 WM_ERASEBKGND 上用 GDI 直接把客户区铺成面色,黑底一秒都不露,完整首帧随后补上细节。
/// </summary>
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(_cmbParent.Text) ? "父 PBI 未填" : "父 PBI #" + _cmbParent.Text.Trim()
}));
_cardPreview.SetSummary(_rows.Count > 0
? "共 " + _rows.Count + " 行,可直接开始导入"
: "选择 Excel 后在这里核对将要写入的内容");
}
/// <summary>第 4 步徽标:待办 / 进行中 / 完成 / 有失败。</summary>
private void SetRunBadge(BadgeKind kind)
{
_runBadge = kind;
_badgeRun.Set(4, kind);
}
/// <summary>绑定后调一次:长文本列吃剩余宽度,数字列等宽右对齐,表头不换行。</summary>
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;
_btnLoadPbi.Enabled = !busy;
_btnFixArea.Enabled = !busy;
_btnLinkParent.Enabled = !busy;
// 去重开关在导入开始时只读一次:跑起来再改会误导(看着改了,本轮其实不生效),直接置灰
_chkSkip.Enabled = !busy;
_fExcel.SetSuffixEnabled(!busy); // 「浏览」已并入输入框尾部,随忙等一起禁用
_btnTemplate.Enabled = !busy;
}
}
/// <summary>预览空状态:表格矢量图标 + 提示文字,整块在预览区居中,替代单行灰字。</summary>
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(); }
}
}