Files
TFSImport/TfsExcelImporter/MainFormUi.cs
T
chenshoulin d055b415fa feat: 增强便携版支持与配置管理
- 更新 .gitignore 文件,添加便携版分发目录和 Fody 生成的配置文件。
- 修改 README.md,详细说明便携版的构建与分发流程,强调单文件分发的优势。
- 在 Config.cs 中实现便携配置管理,优先使用 exe 同目录的 config.json,提升用户体验。
- 更新 ExcelService.cs,优化默认模板的导出逻辑,支持现场替换模板文件。
- 在 MainForm.cs 中加载应用图标,提升界面一致性。
- 调整 Program.cs,确保在单文件分发时正确加载 native DLL。
2026-11-01 20:17:56 +08:00

911 lines
39 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 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<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>需要在窗口拉宽时跟着变宽的控件。</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) => 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.PrimarySoftBorder,
FaceColor = Theme.PrimarySoft,
Height = Theme.Px(44)
};
AddRibbonGroup("服务器", true);
AddRibbonGroup("项目", true);
AddRibbonGroup("工作项", false);
_chipConn = new StatusChip { BackColor = Theme.PrimarySoft };
_ribbon.Controls.Add(_chipConn);
}
/// <summary>一组「标题 + 值」;值文本会变长,所以位置每次重排时按实测宽度算。</summary>
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.Fetch, 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;
}
/// <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 拔高),避免操作条里两个按钮大小不齐
_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 = StatusLabelWidth();
int statH = Theme.Caption.Height + Theme.Px(4);
_lblStatus.SetBounds(_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);
_pbar.SetBounds(left, (_actionBar.Height - _pbar.Height) / 2, barW, _pbar.Height);
}
/// <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;
_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.Tree, 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;
}
// ------------------------------------------------------------- 摆放助手
/// <summary>带固定列宽的行内标签:同一张卡片的多行表单用它把字段左缘对齐成一列。</summary>
private class Lbl
{
public readonly string Text;
public readonly int MinWidth; // 运行时像素(构建时已含缩放)
public Lbl(string text, int minWidth) { Text = text; MinWidth = minWidth; }
}
/// <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);
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;
}
/// <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);
_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());
}
/// <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;
_btnFixArea.Enabled = !busy;
_btnLinkParent.Enabled = !busy;
_btnBrowse.Enabled = !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 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(); }
}
}