- 更新 README.md,明确父 PBI Id 的选择方式,增加加载 PBI 列表的功能,提升用户在导入时的便利性。 - 在 MainForm.cs 中添加加载 PBI 列表的按钮,优化父项选择逻辑,确保用户能够方便地选择合适的父项。 - 修改 MainFormUi.cs,将父 PBI Id 输入框替换为下拉框,支持从列表中选择,提升用户体验。 - 在 TfsClient.cs 中实现获取父项候选的逻辑,确保用户能够根据项目和迭代路径获取相关的工作项信息。 - 调整界面布局,确保父 PBI 选择与其他输入项一致,提升整体视觉效果。
1105 lines
51 KiB
C#
1105 lines
51 KiB
C#
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(); }
|
||
}
|
||
}
|