v2.2: 桌面版同步 DESTACO 风格选型 + 网页预览后台启动

桌面版 (仅网页版 v2.1 已做的部分, 本次补齐桌面端, 两端语义一致)
- 新增 OptionTile 控件类: 三态选项块 (可选/已选/不可选), 替代 ComboBox 下拉
  不可选取值置灰+删除线但保留占位 (让用户看到选项存在, 而非从列表消失);
  悬停青色高亮, 再点一次取消选择
- 新增零件号实时条: 复用与网页版同一套「候选型号最长公共前缀」算法,
  已确定片段深青实心 + 待定位数虚化 + 进度条, 选满唯一命中时整块转青色实心
- 维度标题后回显当前已选值
- range 参数 (连续域) 保留可编辑下拉, 无法枚举成卡片
- 参数联动仍复用 Configurator.AllowedOptions, 规则引擎未改动

网页预览后台启动 (无 PowerShell 窗口)
- 新增 tools/serve-web-bg.ps1: Start-Process -WindowStyle Hidden 启动,
  带 -Stop / -Status; 用命令行匹配定位进程 (比只看 PID 文件可靠)
- _start_web.bat / _start_all.bat 改用之, 不再弹窗口

工具
- ui-diagnose.ps1 增强: 原只检查 _boxes (下拉), 现同时检查选项块数量、
  每维度是否有可选值、零件号条输出 —— 可自动发现"某维度全被置灰无从下手"
- 新增 assess-combo-risk.py: 用量化口径评估生成候选的风险
  (以同缸径真实数据值域为官方值域的代理)
- 新增 make-pending-list.py: 重写待补清单, 增加 nearest_real / diff_from_nearest
  列, 每个候选与其最近的真实型号并列, 便于逐个核实

文档
- 新增 工作总结-2026-09.md: 两阶段工作回顾、发现的问题、踩坑经验

验证
- 编译首次通过; UI 诊断 40/40 系列 0 问题
- 零件号条实测: KC0032-10 / ULP32020AEK0 / UAGP075OK120WS0 / LAE140NN31 均完整;
  UCBP 正确提示"该组合对应 2 个型号" (暴露已知数据问题)

踩坑记录
- PowerShell 变量名大小写不敏感: $root 与 $Root 是同一变量, 互相覆盖导致
  启动脚本指向错误目录 (改用 $projRoot)
- .ps1 必须带 UTF-8 BOM, 否则 PS 5.1 按 ANSI 读, 中文注释破坏解析

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
wangruiguo
2026-09-15 18:27:11 +08:00
parent fc5ee1dd36
commit e94d2aebc9
10 changed files with 4083 additions and 3192 deletions

View File

@@ -36,6 +36,118 @@ namespace Ounibo.Catalog.App
public override string ToString() { return Text; }
}
/// <summary>
/// 选项块 (DESTACO 风, 2026-09-15): 替代下拉菜单的单值可选卡片。
/// 三态: 可选 / 已选 / 不可选 (置灰划线但保留占位, 让用户看到选项存在)。
/// </summary>
public class OptionTile : Border
{
public object Value;
public ParameterDef Param;
readonly TextBlock _code;
readonly TextBlock _desc;
bool _usable = true, _on;
static readonly Brush BorderIdle = new SolidColorBrush(Color.FromRgb(0xE5, 0xE7, 0xEB));
static readonly Brush BorderHover = new SolidColorBrush(Color.FromRgb(0x2D, 0xD4, 0xBF));
static readonly Brush BorderOn = new SolidColorBrush(Color.FromRgb(0x14, 0xB8, 0xA6));
static readonly Brush BgIdle = Brushes.White;
static readonly Brush BgHover = new SolidColorBrush(Color.FromRgb(0xF0, 0xFD, 0xFA));
static readonly Brush BgOn = new SolidColorBrush(Color.FromRgb(0xF0, 0xFD, 0xFA));
static readonly Brush BgOff = new SolidColorBrush(Color.FromRgb(0xF9, 0xFA, 0xFB));
static readonly Brush FgCodeIdle = new SolidColorBrush(Color.FromRgb(0x11, 0x18, 0x27));
static readonly Brush FgCodeOn = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E));
public OptionTile(ParameterDef p, object value, string code, string desc, Action<OptionTile> onClick)
{
Param = p; Value = value;
BorderThickness = new Thickness(1.5);
CornerRadius = new CornerRadius(10);
Padding = new Thickness(9, 7, 9, 7);
Margin = new Thickness(0, 0, 6, 6);
MinWidth = 58;
Cursor = Cursors.Hand;
Background = BgIdle;
BorderBrush = BorderIdle;
var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
_code = new TextBlock
{
Text = code,
FontFamily = new FontFamily("Consolas, monospace"),
FontWeight = FontWeights.Bold,
FontSize = 13,
HorizontalAlignment = HorizontalAlignment.Center,
TextAlignment = TextAlignment.Center
};
sp.Children.Add(_code);
if (!string.IsNullOrEmpty(desc) && desc != code)
{
_desc = new TextBlock
{
Text = desc,
FontSize = 10,
Margin = new Thickness(0, 2, 0, 0),
HorizontalAlignment = HorizontalAlignment.Center,
TextAlignment = TextAlignment.Center,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 120,
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF))
};
sp.Children.Add(_desc);
}
Child = sp;
MouseEnter += (s, e) => { if (_usable && !_on) { BorderBrush = BorderHover; Background = BgHover; } };
MouseLeave += (s, e) => { if (_usable && !_on) { BorderBrush = BorderIdle; Background = BgIdle; } };
MouseLeftButtonUp += (s, e) => { if (_usable) onClick(this); };
}
/// <summary>设置可用性 (被联动规则排除时置灰但不移除)。</summary>
public void SetUsable(bool usable)
{
_usable = usable;
Cursor = usable ? Cursors.Hand : Cursors.Arrow;
if (!usable)
{
Background = BgOff; BorderBrush = BorderIdle;
BorderThickness = new Thickness(1.5);
_code.Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF));
_code.TextDecorations = TextDecorations.Strikethrough;
Opacity = 0.4;
if (_desc != null) _desc.Opacity = 0.7;
}
else
{
Opacity = 1;
_code.TextDecorations = null;
if (_desc != null) _desc.Opacity = 1;
ApplyState();
}
}
/// <summary>设置选中态。</summary>
public void SetOn(bool on)
{
_on = on;
if (_usable) ApplyState();
}
void ApplyState()
{
if (_on)
{
Background = BgOn; BorderBrush = BorderOn;
_code.Foreground = FgCodeOn;
}
else
{
Background = BgIdle; BorderBrush = BorderIdle;
_code.Foreground = FgCodeIdle;
}
}
}
/// <summary>
/// 主窗口: 目录树 + 参数选型 + 3D 预览 + 尺寸图/参数表/数据表 + 搜索 + STEP 下载/批量下载。
/// </summary>
@@ -48,6 +160,12 @@ namespace Ounibo.Catalog.App
Series _series;
readonly Dictionary<string, object> _sel = new Dictionary<string, object>();
readonly Dictionary<string, ComboBox> _boxes = new Dictionary<string, ComboBox>();
// 选项块 (DESTACO 风, 2026-09-15): 每个参数一组可点选项块, 替代下拉菜单
readonly Dictionary<string, List<OptionTile>> _tiles = new Dictionary<string, List<OptionTile>>();
readonly Dictionary<string, WrapPanel> _tilePanels = new Dictionary<string, WrapPanel>();
TextBlock _skuCode, _skuHint; // 零件号实时条
Border _skuBar;
ProgressBar _skuProg;
readonly Dictionary<string, double?[]> _ranges = new Dictionary<string, double?[]>();
readonly Dictionary<string, TextBox[]> _rangeBoxes = new Dictionary<string, TextBox[]>();
List<SearchEngine.SearchItem> _searchResult;
@@ -165,6 +283,53 @@ namespace Ounibo.Catalog.App
var left = new StackPanel { Margin = new Thickness(8) };
_modelTitle = new TextBlock { FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 8), TextWrapping = TextWrapping.Wrap };
left.Children.Add(_modelTitle);
// 零件号实时条 (DESTACO 风, 2026-09-15): 边选边拼, 已确定片段高亮 / 待定虚化
_skuBar = new Border
{
CornerRadius = new CornerRadius(12),
BorderThickness = new Thickness(1),
BorderBrush = new SolidColorBrush(Color.FromRgb(0x99, 0xF6, 0xE4)),
Background = new LinearGradientBrush(
Color.FromRgb(0xF0, 0xFD, 0xFA), Colors.White, 45),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 10)
};
var skuInner = new StackPanel();
skuInner.Children.Add(new TextBlock
{
Text = _lang.T("零件号 Part Number", "Part Number"),
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
Margin = new Thickness(0, 0, 0, 4)
});
_skuCode = new TextBlock
{
FontFamily = new FontFamily("Consolas, monospace"),
FontSize = 17, FontWeight = FontWeights.Bold,
TextWrapping = TextWrapping.Wrap,
MinHeight = 22
};
skuInner.Children.Add(_skuCode);
_skuProg = new ProgressBar
{
Height = 3, Margin = new Thickness(0, 7, 0, 0),
Minimum = 0, Maximum = 1, Value = 0,
Foreground = new SolidColorBrush(Color.FromRgb(0x14, 0xB8, 0xA6)),
Background = new SolidColorBrush(Color.FromRgb(0xCC, 0xFB, 0xF1)),
BorderThickness = new Thickness(0)
};
skuInner.Children.Add(_skuProg);
_skuHint = new TextBlock
{
FontSize = 11, Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80))
};
skuInner.Children.Add(_skuHint);
_skuBar.Child = skuInner;
left.Children.Add(_skuBar);
_paramPanel = new StackPanel();
left.Children.Add(_paramPanel); // 参数最多 7 行, 直接平铺不滚动 (避免下方参数被遮挡)
// 下载操作区 (2026-08-24, 与网页版一致: 参数下方并排 下载 STEP / 下载 PDF, 同款配色)
@@ -494,6 +659,8 @@ namespace Ounibo.Catalog.App
_series = null; _cfg = null;
_paramPanel.Children.Clear();
_boxes.Clear(); _sel.Clear();
_tiles.Clear(); _tilePanels.Clear();
if (_skuBar != null) _skuBar.Visibility = Visibility.Collapsed;
}
void TreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
@@ -512,26 +679,67 @@ namespace Ounibo.Catalog.App
_cfg = new Configurator(s);
_sel.Clear();
_boxes.Clear();
_tiles.Clear();
_tilePanels.Clear();
_paramPanel.Children.Clear();
_ranges.Clear(); _rangeBoxes.Clear(); _rangePanel.Children.Clear();
if (_skuBar != null) _skuBar.Visibility = Visibility.Visible;
foreach (var p in s.parameters)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
row.Children.Add(new TextBlock { Text = ParamLabel(p), Width = 120, VerticalAlignment = VerticalAlignment.Center });
var cb = new ComboBox { Width = 150, Tag = p };
var block = new StackPanel { Margin = new Thickness(0, 0, 0, 10) };
// 维度标题: 小号大写 + 已选值回显
var head = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 5) };
head.Children.Add(new TextBlock
{
Text = ParamLabel(p).ToUpperInvariant(),
FontSize = 10.5, FontWeight = FontWeights.Bold,
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
VerticalAlignment = VerticalAlignment.Center
});
var picked = new TextBlock
{
FontSize = 11.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E)),
Margin = new Thickness(7, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center
};
picked.Tag = p.code; // 供 RefreshOptions 回显
head.Children.Add(picked);
block.Children.Add(head);
if (p.type == "range")
{
// 参数化 range 域: 可编辑下拉 (标准档位快捷选 + 自由输入, 与网页版一致)
cb.IsEditable = true;
cb.IsTextSearchEnabled = false;
// 连续域无法枚举成选项块 → 保留可编辑下拉 (标准档位 + 自由输入)
var cb = new ComboBox { Width = 150, Tag = p, IsEditable = true, IsTextSearchEnabled = false, HorizontalAlignment = HorizontalAlignment.Left };
cb.KeyDown += (s2, e2) => { if (e2.Key == System.Windows.Input.Key.Enter) TrySetRangeText(cb); };
cb.LostKeyboardFocus += (s2, e2) => TrySetRangeText(cb);
cb.SelectionChanged += ParamChanged;
block.Children.Add(cb);
_boxes[p.code] = cb;
}
cb.SelectionChanged += ParamChanged;
row.Children.Add(cb);
_paramPanel.Children.Add(row);
_boxes[p.code] = cb;
else
{
// 枚举维度 → 选项块网格 (有说明显示说明, 无说明只显示代号)
var wrap = new WrapPanel();
var list = new List<OptionTile>();
foreach (var v in (p.values ?? new List<object>()))
{
var key = v == null ? "" : Convert.ToString(v, CultureInfo.InvariantCulture);
var disp = DisplayText(p, v);
bool hasDesc = !string.IsNullOrEmpty(disp) && disp != key;
var tile = new OptionTile(p, v,
hasDesc ? (key.Length == 0 ? "—" : key) : disp,
hasDesc ? disp : null,
OnTileClick);
list.Add(tile);
wrap.Children.Add(tile);
}
block.Children.Add(wrap);
_tilePanels[p.code] = wrap;
_tiles[p.code] = list;
}
_paramPanel.Children.Add(block);
// 数值/range 参数 → 参数表范围筛选控件
if (p.type == "number" || p.type == "range")
@@ -598,6 +806,116 @@ namespace Ounibo.Catalog.App
RefreshOptions();
}
/// <summary>选项块点击: 切换选择 (再点一次取消); 与下拉时代语义一致。</summary>
void OnTileClick(OptionTile tile)
{
if (_refreshing || _cfg == null) return;
var code = tile.Param.code;
if (_sel.ContainsKey(code) && EqStr(_sel[code], tile.Value)) _sel.Remove(code);
else _sel[code] = tile.Value;
RefreshOptions();
}
// ---------- 零件号实时拼装 (DESTACO 风, 与网页版同一套语义) ----------
// 不依赖编码模板 (40 系列仅 KC 有): 用「当前候选型号的最长公共前缀」——
// 选得越多候选越少、前缀越长, 逐字拼出完整零件号。
List<string> CandidateCodes()
{
var res = new List<string>();
if (_series == null) return res;
var vs = _series.variants ?? new List<Variant>();
if (_sel.Count == 0) { foreach (var v in vs) res.Add(v.modelCode); return res; }
foreach (var v in vs)
{
bool ok = true;
foreach (var kv in _sel)
{
object val;
if (!v.@params.TryGetValue(kv.Key, out val) || !EqStr(val, kv.Value)) { ok = false; break; }
}
if (ok) res.Add(v.modelCode);
}
return res;
}
static string LongestCommonPrefix(List<string> list)
{
if (list == null || list.Count == 0) return "";
var a = list[0]; var b = list[0];
foreach (var x in list)
{
if (string.CompareOrdinal(x, a) < 0) a = x;
if (string.CompareOrdinal(x, b) > 0) b = x;
}
int i = 0;
while (i < a.Length && i < b.Length && a[i] == b[i]) i++;
return a.Substring(0, i);
}
/// <summary>刷新零件号实时条: 已确定前缀 + 待定位数 + 进度 + 提示。</summary>
void RefreshSkuBar()
{
if (_skuBar == null || _series == null) return;
var total = (_series.parameters ?? new List<ParameterDef>()).Count;
var chosen = _sel.Count;
var cand = CandidateCodes();
var pre = LongestCommonPrefix(cand);
_skuCode.Inlines.Clear();
if (pre.Length > 0)
{
_skuCode.Inlines.Add(new System.Windows.Documents.Run(pre)
{
Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E))
});
}
var rest = new string('·', Math.Max(0, total - chosen));
if (rest.Length > 0)
{
_skuCode.Inlines.Add(new System.Windows.Documents.Run(rest)
{
Foreground = new SolidColorBrush(Color.FromRgb(0xD1, 0xD5, 0xDB))
});
}
if (pre.Length == 0 && rest.Length == 0)
{
_skuCode.Inlines.Add(new System.Windows.Documents.Run(
_lang.T("选择参数以生成零件号", "Select options to build part number"))
{ Foreground = new SolidColorBrush(Color.FromRgb(0xD1, 0xD5, 0xDB)) });
}
bool done = chosen == total && cand.Count == 1;
if (done)
{
// 完成态: 整块转青色实心
_skuBar.Background = new LinearGradientBrush(
Color.FromRgb(0x14, 0xB8, 0xA6), Color.FromRgb(0x0D, 0x94, 0x88), 45);
_skuBar.BorderBrush = Brushes.Transparent;
_skuCode.Inlines.Clear();
_skuCode.Inlines.Add(new System.Windows.Documents.Run(pre) { Foreground = Brushes.White });
}
else
{
_skuBar.Background = new LinearGradientBrush(
Color.FromRgb(0xF0, 0xFD, 0xFA), Colors.White, 45);
_skuBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x99, 0xF6, 0xE4));
}
_skuProg.Value = total == 0 ? 0 : (double)chosen / total;
if (chosen < total)
_skuHint.Text = _lang.T(
"已选 " + chosen + "/" + total + " 项 · 候选 " + cand.Count + " 个型号",
chosen + "/" + total + " selected · " + cand.Count + " candidates");
else if (cand.Count == 1)
_skuHint.Text = _lang.T("零件号完整", "Part number complete");
else if (cand.Count > 1)
_skuHint.Text = _lang.T(
"该参数组合对应 " + cand.Count + " 个型号, 请在参数表中选择",
cand.Count + " models share these options — pick one in the table");
else
_skuHint.Text = _lang.T("无匹配型号", "No matching model");
}
// range 参数自由输入 (参数化引擎): 回车/失焦时校验并写入选择; 非法输入恢复原显示 (与网页版一致)
void TrySetRangeText(ComboBox cb)
{
@@ -632,6 +950,7 @@ namespace Ounibo.Catalog.App
try
{
var allowed = _cfg.AllowedOptions(_sel);
// 下拉 (range 参数)
foreach (var kv in _boxes)
{
var cb = kv.Value;
@@ -649,11 +968,52 @@ namespace Ounibo.Catalog.App
if (_sel.ContainsKey(p.code))
cb.SelectedItem = cb.Items.OfType<ComboItem>().FirstOrDefault(x => EqStr(x.Value, _sel[p.code]));
}
// 选项块 (枚举参数): 不可选的置灰保留, 已选的打勾
foreach (var kv in _tiles)
{
var code = kv.Key;
List<object> list;
if (!allowed.TryGetValue(code, out list)) list = new List<object>();
if (_sel.ContainsKey(code) && !list.Any(x => EqStr(x, _sel[code])))
_sel.Remove(code);
foreach (var tile in kv.Value)
{
tile.SetUsable(list.Any(x => EqStr(x, tile.Value)));
tile.SetOn(_sel.ContainsKey(code) && EqStr(_sel[code], tile.Value));
}
}
// 维度标题后的已选值回显
if (_series != null)
foreach (var p in _series.parameters)
{
var found = FindPickedTextBlock(p.code);
if (found != null)
found.Text = _sel.ContainsKey(p.code) ? "· " + DisplayText(p, _sel[p.code]) : "";
}
RefreshSkuBar();
TryUpdateSelection();
}
finally { _refreshing = false; }
}
/// <summary>按 Tag (=参数 code) 找到维度标题后的回显 TextBlock。</summary>
TextBlock FindPickedTextBlock(string code)
{
foreach (var child in _paramPanel.Children)
{
var block = child as StackPanel;
if (block == null || block.Children.Count == 0) continue;
var head = block.Children[0] as StackPanel;
if (head == null) continue;
foreach (var h in head.Children)
{
var tb = h as TextBlock;
if (tb != null && tb.Tag as string == code) return tb;
}
}
return null;
}
static bool EqStr(object a, object b)
{
return string.Equals(