fix: 更新图标和按钮样式以提升用户界面一致性

- 在 MainFormUi.cs 中,将“加载迭代列表”按钮的图标更改为 Fetch,更新“补挂父项”按钮的图标为 Tree,以增强视觉一致性。
- 在 Theme.cs 中,新增 Fetch 和 Tree 图标类型,并优化现有图标的绘制逻辑,确保在不同尺寸下的清晰度。
- debug.log 文件中添加了新的错误记录,便于后续调试和问题追踪。
This commit is contained in:
chenshoulin
2026-11-01 19:34:17 +08:00
parent dbe8ff2c08
commit afed57047a
3 changed files with 164 additions and 53 deletions
+2 -2
View File
@@ -276,7 +276,7 @@ namespace TfsExcelImporter
_fExcel = NewField(_txtExcel, 500, true);
_fParent = NewField(_txtParentPbi, 150, true);
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Sync, 130);
_btnLoadIterations = NewButton("加载迭代列表", BtnKind.Secondary, IconKind.Fetch, 130);
_btnBrowse = NewButton("浏览…", BtnKind.Secondary, IconKind.Folder, 92);
_btnTemplate = NewButton("导出 Excel 模板", BtnKind.Secondary, IconKind.Doc, 142);
_chkSkip = NewCheck("跳过已存在标题", "");
@@ -454,7 +454,7 @@ namespace TfsExcelImporter
_cardAdv.ShowBadge = false;
_fArea = NewField(_txtAreaPath, 420, true);
_btnFixArea = NewButton("按迭代修 Area", BtnKind.Secondary, IconKind.Wrench, 158);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.ArrowUp, 120);
_btnLinkParent = NewButton("补挂父项", BtnKind.Secondary, IconKind.Tree, 120);
int y = _cardAdv.HeaderH + Theme.S12;
y = Row(_cardAdv, y, 52, "Area 路径", _fArea, "改 Area 与补挂父项都按上面的「目标迭代」筛选已有工作项");
+160 -51
View File
@@ -199,10 +199,14 @@ namespace TfsExcelImporter
internal enum IconKind
{
None, ChevronDown, ChevronRight, Check, Bolt, Folder, Doc, ArrowUp, Wrench, Trash, Table, Gear, Sync
None, ChevronDown, ChevronRight, Check, Bolt, Folder, Doc, ArrowUp, Wrench, Trash, Table, Gear, Fetch, Tree
}
/// <summary>GDI+ 矢量图标:统一 24 网格、约 1.6 描边、圆头。界面内不用 emoji、不用位图。</summary>
/// <summary>
/// GDI+ 矢量图标:统一 24 网格、约 2 描边、圆头圆角。界面内不用 emoji、不用位图。
/// 这条流水线的图标实际只有 14~16px,所以几何一律「少笔画、留大间隙」:
/// 细节(桶身竖纹、齿轮齿)在小尺寸会糊成一坨,宁可省略也不用 2 个网格以内的短笔画。
/// </summary>
internal static class Icons
{
public static void Draw(Graphics g, IconKind kind, Rectangle box, Color color)
@@ -215,7 +219,10 @@ namespace TfsExcelImporter
float ox = box.X + (box.Width - side) / 2f;
float oy = box.Y + (box.Height - side) / 2f;
using (var pen = new Pen(color, Math.Max(1.2f, 1.7f * k)))
// 描边随尺寸线性走(24 网格 2px),最小 1.35 保证 14px 下不断线
float stroke = Math.Max(1.35f, 2f * k);
using (var pen = new Pen(color, stroke))
using (var brush = new SolidBrush(color))
{
pen.StartCap = LineCap.Round;
pen.EndCap = LineCap.Round;
@@ -223,69 +230,127 @@ namespace TfsExcelImporter
switch (kind)
{
case IconKind.ChevronDown:
Poly(g, pen, ox, oy, k, 6, 9, 12, 15, 18, 9);
Poly(g, pen, ox, oy, k, 6.2f, 9.2f, 12f, 15f, 17.8f, 9.2f);
break;
case IconKind.ChevronRight:
Poly(g, pen, ox, oy, k, 9, 6, 15, 12, 9, 18);
Poly(g, pen, ox, oy, k, 9.2f, 6.2f, 15f, 12f, 9.2f, 17.8f);
break;
case IconKind.Check:
Poly(g, pen, ox, oy, k, 5, 13, 10, 18, 19, 6);
Poly(g, pen, ox, oy, k, 4.8f, 12.6f, 9.6f, 17.4f, 19.2f, 6.6f);
break;
case IconKind.Bolt:
Poly(g, pen, ox, oy, k, 13, 3, 6, 13, 12, 13, 11, 21, 18, 11, 12, 11, 13, 3);
case IconKind.Bolt: // 实心:14px 下描边闪电会糊成一根棍
Fill(g, brush, ox, oy, k, 13.2f, 2.4f, 4.8f, 13.4f, 10.4f, 13.4f,
10.8f, 21.6f, 19.2f, 10.6f, 13.6f, 10.6f);
break;
case IconKind.Folder:
Poly(g, pen, ox, oy, k, 3, 7, 9, 7, 11, 9, 21, 9, 21, 19, 3, 19, 3, 7);
Poly(g, pen, ox, oy, k, 2.8f, 6.4f, 9.4f, 6.4f, 11.4f, 8.6f,
21.2f, 8.6f, 21.2f, 18.8f, 2.8f, 18.8f, 2.8f, 6.4f);
break;
case IconKind.Doc:
Poly(g, pen, ox, oy, k, 6, 3, 15, 3, 19, 7, 19, 21, 6, 21, 6, 3);
Poly(g, pen, ox, oy, k, 15, 3, 15, 7, 19, 7);
Line(g, pen, ox, oy, k, 9, 12, 16, 12);
Line(g, pen, ox, oy, k, 9, 16, 16, 16);
Poly(g, pen, ox, oy, k, 5.8f, 3f, 14.6f, 3f, 19f, 7.4f, 19f, 21f, 5.8f, 21f, 5.8f, 3f);
Poly(g, pen, ox, oy, k, 14.6f, 3f, 14.6f, 7.4f, 19f, 7.4f);
Line(g, pen, ox, oy, k, 8.8f, 12.2f, 16f, 12.2f);
Line(g, pen, ox, oy, k, 8.8f, 16.2f, 16f, 16.2f);
break;
case IconKind.ArrowUp:
Line(g, pen, ox, oy, k, 12, 20, 12, 5);
Poly(g, pen, ox, oy, k, 5, 12, 12, 5, 19, 12);
Line(g, pen, ox, oy, k, 12f, 20.4f, 12f, 5.2f);
Poly(g, pen, ox, oy, k, 5.8f, 11.4f, 12f, 5.2f, 18.2f, 11.4f);
break;
case IconKind.Wrench:
g.DrawArc(pen, ox + 3 * k, oy + 3 * k, 8 * k, 8 * k, 300f, 250f);
Line(g, pen, ox, oy, k, 10, 14, 19, 21);
case IconKind.Wrench: // 实心开口扳手:环+手柄在 16px 下会读成放大镜,实心剪影才立得住
var wst = g.Save();
g.TranslateTransform(ox + 12f * k, oy + 12f * k);
g.RotateTransform(45f);
using (var path = new GraphicsPath())
{
path.AddPolygon(new[]
{
new PointF(-4.8f * k, -9.4f * k), new PointF(-1.7f * k, -9.4f * k),
new PointF(-1.7f * k, -6.2f * k), new PointF(1.7f * k, -6.2f * k),
new PointF(1.7f * k, -9.4f * k), new PointF(4.8f * k, -9.4f * k),
new PointF(4.8f * k, -4.4f * k), new PointF(2.2f * k, -1.6f * k),
new PointF(2.2f * k, 9.6f * k), new PointF(-2.2f * k, 9.6f * k),
new PointF(-2.2f * k, -1.6f * k), new PointF(-4.8f * k, -4.4f * k)
});
g.FillPath(brush, path);
}
g.Restore(wst);
break;
case IconKind.Trash:
Poly(g, pen, ox, oy, k, 4, 7, 20, 7);
Poly(g, pen, ox, oy, k, 8, 7, 8, 20, 16, 20, 16, 7);
Line(g, pen, ox, oy, k, 11, 4, 13, 4);
Line(g, pen, ox, oy, k, 4.2f, 8.2f, 19.8f, 8.2f); // 盖
Line(g, pen, ox, oy, k, 9.4f, 4.2f, 14.6f, 4.2f); // 提手(与盖留 4 单位空隙)
Poly(g, pen, ox, oy, k, 6.8f, 8.2f, 6.8f, 20f, 17.2f, 20f, 17.2f, 8.2f); // 桶身
break;
case IconKind.Table:
g.DrawRectangle(pen, ox + 3 * k, oy + 5 * k, 18 * k, 14 * k);
Line(g, pen, ox, oy, k, 3, 10, 21, 10);
Line(g, pen, ox, oy, k, 10, 10, 10, 19);
var tr = new RectangleF(ox + 2.8f * k, oy + 4.6f * k, 18.4f * k, 14.8f * k);
using (var path = Theme.RoundRect(tr, 2.2f * k)) g.DrawPath(pen, path);
Line(g, pen, ox, oy, k, 2.8f, 9.6f, 21.2f, 9.6f);
Line(g, pen, ox, oy, k, 10.4f, 9.6f, 10.4f, 19.4f);
break;
case IconKind.Gear:
g.DrawEllipse(pen, ox + 8 * k, oy + 8 * k, 8 * k, 8 * k);
g.DrawEllipse(pen, ox + 10.5f * k, oy + 10.5f * k, 3 * k, 3 * k);
case IconKind.Gear: // 环 + 8 根短齿 + 实心轴:齿短一点才不会糊成太阳
for (int i = 0; i < 8; i++)
{
double a = i * Math.PI / 4;
Line(g, pen, ox, oy, k, 12f + (float)Math.Cos(a) * 5.2f, 12f + (float)Math.Sin(a) * 5.2f,
12f + (float)Math.Cos(a) * 7.4f, 12f + (float)Math.Sin(a) * 7.4f);
}
g.DrawEllipse(pen, ox + 6.8f * k, oy + 6.8f * k, 10.4f * k, 10.4f * k);
g.FillEllipse(brush, ox + 10.2f * k, oy + 10.2f * k, 3.6f * k, 3.6f * k);
break;
case IconKind.Sync: // 拉取服务器数据:带缺口的圆 + 箭头
g.DrawArc(pen, ox + 4 * k, oy + 4 * k, 16 * k, 16 * k, 300f, 250f);
Poly(g, pen, ox, oy, k, 19, 4, 20, 9, 15, 8);
case IconKind.Fetch: // 从服务器拉取(加载迭代列表):下箭头落进托盘,与「开始导入」的上箭头成对
// 圆形箭头在 16px 下始终读成「缺口的圈 + 一坨」,这里换直线几何;
// 托盘底边取 19.8 而不是 20.2,100%/125% 下都落在像素中心,不会渲成一条虚灰线
Line(g, pen, ox, oy, k, 12f, 3.4f, 12f, 12.2f);
Poly(g, pen, ox, oy, k, 6.4f, 7.4f, 12f, 13.2f, 17.6f, 7.4f);
Poly(g, pen, ox, oy, k, 5f, 14.6f, 5f, 19.8f, 19f, 19.8f, 19f, 14.6f);
break;
case IconKind.Tree: // 父项 + 两个子项:比链环更像「挂到父项下」,16px 也分得清
using (var node = Theme.RoundRect(new RectangleF(ox + 8.8f * k, oy + 2.6f * k, 6.4f * k, 5.2f * k), 1.4f * k))
using (var c1 = Theme.RoundRect(new RectangleF(ox + 2.6f * k, oy + 15.4f * k, 6.4f * k, 5.2f * k), 1.4f * k))
using (var c2 = Theme.RoundRect(new RectangleF(ox + 15f * k, oy + 15.4f * k, 6.4f * k, 5.2f * k), 1.4f * k))
{
g.FillPath(brush, node);
g.FillPath(brush, c1);
g.FillPath(brush, c2);
}
Line(g, pen, ox, oy, k, 12f, 7.8f, 12f, 12.4f);
Line(g, pen, ox, oy, k, 5.8f, 12.4f, 18.2f, 12.4f);
Line(g, pen, ox, oy, k, 5.8f, 12.4f, 5.8f, 15.4f);
Line(g, pen, ox, oy, k, 18.2f, 12.4f, 18.2f, 15.4f);
break;
}
}
g.SmoothingMode = old;
}
/// <summary>圆徽标里画勾用的内缩盒子:铺满整个圆会贴边,视觉上像被裁掉一块。</summary>
public static Rectangle BadgeGlyph(Rectangle circle)
{
int inset = Math.Max(2, circle.Width * 2 / 11); // 22px 圆 → 4px 内缩(14px 盒子)
return Rectangle.Inflate(circle, -inset, -inset);
}
private static void Poly(Graphics g, Pen pen, float ox, float oy, float k, params float[] xy)
{
var pts = new PointF[xy.Length / 2];
for (int i = 0; i < pts.Length; i++)
pts[i] = new PointF(ox + xy[i * 2] * k, oy + xy[i * 2 + 1] * k);
g.DrawLines(pen, pts);
g.DrawLines(pen, Points(ox, oy, k, xy));
}
private static void Fill(Graphics g, Brush brush, float ox, float oy, float k, params float[] xy)
{
g.FillPolygon(brush, Points(ox, oy, k, xy));
}
private static void Line(Graphics g, Pen pen, float ox, float oy, float k, float x1, float y1, float x2, float y2)
{
g.DrawLine(pen, ox + x1 * k, oy + y1 * k, ox + x2 * k, oy + y2 * k);
}
private static PointF[] Points(float ox, float oy, float k, float[] xy)
{
var pts = new PointF[xy.Length / 2];
for (int i = 0; i < pts.Length; i++)
pts[i] = new PointF(ox + xy[i * 2] * k, oy + xy[i * 2 + 1] * k);
return pts;
}
}
internal enum BadgeKind { Pending, Active, Done, Warn }
@@ -329,7 +394,8 @@ namespace TfsExcelImporter
var r = new RectangleF(0.5f, 0.5f, Width - 1f, Height - 1f);
using (var b = new SolidBrush(fill)) g.FillEllipse(b, r);
g.SmoothingMode = old;
if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, ClientRectangle, ink);
// 勾画在圆内缩 4px(14px 盒子 / 22px 圆),铺满整圆会贴边看着像被裁掉
if (Kind == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(ClientRectangle), ink);
else
TextRenderer.DrawText(g, Number.ToString(), Theme.CaptionBold, ClientRectangle, ink,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
@@ -505,7 +571,7 @@ namespace TfsExcelImporter
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var b = new SolidBrush(badgeFill)) g.FillEllipse(b, badge);
g.SmoothingMode = old;
if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, badge, badgeInk);
if (Badge == BadgeKind.Done) Icons.Draw(g, IconKind.Check, Icons.BadgeGlyph(badge), badgeInk);
else
TextRenderer.DrawText(g, StepNumber.ToString(), Theme.CaptionBold, badge, badgeInk,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding);
@@ -575,7 +641,7 @@ namespace TfsExcelImporter
Width = Math.Max(Theme.Px(designMin), pad * 2 + gw + gap + Theme.TextWidth(Text, Font));
}
private int GlyphWidth { get { return Glyph == IconKind.None ? 0 : Theme.Px(14); } }
private int GlyphWidth { get { return Glyph == IconKind.None ? 0 : Theme.Px(16); } }
private Color FaceColor
{
@@ -773,6 +839,10 @@ namespace TfsExcelImporter
/// <summary>宿主右侧留给自绘箭头的区域,同时也是点击展开下拉的热区。</summary>
private int ArrowZone { get { return Theme.Px(26); } }
/// <summary>内容左缘相对 _box 的缩进。TextBox 就摆在 Px(7),组合框闭合态文字必须共用同一基准,
/// 否则同一排的输入框文字会错开一条竖线。</summary>
private static int TextInsetX { get { return Theme.Px(7); } }
public InputField()
{
SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint
@@ -851,19 +921,30 @@ namespace TfsExcelImporter
// 闭合态就是普通输入框:白底黑字。焦点/校验态由宿主描边表达,
// 不响应 Selected 标记——否则一聚焦整块变浅蓝,还会把旁边的箭头衬成白色方块。
using (var b = new SolidBrush(Theme.Card)) e.Graphics.FillRectangle(b, full);
// 文字矩形不能直接用 e.Bounds:原生闭合区自带内缩(左侧约 2px、上沿同理),
// 且该内缩随 DPI 放大(150% 下实测 4px),照它画出来的文字会比同排 TextBox
// 右移、下沉 3~4px,看上去就是「没居中」。改成在 _box 坐标里定目标矩形
// (与 TextBox 共用 TextInsetX 基准),再换算到组合框客户区,DPI 无关。
// 额外再让 1px:原生那条「闭合区」裁剪边界正好压在文字起点上,贴着画会把首字
// 最左边的反锯齿列切掉("ntlm" 的 n 少半竖就是这个);顺带补上 TextBox 自身
// 1px 的 EM 内边距,两者文字才是同一条竖线。
var target = new Rectangle(TextInsetX + Theme.Px(1), 0,
Math.Max(0, _box.Width - TextInsetX - Theme.Px(1) - ArrowZone), _box.Height);
var textRect = cb.IsHandleCreated && _box.IsHandleCreated
? cb.RectangleToClient(_box.RectangleToScreen(target))
: target;
TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font,
new Rectangle(full.X, full.Y, Math.Max(0, full.Width - ArrowZone), full.Height),
Theme.Ink,
textRect, Theme.Ink,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding
| TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis);
return;
}
using (var b = new SolidBrush(selected ? Theme.PrimarySoft : Theme.Card))
e.Graphics.FillRectangle(b, full);
// 列表项贴着自己窗口的左沿,要补一段缩进才和输入框里的文字对齐;
// 闭合态的组合框本身就摆在宿主内缩处,原生 bounds 已经带了这个缩进。
int pad = inBox ? 0 : Theme.Px(10);
int right = inBox ? ArrowZone : Theme.Px(6);
// 列表项贴着自己窗口的左沿(列表窗口对齐宿主外沿),要补上 _box 的 2px 内缩
// 和文字基准缩进,才和闭合态里的文字严格同一条竖线。
int pad = TextInsetX + 2;
int right = Theme.Px(6);
var text = new Rectangle(full.X + pad, full.Y,
Math.Max(0, full.Width - pad - right), full.Height);
TextRenderer.DrawText(e.Graphics, cb.GetItemText(cb.Items[e.Index]), cb.Font, text,
@@ -955,6 +1036,9 @@ namespace TfsExcelImporter
private void ToggleComboDropDown()
{
if (!_combo.IsHandleCreated) return;
// 先让组合框拿到焦点:没焦点就 CB_SHOWDROPDOWN,列表展开后随即被焦点变化收掉,
// 表现出来就是「点一下闪一下」。
if (!_combo.Focused) _combo.Focus();
bool dropped = SendMessage(_combo.Handle, CB_GETDROPPEDSTATE, IntPtr.Zero, IntPtr.Zero) != IntPtr.Zero;
SendMessage(_combo.Handle, CB_SHOWDROPDOWN, new IntPtr(dropped ? 0 : 1), IntPtr.Zero);
}
@@ -1040,14 +1124,19 @@ namespace TfsExcelImporter
}
private ComboListHook _listHook;
private IntPtr _listHwnd = IntPtr.Zero;
private bool _listSkinned;
/// <summary>ComboLBox 随组合框句柄一起创建,尽早挂上钩子,第一次下拉就不跳。</summary>
private void HookComboList()
{
if (_listHook != null || _combo == null || !_combo.IsHandleCreated) return;
if (_combo == null || !_combo.IsHandleCreated) return;
var info = new ComboInfo();
info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
if (_listHook != null && _listHook.Handle == info.hwndList) return; // 已挂在当前列表窗口上
// 列表窗口被重建过:旧钩子会留在已销毁的句柄上,必须换新的
if (_listHook != null) { _listHook.ReleaseHandle(); _listHook = null; }
_listHook = new ComboListHook(this);
_listHook.AssignHandle(info.hwndList);
}
@@ -1076,17 +1165,37 @@ namespace TfsExcelImporter
var info = new ComboInfo();
info.cbSize = System.Runtime.InteropServices.Marshal.SizeOf(typeof(ComboInfo));
if (!GetComboBoxInfo(_combo.Handle, ref info) || info.hwndList == IntPtr.Zero) return;
int style = GetWindowLong(info.hwndList, GWL_STYLE);
if ((style & WS_BORDER) != 0)
SetWindowLong(info.hwndList, GWL_STYLE, style & ~WS_BORDER);
int ex = GetWindowLong(info.hwndList, GWL_EXSTYLE);
if ((ex & WS_EX_CLIENTEDGE) != 0)
SetWindowLong(info.hwndList, GWL_EXSTYLE, ex & ~WS_EX_CLIENTEDGE);
// 列表窗口重建后边框会长回来,按句柄判断是否需要重新剥一次
if (_listHwnd != info.hwndList) { _listHwnd = info.hwndList; _listSkinned = false; }
bool skin = false;
if (!_listSkinned)
{
int style = GetWindowLong(info.hwndList, GWL_STYLE);
if ((style & WS_BORDER) != 0)
{
SetWindowLong(info.hwndList, GWL_STYLE, style & ~WS_BORDER);
skin = true;
}
int ex = GetWindowLong(info.hwndList, GWL_EXSTYLE);
if ((ex & WS_EX_CLIENTEDGE) != 0)
{
SetWindowLong(info.hwndList, GWL_EXSTYLE, ex & ~WS_EX_CLIENTEDGE);
skin = true;
}
_listSkinned = true;
}
RECT r;
if (!GetWindowRect(info.hwndList, out r)) return;
var host = RectangleToScreen(ClientRectangle);
// 位置已经被 ComboListHook 改写到宿主外沿时不要再 SetWindowPos:
// 每重摆一次列表窗口都要整窗重算重画,肉眼就是「打开瞬间闪一下」。
bool moved = r.Left != host.Left || r.Top != host.Bottom || (r.Right - r.Left) != host.Width;
if (!moved && !skin) return;
// 只有真的改了窗口样式才需要 SWP_FRAMECHANGED(它强制整窗重算非客户区)
SetWindowPos(info.hwndList, IntPtr.Zero, host.Left, host.Bottom,
host.Width, r.Bottom - r.Top, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
host.Width, Math.Max(0, r.Bottom - r.Top),
SWP_NOZORDER | SWP_NOACTIVATE | (skin ? SWP_FRAMECHANGED : 0));
}
protected override void OnPaint(PaintEventArgs e)