Files
OnebotCatalog/src/App/MainWindow.cs
wangruiguo e94d2aebc9 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>
2026-09-15 18:27:11 +08:00

1937 lines
95 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.Data;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D; // Helix 窗口内 3D (ModelVisual3D 等)
using System.Windows.Controls.Primitives; // DataGridColumnHeader (参数表按列筛选)
using Microsoft.Win32;
using Ounibo.Catalog.Core;
using Ounibo.Catalog.Viewer3D;
namespace Ounibo.Catalog.App
{
/// <summary>简单双语: zh/en 文案切换。</summary>
public class Lang
{
public string Code = "zh";
public string T(string zh, string en) { return Code == "en" ? en : zh; }
public void Toggle() { Code = (Code == "zh") ? "en" : "zh"; }
}
public class ComboItem
{
public object Value;
public string Text;
public ComboItem(object v, string t) { Value = v; Text = t; }
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>
public class MainWindow : Window
{
Lang _lang = new Lang();
OpcPackage _pkg;
Configurator _cfg;
SearchEngine _search;
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;
bool _refreshing;
#if !CUSTOMER_BUILD
bool _admin;
#endif
TreeView _tree;
StackPanel _paramPanel, _rangePanel;
ViewerControl _viewer;
Image _dimImage;
StackPanel _dimPanel, _manualPanel;
DataGrid _grid, _searchGrid, _crossGrid;
ComboBox _crossBrand;
TabControl _tabs;
TextBlock _modelTitle, _status, _pkgInfo;
Button _btnDownload, _btnDownloadPdf, _btnAddCart, _btnOpen, _btnLang, _btnCart, _btnSearch, _btnExport, _btnRangeApply, _btnRangeReset;
string _curModelCode; // 当前已选型号 (平替对照按此过滤)
DataTable _gridDt; // 参数表数据 (RowFilter 按列筛选)
Dictionary<string, string> _colFilter = new Dictionary<string, string>(); // 参数表按列筛选 {参数code: 值}
List<CartItem> _cart = new List<CartItem>(); // 购物车 (淘宝式清单, 与选型状态解耦)
Variant _curVariant; // 当前已解析型号 (加入清单用)
ParamResult _curParam; // 当前参数化结果 (非标档位时非空, 与 _curVariant 互斥; 一期 2026-08-25)
Border _viewerHint; // 3D 视口顶部非标档位提示条
string _genApi = ""; // NX 按需生成服务地址 (catalog.json genApi; 空=非标下载置灰)
ComboBox _btnGenFmt; // 生成格式选择 (非标档位在线生成, 二期)
CartWindow _cartWin; // 清单窗口 (非模态, 数据在主窗口)
StackPanel _fallbackPanel; // 真实数模 (桌面解析器不支持) → 浏览器预览回退面板
Button _btnBrowser3D;
LocalViewerServer _previewSrv; // 本机 3D 预览微型服务器 (惰性启动)
string _curStepName; // 当前变体 STEP 文件名 (浏览器预览用)
byte[] _curStepBytes;
HelixToolkit.Wpf.HelixViewport3D _helixView; // 开源 Helix Toolkit (MIT): 真实数模网格 (STL) 窗口内直渲
Microsoft.Web.WebView2.Wpf.WebView2 _webView2; // 方案 B: 窗口内嵌新迪查看器, 真实 STEP 直显 (免弹浏览器)
bool _webView2Ready; // WebView2 运行时可用标记 (不可用自动降级)
#if !CUSTOMER_BUILD
Button _btnAdmin;
#endif
TextBox _searchBox;
public OpcPackage Package { get { return _pkg; } }
public MainWindow(OpcPackage pkg, bool admin)
{
_pkg = pkg;
_genApi = string.IsNullOrEmpty(pkg.Catalog.genApi) ? "" : pkg.Catalog.genApi.TrimEnd('/'); // NX 按需生成服务 (二期)
#if !CUSTOMER_BUILD
_admin = admin;
#endif
Width = 1280; Height = 840; MinWidth = 900; MinHeight = 560;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
var logo = AppResources.LoadPng("OneBot-logo.png");
if (logo != null) Icon = logo; // 窗口图标 (OneBot logo)
BuildUi();
ApplyLang();
if (_pkg != null) LoadPackageIntoUi();
Closed += (s, e) => { if (_previewSrv != null) { try { _previewSrv.Dispose(); } catch { } } };
}
void BuildUi()
{
var root = new DockPanel();
// ---- 顶栏 ----
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 6, 8, 4) };
var logoImg = AppResources.LoadPng("OneBot-logo.png");
if (logoImg != null)
top.Children.Add(new Image { Source = logoImg, Height = 26, Stretch = Stretch.Uniform, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center });
_btnOpen = new Button { Width = 110, Margin = new Thickness(0, 0, 6, 0) };
_btnOpen.Click += (s, e) => OpenPackage();
_searchBox = new TextBox { Width = 200, Margin = new Thickness(10, 0, 4, 0) };
_searchBox.KeyDown += (s, e) => { if (e.Key == Key.Enter) DoSearch(); };
_btnSearch = new Button { Width = 60, Margin = new Thickness(0, 0, 6, 0) };
_btnSearch.Click += (s, e) => DoSearch();
_btnLang = new Button { Width = 60, Margin = new Thickness(0, 0, 6, 0) };
_btnLang.Click += (s, e) => { _lang.Toggle(); ApplyLang(); };
// 清单角标按钮 (常驻顶栏, 2026-08-24)
_btnCart = new Button { Width = 90, Margin = new Thickness(6, 0, 0, 0) };
_btnCart.Click += (s, e) => OpenCart();
#if !CUSTOMER_BUILD
_btnAdmin = new Button { Width = 100, Margin = new Thickness(0, 0, 6, 0), Visibility = _admin ? Visibility.Visible : Visibility.Collapsed };
_btnAdmin.Click += (s, e) => { var w = new AdminWindow(this); w.ShowDialog(); };
#endif
_pkgInfo = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
top.Children.Add(_btnOpen);
top.Children.Add(_searchBox); top.Children.Add(_btnSearch);
top.Children.Add(_btnLang);
top.Children.Add(_btnCart);
#if !CUSTOMER_BUILD
top.Children.Add(_btnAdmin);
#endif
top.Children.Add(_pkgInfo);
DockPanel.SetDock(top, Dock.Top);
root.Children.Add(top);
// ---- 底部状态栏 ----
_status = new TextBlock { Margin = new Thickness(8, 4, 8, 6) };
DockPanel.SetDock(_status, Dock.Bottom);
root.Children.Add(_status);
// ---- 中部: 目录树 | 参数面板 | 标签页 (分隔条可拖拽调整宽度) ----
var center = new Grid();
center.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(250), MinWidth = 150, MaxWidth = 420 });
center.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
center.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(320), MinWidth = 220, MaxWidth = 520 });
center.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
center.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
_tree = new TreeView();
Grid.SetColumn(_tree, 0);
center.Children.Add(_tree);
center.Children.Add(MakeSplitter(1));
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, 同款配色)
var actions = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 10, 0, 0) };
_btnDownload = new Button
{
Padding = new Thickness(14, 6, 14, 6),
FontSize = 13,
Margin = new Thickness(0, 0, 8, 0),
Visibility = Visibility.Collapsed,
Background = new SolidColorBrush(Color.FromRgb(0x1B, 0x4F, 0x8C)),
Foreground = Brushes.White,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand
};
_btnDownload.Click += (s, e) =>
{
// 非标档位 + 生成服务可用 → 在线生成 (二期); 否则原 STEP 下载
if (_curParam != null && !string.IsNullOrEmpty(_genApi)) GenSingle(_curParam);
else DownloadStep();
};
// 生成格式选择 (非标档位在线生成用; 生成输出留存全格式, 格式只影响下载)
_btnGenFmt = new ComboBox
{
Width = 108,
Margin = new Thickness(0, 0, 8, 0),
Visibility = Visibility.Collapsed,
ItemsSource = new[] {
new ComboItem("zip", "全格式 zip"),
new ComboItem("step", "STEP"),
new ComboItem("parasolid", "Parasolid"),
new ComboItem("iges", "IGES"),
new ComboItem("stl", "STL")
},
SelectedIndex = 0
};
_btnDownloadPdf = new Button
{
Padding = new Thickness(14, 6, 14, 6),
FontSize = 13,
Visibility = Visibility.Collapsed,
Background = new SolidColorBrush(Color.FromRgb(0xE9, 0xEC, 0xEF)),
Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
BorderBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)),
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand
};
_btnDownloadPdf.Click += (s, e) => DownloadDatasheet();
// 加入清单 (淘宝式购物车, 2026-08-24)
_btnAddCart = new Button
{
Padding = new Thickness(14, 6, 14, 6),
FontSize = 13,
Margin = new Thickness(0, 0, 8, 0),
Visibility = Visibility.Collapsed,
Background = new SolidColorBrush(Color.FromRgb(0xE9, 0xEC, 0xEF)),
Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
BorderBrush = new SolidColorBrush(Color.FromRgb(0xCC, 0xCC, 0xCC)),
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand
};
_btnAddCart.Click += (s, e) =>
{
if (_curVariant != null) AddToCart(_curVariant);
else if (_curParam != null) AddToCart(_curParam);
};
actions.Children.Add(_btnDownload); actions.Children.Add(_btnGenFmt); actions.Children.Add(_btnDownloadPdf); actions.Children.Add(_btnAddCart);
left.Children.Add(actions);
Grid.SetColumn(left, 2);
center.Children.Add(left);
center.Children.Add(MakeSplitter(3));
_tabs = new TabControl();
var tab3d = new TabItem();
// 3D/尺寸图同页 (2026-08-24, 与网页版布局一致): 左 3D 视口 | 可拖拽分隔条 | 右 尺寸图+手册页
var grid3d = new Grid();
grid3d.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star), MinWidth = 200 });
grid3d.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid3d.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(320), MinWidth = 180, MaxWidth = 640 });
var view3d = new Grid();
_viewer = new ViewerControl();
view3d.Children.Add(_viewer);
// 窗口内 3D (开源 Helix Toolkit, MIT): 渲染 NX 导出的 STL 网格, 免浏览器
_helixView = new HelixToolkit.Wpf.HelixViewport3D
{
Visibility = Visibility.Collapsed,
Background = new LinearGradientBrush(
Color.FromRgb(0xF2, 0xF4, 0xF7), Color.FromRgb(0xD8, 0xDE, 0xE6), 90.0),
ShowCoordinateSystem = false,
ShowViewCube = false
};
_helixView.Children.Add(new HelixToolkit.Wpf.DefaultLights());
view3d.Children.Add(_helixView);
// 窗口内嵌 3D (方案 B, WebView2): 真实 STEP 直接渲染, 不弹外部浏览器
_webView2 = new Microsoft.Web.WebView2.Wpf.WebView2 { Visibility = Visibility.Collapsed };
view3d.Children.Add(_webView2);
// 真实数模回退面板: 桌面内置解析器只支持简化占位几何, 真实 B-rep 走本机浏览器+新迪查看器
_fallbackPanel = new StackPanel
{
Visibility = Visibility.Collapsed,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
MaxWidth = 520
};
_fallbackPanel.Children.Add(new TextBlock
{
Text = "该型号为真实 CAD 数模,已自动在浏览器中打开 3D 预览(本机运行,完全离线)。\n若浏览器窗口被关闭,可点下方按钮重新打开。",
TextWrapping = TextWrapping.Wrap,
TextAlignment = TextAlignment.Center,
Margin = new Thickness(20, 0, 20, 14)
});
_btnBrowser3D = new Button { Width = 240, Height = 42, FontSize = 15 };
_btnBrowser3D.Click += (s, e) => OpenBrowser3D();
_fallbackPanel.Children.Add(_btnBrowser3D);
view3d.Children.Add(_fallbackPanel);
// 非标档位提示条 (参数化引擎: 3D 展示最近标准档位近似几何时显示; 与网页版一致) — 最后添加保证置顶
_viewerHint = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xE6, 0xFF, 0xF7, 0xD6)),
BorderBrush = new SolidColorBrush(Color.FromRgb(0xD9, 0xB6, 0x3C)),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 4, 8, 4),
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(6),
Visibility = Visibility.Collapsed
};
_viewerHint.Child = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = 12 };
view3d.Children.Add(_viewerHint);
Grid.SetColumn(view3d, 0);
grid3d.Children.Add(view3d);
grid3d.Children.Add(MakeSplitter(1));
_dimPanel = new StackPanel();
_dimPanel.Children.Add(_dimImage = new Image { Stretch = Stretch.Uniform, Margin = new Thickness(8), HorizontalAlignment = HorizontalAlignment.Left });
_manualPanel = new StackPanel();
_dimPanel.Children.Add(_manualPanel);
var dimScroll = new ScrollViewer { Content = _dimPanel, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
Grid.SetColumn(dimScroll, 2);
grid3d.Children.Add(dimScroll);
tab3d.Content = grid3d;
var tabGrid = new TabItem(); tabGrid.Content = BuildGridTab();
var tabSearch = new TabItem(); tabSearch.Content = BuildSearchTab();
var tabCross = new TabItem(); tabCross.Content = BuildCrossRefTab();
_tabs.Items.Add(tab3d); _tabs.Items.Add(tabGrid); _tabs.Items.Add(tabSearch); _tabs.Items.Add(tabCross);
Grid.SetColumn(_tabs, 4);
center.Children.Add(_tabs);
root.Children.Add(center);
Content = root;
Loaded += (s, e) => InitWebView2Async(); // WebView2 初始化须在窗口加载后
Closed += (s, e) => { if (_webView2 != null) _webView2.Dispose(); };
}
// WebView2 运行时初始化 (窗口内嵌 3D 方案 B): 失败则静默降级到浏览器预览
async void InitWebView2Async()
{
try
{
var userData = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OnebotCatalog", "WebView2");
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(null, userData);
await _webView2.EnsureCoreWebView2Async(env);
_webView2Ready = true;
}
catch
{
_webView2Ready = false;
_status.Text = _lang.T(
"未检测到 WebView2 运行时, 真实数模用浏览器预览 (Win11 自带; Win10 装 Edge 即可)",
"WebView2 runtime not found - real models open in browser (built into Win11; install Edge on Win10)");
}
}
// 列分隔条 (鼠标拖拽调宽)
GridSplitter MakeSplitter(int col)
{
var sp = new GridSplitter { Width = 6, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch };
Grid.SetColumn(sp, col);
return sp;
}
static T FindAncestor<T>(DependencyObject d) where T : DependencyObject
{
while (d != null)
{
if (d is T) return (T)d;
d = VisualTreeHelper.GetParent(d);
}
return null;
}
// ---- 参数表 Tab (范围筛选 + 批量下载) ----
DockPanel BuildGridTab()
{
var panel = new DockPanel();
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4) };
_rangePanel = new StackPanel { Orientation = Orientation.Horizontal };
top.Children.Add(_rangePanel);
_btnRangeApply = new Button { Width = 60, Margin = new Thickness(6, 0, 4, 0) };
_btnRangeApply.Click += (s, e) => ApplyRange();
_btnRangeReset = new Button { Width = 60, Margin = new Thickness(0, 0, 10, 0) };
_btnRangeReset.Click += (s, e) => ResetRange();
_btnExport = new Button { Width = 110 };
_btnExport.Click += (s, e) => ExportSelected();
top.Children.Add(_btnRangeApply); top.Children.Add(_btnRangeReset); top.Children.Add(_btnExport);
DockPanel.SetDock(top, Dock.Top);
panel.Children.Add(top);
_grid = new DataGrid { AutoGenerateColumns = false, IsReadOnly = true, SelectionMode = DataGridSelectionMode.Extended, SelectionUnit = DataGridSelectionUnit.FullRow, Margin = new Thickness(4) };
// 双击行 → 参数带入选型 (与网页版一致; 单击/框选仍用于批量下载)
_grid.MouseDoubleClick += (s, e) =>
{
var dr = _grid.SelectedItem as DataRowView;
if (dr != null) NavigateToModel(dr["modelCode"].ToString());
};
// 右键列头 → 按列筛选 (2026-08-24, 与网页版列筛选一致)
_grid.PreviewMouseRightButtonUp += (s, e) =>
{
var header = FindAncestor<DataGridColumnHeader>(e.OriginalSource as DependencyObject);
if (header == null || header.Column == null) return;
var col = header.Column as DataGridTextColumn;
if (col == null || !(col.Binding is System.Windows.Data.Binding)) return;
var path = ((System.Windows.Data.Binding)col.Binding).Path.Path;
if (path == "modelCode") return;
var menu = new ContextMenu();
var all = new MenuItem { Header = _lang.T("全部 (清除本列筛选)", "All (clear filter)") };
all.Click += (s2, e2) => { _colFilter.Remove(path); BuildGrid(); };
menu.Items.Add(all);
var vals = new List<string>();
foreach (DataRowView r in _grid.Items) { var sv = Convert.ToString(r[path]); if (!vals.Contains(sv)) vals.Add(sv); }
foreach (var sv in vals)
{
var mi = new MenuItem
{
Header = sv.Length == 0 ? _lang.T("(空值)", "(empty)") : sv,
IsChecked = _colFilter.ContainsKey(path) && _colFilter[path] == sv
};
mi.Click += (s2, e2) => { _colFilter[path] = sv; BuildGrid(); };
menu.Items.Add(mi);
}
menu.PlacementTarget = header;
menu.IsOpen = true;
e.Handled = true;
};
var gridHint = new TextBlock
{
Text = "双击行 → 参数带入选型 | 右键列头 → 按列筛选 | 单击/框选多行用于批量下载",
FontSize = 12,
Foreground = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)),
Margin = new Thickness(6, 2, 0, 4)
};
DockPanel.SetDock(gridHint, Dock.Bottom);
panel.Children.Add(gridHint);
panel.Children.Add(_grid);
return panel;
}
// ---- 搜索 Tab ----
DockPanel BuildSearchTab()
{
var panel = new DockPanel { Margin = new Thickness(4) };
var hint = new TextBlock
{
Text = "双击结果定位到该型号。支持: 型号编码 / 中文名 / 英文名 / 关键词(拼音) / 竞品型号(平替, 支持模糊)。",
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 4)
};
DockPanel.SetDock(hint, Dock.Top);
panel.Children.Add(hint);
_searchGrid = new DataGrid { AutoGenerateColumns = false, IsReadOnly = true, SelectionMode = DataGridSelectionMode.Single };
_searchGrid.Columns.Add(new DataGridTextColumn { Header = "型号 Model", Binding = new System.Windows.Data.Binding("ModelCode"), Width = 180 });
_searchGrid.Columns.Add(new DataGridTextColumn { Header = "系列 Series", Binding = new System.Windows.Data.Binding("SeriesName") });
_searchGrid.Columns.Add(new DataGridTextColumn { Header = "STEP", Binding = new System.Windows.Data.Binding("Step") });
_searchGrid.MouseDoubleClick += (s, e) => JumpToSelected();
panel.Children.Add(_searchGrid);
return panel;
}
// ---- 数据包加载 ----
void OpenPackage()
{
var dlg = new OpenFileDialog { Filter = "目录数据包 (*.opc)|*.opc", Title = "打开 .opc 数据包" };
if (dlg.ShowDialog() == true)
ReplacePackage(dlg.FileName);
}
public bool ReplacePackage(string path)
{
try
{
var np = OpcPackage.Open(path);
if (_pkg != null) _pkg.Dispose();
_pkg = np;
LoadPackageIntoUi();
return true;
}
catch (Exception ex)
{
MessageBox.Show("数据包打开失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
return false;
}
}
void LoadPackageIntoUi()
{
_search = new SearchEngine(_pkg.Catalog);
_tree.Items.Clear();
foreach (var cat in _pkg.Catalog.categories)
{
var catNode = new TreeViewItem { Header = cat.nameZh, Tag = cat };
foreach (var sc in cat.series)
{
var s = _pkg.Catalog.series.FirstOrDefault(x => x.code == sc);
if (s == null) continue;
var node = new TreeViewItem { Header = s.nameZh, Tag = s };
catNode.Items.Add(node);
}
catNode.IsExpanded = true;
_tree.Items.Add(catNode);
}
_tree.SelectedItemChanged += TreeSelected;
_pkgInfo.Text = string.Format("{0} v{1}", _pkg.Catalog.catalogName, _pkg.Catalog.catalogVersion);
RefreshCrossRef();
_status.Text = _lang.T("请选择产品系列", "Select a series");
_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)
{
var node = _tree.SelectedItem as TreeViewItem;
if (node == null || !(node.Tag is Series)) return;
LoadSeries((Series)node.Tag);
}
void LoadSeries(Series s)
{
_series = s;
// 下载 PDF 按钮: 系列有数据表附件才显示 (与网页一致)
_btnDownloadPdf.Visibility = (s.attachments ?? new List<Attachment>()).Any(a => a.kind == "datasheet")
? Visibility.Visible : Visibility.Collapsed;
_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 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")
{
// 连续域无法枚举成选项块 → 保留可编辑下拉 (标准档位 + 自由输入)
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;
}
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")
{
_rangePanel.Children.Add(new TextBlock { Text = ParamLabel(p) + " ", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) });
var min = new TextBox { Width = 50, Margin = new Thickness(0, 0, 4, 0) };
var max = new TextBox { Width = 50, Margin = new Thickness(0, 0, 4, 0) };
_rangePanel.Children.Add(min); _rangePanel.Children.Add(new TextBlock { Text = " ~ ", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) }); _rangePanel.Children.Add(max);
_rangeBoxes[p.code] = new[] { min, max };
}
}
_viewer.Clear();
_viewer.Visibility = Visibility.Visible;
_fallbackPanel.Visibility = Visibility.Collapsed;
if (_viewerHint != null) _viewerHint.Visibility = Visibility.Collapsed;
_dimImage.Source = null;
LoadManualPages(s);
BuildGrid();
RefreshOptions();
}
// 手册页 (尺寸图窗口滚动对照, 系列级附件 kind=manual; 标题只显示序号不显示路径)
void LoadManualPages(Series s)
{
_manualPanel.Children.Clear();
var pages = s.attachments.Where(x => x.kind == "manual").ToList();
for (int i = 0; i < pages.Count; i++)
{
try
{
var a = pages[i];
var img = new Image { Source = BitmapFromBytes(_pkg.ReadAsset(a.path)), Stretch = Stretch.Uniform, Margin = new Thickness(8, 4, 8, 8), HorizontalAlignment = HorizontalAlignment.Left };
_manualPanel.Children.Add(new TextBlock { Text = _lang.T("手册页", "Manual page") + " " + (i + 1) + "/" + pages.Count, Margin = new Thickness(12, 10, 0, 0), FontWeight = FontWeights.Bold });
_manualPanel.Children.Add(img);
}
catch { }
}
}
// ---- 选型逻辑 ----
string ParamLabel(ParameterDef p)
{
var name = _lang.T(p.nameZh, p.nameEn);
return string.IsNullOrEmpty(p.unit) ? name : name + " (" + p.unit + ")";
}
string DisplayText(ParameterDef p, object v)
{
string key = v == null ? "" : Convert.ToString(v, CultureInfo.InvariantCulture);
if (p.display != null && p.display.ContainsKey(key))
return p.display[key];
return key;
}
void ParamChanged(object sender, SelectionChangedEventArgs e)
{
if (_refreshing || _cfg == null) return;
var cb = (ComboBox)sender;
var p = (ParameterDef)cb.Tag;
var item = cb.SelectedItem as ComboItem;
if (item != null) _sel[p.code] = item.Value;
else _sel.Remove(p.code);
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)
{
if (_refreshing || _cfg == null) return;
var p = (ParameterDef)cb.Tag;
if (p.type != "range") return;
var text = (cb.Text ?? "").Trim();
if (text.Length == 0)
{
if (_sel.ContainsKey(p.code)) { _sel.Remove(p.code); RefreshOptions(); }
return;
}
double d;
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
{
object v = d;
if (ParametricEngine.DomainValid(p, v))
{
if (!_sel.ContainsKey(p.code) || !EqStr(_sel[p.code], v)) _sel[p.code] = v;
RefreshOptions();
return;
}
_status.Text = _lang.T("输入超出范围或与步长不符", "Out of range or not aligned to step");
}
RefreshOptions(); // 恢复显示当前已选值 (非法输入不生效)
}
void RefreshOptions()
{
if (_cfg == null) return;
_refreshing = true;
try
{
var allowed = _cfg.AllowedOptions(_sel);
// 下拉 (range 参数)
foreach (var kv in _boxes)
{
var cb = kv.Value;
var p = (ParameterDef)cb.Tag;
List<object> list;
if (!allowed.TryGetValue(p.code, out list)) list = new List<object>();
if (_sel.ContainsKey(p.code) && !list.Any(x => EqStr(x, _sel[p.code])))
_sel.Remove(p.code);
// range 参数: 标准档位选项标注 "标准" (自由输入值不标)
var gridVals = p.gridValues ?? new List<object>();
cb.ItemsSource = list.Select(v => new ComboItem(v,
p.type == "range" && gridVals.Any(g => EqStr(g, v))
? DisplayText(p, v) + " (" + _lang.T("标准", "Std") + ")"
: DisplayText(p, v))).ToList();
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(
a == null ? "" : Convert.ToString(a, CultureInfo.InvariantCulture),
b == null ? "" : Convert.ToString(b, CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
void TryUpdateSelection()
{
var v = _cfg == null ? null : _cfg.Resolve(_sel);
ParamResult pres = null;
if (v == null && _cfg != null && _cfg.IsParametric &&
_sel.Count == (_series.parameters ?? new List<ParameterDef>()).Count)
{
pres = ParametricEngine.Generate(_series, _cfg, _sel);
if (pres != null && pres.Error != null) pres = null; // 非法组合按"无匹配"处理
}
_curVariant = v;
_curParam = pres;
_curModelCode = v != null ? v.modelCode : (pres != null ? pres.ModelCode : null);
// 下载按钮 (与网页一致): 标准 → 可用; 非标 → 生成服务可用则【在线生成数模】, 否则置灰+提示; 未选全 → 隐藏
if (v != null)
{
_btnDownload.Visibility = Visibility.Visible;
_btnDownload.IsEnabled = true;
_btnDownload.ToolTip = null;
_btnDownload.Content = _lang.T("下载 STEP", "Download STEP");
if (_btnGenFmt != null) _btnGenFmt.Visibility = Visibility.Collapsed; // 标准档位无多格式
}
else if (pres != null)
{
_btnDownload.Visibility = Visibility.Visible;
if (!string.IsNullOrEmpty(_genApi))
{
_btnDownload.IsEnabled = true;
_btnDownload.ToolTip = null;
_btnDownload.Content = _lang.T("在线生成数模", "Generate 3D model online");
if (_btnGenFmt != null) _btnGenFmt.Visibility = Visibility.Visible;
}
else
{
_btnDownload.IsEnabled = false;
_btnDownload.ToolTip = _lang.T("非标档位暂不支持数模下载,请联系销售或选择标准档位",
"STEP download unavailable for non-standard config. Contact sales or choose a standard config.");
_btnDownload.Content = _lang.T("下载 STEP", "Download STEP");
if (_btnGenFmt != null) _btnGenFmt.Visibility = Visibility.Collapsed;
}
}
else { _btnDownload.Visibility = Visibility.Collapsed; if (_btnGenFmt != null) _btnGenFmt.Visibility = Visibility.Collapsed; }
_btnAddCart.Visibility = (v != null || pres != null) ? Visibility.Visible : Visibility.Collapsed;
RefreshCrossRef(); // 平替对照只显示当前型号的平替数据
if (v == null && pres == null)
{
var missing = _series.parameters.Where(p => !_sel.ContainsKey(p.code)).Select(p => ParamLabel(p)).ToList();
_modelTitle.Text = _lang.T("请选择完整参数", "Complete the selection");
if (missing.Count > 0)
_status.Text = _lang.T(
"已选 " + _sel.Count + "/" + _series.parameters.Count + " 项, 还缺: " + string.Join("、", missing),
_sel.Count + "/" + _series.parameters.Count + " selected, missing: " + string.Join(", ", missing));
else
_status.Text = _lang.T("该组合无对应型号", "No matching model");
return;
}
// 非标档位 (参数化引擎): 标题带徽标, 3D 展示最近标准档位近似几何 + 提示条 (下载已置灰)
if (pres != null)
{
_modelTitle.Text = _lang.T("已选型号: ", "Model: ") + pres.ModelCode +
_lang.T(" [非标档位(无标准数模)]", " [Non-standard (no standard model)]");
var near = ParametricEngine.NearestStandard(_series, _cfg, pres.Params);
if (near != null)
{
try { TryLegacyRender(near); }
catch (Exception ex) { _status.Text = _lang.T("3D 加载失败: ", "3D load failed: ") + ex.Message; }
}
((TextBlock)_viewerHint.Child).Text = _lang.T(
"当前为非标档位,展示最近标准档位 " + (near == null ? "?" : near.modelCode) + " 的近似几何",
"Non-standard config — showing nearest standard model " + (near == null ? "?" : near.modelCode) + " as approximation.");
_viewerHint.Visibility = Visibility.Visible;
_status.Text = _lang.T("非标档位: 无对应标准数模, 3D 展示最近标准档位近似几何; STEP 下载需联系销售或选择标准档位",
"Non-standard config: nearest standard model shown; contact sales or choose a standard config for STEP");
return;
}
_modelTitle.Text = _lang.T("已选型号: ", "Model: ") + v.modelCode;
_status.Text = _lang.T("选型完成,可下载 STEP 或查看数据表", "Ready - download STEP or view datasheet");
try
{
// 3D 显示链 (2026-08-24, 与网页版一致): 占位→自绘; 真实数模→WebView2 窗口内; 无 WebView2→网格 Helix 降级; 都无→浏览器回退
TryLegacyRender(v);
}
catch (Exception ex)
{
_status.Text = _lang.T("3D 加载失败: ", "3D load failed: ") + ex.Message;
}
var dim = _series.attachments.FirstOrDefault(a => a.kind == "dimDrawing" && a.model == v.modelCode)
?? _series.attachments.FirstOrDefault(a => a.kind == "dimDrawing");
if (dim != null)
{
try { _dimImage.Source = BitmapFromBytes(_pkg.ReadAsset(dim.path)); }
catch { _dimImage.Source = null; }
}
}
BitmapImage BitmapFromBytes(byte[] bytes)
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.StreamSource = new MemoryStream(bytes);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
return bmp;
}
// 网格侧车文件: <数据包目录>\mesh\<step 同名>.stl/.obj (NX journal 同步导出的预览网格)
string FindMeshSidecar(string stepPath)
{
if (string.IsNullOrEmpty(stepPath)) return null;
var baseName = System.IO.Path.GetFileNameWithoutExtension(stepPath.Replace('\\', '/'));
var dir = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(_pkg.Path) ?? ".", "mesh");
foreach (var ext in new[] { ".stl", ".obj" })
{
var f = System.IO.Path.Combine(dir, baseName + ext);
if (System.IO.File.Exists(f)) return f;
}
return null;
}
// Helix 视口加载网格 (STL/OBJ), 加载后自适应视角
void LoadHelixMesh(string path)
{
_helixView.Children.Clear();
_helixView.Children.Add(new HelixToolkit.Wpf.DefaultLights());
var importer = new HelixToolkit.Wpf.ModelImporter();
var model = importer.Load(path);
_helixView.Children.Add(new ModelVisual3D { Content = model });
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background,
new Action(() => { if (_helixView.IsVisible) _helixView.ZoomExtents(); }));
}
// 3D 显示链 (2026-08-24 定稿): 占位→自绘; 真实 B-rep→WebView2 精确视图直显; 无 WebView2→网格 Helix 降级; 都无→浏览器回退
void TryLegacyRender(Variant v)
{
if (_viewerHint != null) _viewerHint.Visibility = Visibility.Collapsed; // 标准档位无提示条
try
{
var stepText = _pkg.ReadAssetText(v.step);
var mesh = StepMesh.Parse(stepText);
if (mesh != null && mesh.Count >= 3)
{
_viewer.Load(mesh);
_viewer.Visibility = Visibility.Visible;
_helixView.Visibility = Visibility.Collapsed;
_webView2.Visibility = Visibility.Collapsed;
_fallbackPanel.Visibility = Visibility.Collapsed;
return;
}
}
catch { }
if (TryWebView2(v)) return;
// WebView2 不可用: 网格 Helix 降级 (老 Win10 无运行时环境)
var meshPath = FindMeshSidecar(v.step);
if (meshPath != null)
{
try
{
LoadHelixMesh(meshPath);
_helixView.Visibility = Visibility.Visible;
_viewer.Visibility = Visibility.Collapsed;
_webView2.Visibility = Visibility.Collapsed;
_fallbackPanel.Visibility = Visibility.Collapsed;
_status.Text = _lang.T("3D 已在窗口内显示 (STL 网格降级预览)", "3D shown in-window (STL mesh fallback)");
return;
}
catch { }
}
FallbackToBrowser(v);
}
// 方案 B: 窗口内嵌 WebView2 显示真实 STEP (本机微型服务器 URL, 完全离线); 不可用返回 false
bool TryWebView2(Variant v)
{
if (!_webView2Ready) return false;
try
{
_curStepName = System.IO.Path.GetFileName((v.step ?? "").Replace('\\', '/'));
_curStepBytes = _pkg.ReadAsset(v.step);
var url = GetPreviewUrl();
if (url == null) return false;
_webView2.Source = new Uri(url);
_webView2.Visibility = Visibility.Visible;
_viewer.Visibility = Visibility.Collapsed;
_helixView.Visibility = Visibility.Collapsed;
_fallbackPanel.Visibility = Visibility.Collapsed;
_status.Text = _lang.T("真实数模已在窗口内显示 (内嵌 3D 查看器, 完全离线)", "Real model shown in-window (embedded 3D viewer, fully offline)");
return true;
}
catch { return false; }
}
// 真实数模且无网格、WebView2 不可用 → 本机浏览器 + 新迪查看器 (最后回退)
void FallbackToBrowser(Variant v)
{
_helixView.Visibility = Visibility.Collapsed;
_webView2.Visibility = Visibility.Collapsed;
_fallbackPanel.Visibility = Visibility.Visible;
_curStepName = System.IO.Path.GetFileName((v.step ?? "").Replace('\\', '/'));
_curStepBytes = _pkg.ReadAsset(v.step);
OpenBrowser3D(true);
}
// 生成 3D 预览 URL (本机微型服务器 + 新迪查看器; WebView2 窗口内与浏览器弹窗共用); 不可用返回 null
string GetPreviewUrl()
{
if (_curStepBytes == null) return null;
if (_previewSrv == null)
{
// 开发版布局 bin\..\viewer; 客户版布局 exe 旁 viewer\
var viewerRoot = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "viewer");
if (!Directory.Exists(viewerRoot))
viewerRoot = System.IO.Path.GetFullPath(System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "viewer"));
if (!Directory.Exists(viewerRoot)) return null;
_previewSrv = new LocalViewerServer(viewerRoot);
}
return _previewSrv.PublishStep(_curStepName ?? "model.step", _curStepBytes);
}
// 真实数模 → 本机浏览器 + 新迪查看器 (完全离线, 零外部依赖)
void OpenBrowser3D() { OpenBrowser3D(false); }
void OpenBrowser3D(bool silent)
{
try
{
var url = GetPreviewUrl();
if (url == null)
{
if (silent)
{
_status.Text = _lang.T("未找到 3D 查看器组件 (viewer 目录缺失), 可点下方按钮重试", "3D viewer component not found (missing viewer folder)");
return;
}
MessageBox.Show(_lang.T("未找到 3D 查看器组件 (viewer 目录缺失)", "3D viewer component not found (missing viewer folder)"),
Title, MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
Process.Start(url);
_status.Text = _lang.T("已自动在浏览器打开真实数模 3D 预览 (本机服务, 完全离线)", "Real model 3D preview opened in browser (local, fully offline)");
}
catch (Exception ex)
{
if (silent)
{
_status.Text = _lang.T("自动打开 3D 预览失败, 请点按钮重试: ", "Auto-open 3D preview failed, retry with the button: ") + ex.Message;
return;
}
MessageBox.Show(_lang.T("打开 3D 预览失败: ", "Failed to open 3D preview: ") + ex.Message,
Title, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
// ---- 参数表 (含范围过滤) ----
bool InRange(Variant v)
{
foreach (var r in _ranges)
{
var p = _series.parameters.FirstOrDefault(x => x.code == r.Key);
if (p == null || p.type != "number") continue;
object val;
if (!v.@params.TryGetValue(r.Key, out val)) continue;
double d = Convert.ToDouble(val, CultureInfo.InvariantCulture);
if (r.Value[0].HasValue && d < r.Value[0].Value) return false;
if (r.Value[1].HasValue && d > r.Value[1].Value) return false;
}
return true;
}
void BuildGrid()
{
var dt = new DataTable();
dt.Columns.Add("modelCode");
foreach (var p in _series.parameters)
dt.Columns.Add(p.code);
foreach (var v in _cfg.ValidVariants().Where(InRange))
{
var row = dt.NewRow();
row["modelCode"] = v.modelCode;
foreach (var p in _series.parameters)
{
object val;
row[p.code] = v.@params.TryGetValue(p.code, out val) ? Convert.ToString(val, CultureInfo.InvariantCulture) : "";
}
dt.Rows.Add(row);
}
_grid.Columns.Clear();
_grid.Columns.Add(new DataGridTextColumn { Header = _lang.T("型号 Model", "Model"), Binding = new System.Windows.Data.Binding("modelCode") });
foreach (var p in _series.parameters)
{
_grid.Columns.Add(new DataGridTextColumn
{
Header = _lang.T(p.nameZh, p.nameEn) + (string.IsNullOrEmpty(p.unit) ? "" : " (" + p.unit + ")"),
Binding = new System.Windows.Data.Binding(p.code)
});
}
_gridDt = dt;
ApplyGridFilter();
_grid.ItemsSource = dt.DefaultView;
}
// 按列筛选 (右键列头选值, 多列 AND 叠加; RowFilter 字符串比较)
void ApplyGridFilter()
{
if (_gridDt == null) return;
var parts = new List<string>();
foreach (var kv in _colFilter)
parts.Add("[" + kv.Key + "] = " + (kv.Value.Length == 0 ? "''" : "'" + kv.Value.Replace("'", "''") + "'"));
_gridDt.DefaultView.RowFilter = parts.Count == 0 ? "" : string.Join(" AND ", parts);
}
void ApplyRange()
{
_ranges.Clear();
foreach (var kv in _rangeBoxes)
{
double? min = null, max = null;
double d;
if (double.TryParse(kv.Value[0].Text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out d)) min = d;
if (double.TryParse(kv.Value[1].Text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out d)) max = d;
if (min.HasValue || max.HasValue) _ranges[kv.Key] = new double?[] { min, max };
}
BuildGrid();
_status.Text = _lang.T("范围筛选: " + _ranges.Count + " 项生效", "Range filters: " + _ranges.Count + " active");
}
void ResetRange()
{
_ranges.Clear();
foreach (var kv in _rangeBoxes) { kv.Value[0].Text = ""; kv.Value[1].Text = ""; }
BuildGrid();
_status.Text = _lang.T("范围筛选已重置", "Range filters reset");
}
// ---- 购物车 (淘宝式清单批量导出, 2026-08-24; 与网页版语义/文案一致) ----
void AddToCart(Variant v)
{
if (v == null) return;
AddToCart(v.modelCode, v.step, BuildParamsSummary(v), true, v.@params);
}
// 非标档位加入清单: 无 STEP, 打包时在线生成或注明 (参数化引擎, 与网页版一致)
void AddToCart(ParamResult p)
{
if (p == null) return;
AddToCart(p.ModelCode, null,
_lang.T("【非标档位(打包时无 STEP)】 ", "[Non-standard (no STEP in package)] ") + BuildParamsSummary(p.Params),
false, p.Params);
}
void AddToCart(string modelCode, string stepPath, string summary, bool isStandard, Dictionary<string, object> pars)
{
if (_cart.Any(x => string.Equals(x.ModelCode, modelCode, StringComparison.OrdinalIgnoreCase)))
{
_status.Text = _lang.T("该型号已在清单中", "Already in cart");
return;
}
_cart.Add(new CartItem
{
ModelCode = modelCode,
SeriesCode = _series == null ? "" : _series.code,
SeriesName = _series == null ? "" : (_lang.Code == "en" ? _series.nameEn : _series.nameZh),
StepPath = stepPath,
ParamsSummary = summary,
IsStandard = isStandard,
Params = pars
});
UpdateCartBadge();
_status.Text = _lang.T("已加入清单 (共 " + _cart.Count + " 项)", "Added to cart (" + _cart.Count + " items)");
}
// 参数摘要 "键=值; ..." (显示名映射, 空值显示(无))
string BuildParamsSummary(Variant v)
{
return v == null ? "" : BuildParamsSummary(v.@params);
}
string BuildParamsSummary(Dictionary<string, object> par)
{
if (_series == null) return "";
var parts = new List<string>();
foreach (var p in _series.parameters)
{
object val;
if (!par.TryGetValue(p.code, out val)) continue;
var sv = Convert.ToString(val, CultureInfo.InvariantCulture) ?? "";
var label = (p.display != null && p.display.ContainsKey(sv)) ? p.display[sv] : sv;
parts.Add(p.nameZh + "=" + (label.Length == 0 ? "(无)" : label));
}
return string.Join("; ", parts);
}
void UpdateCartBadge()
{
_btnCart.Content = _lang.T("清单 (" + _cart.Count + ")", "Cart (" + _cart.Count + ")");
if (_cartWin != null) _cartWin.Refresh();
}
void OpenCart()
{
if (_cartWin == null)
{
_cartWin = new CartWindow(_cart, UpdateCartBadge, DownloadCartZip);
_cartWin.Closed += (s, e) => _cartWin = null;
}
_cartWin.Show();
_cartWin.Activate();
}
// 一键打包 (异步, 含非标批量在线生成): 返回 null 成功 / 错误提示字符串
async System.Threading.Tasks.Task<string> DownloadCartZip()
{
if (_cart.Count == 0) return _lang.T("清单为空, 请先加入型号", "Cart is empty");
var dlg = new SaveFileDialog
{
FileName = "ONEBOT_清单_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".zip",
Filter = "ZIP 压缩包 (*.zip)|*.zip"
};
if (dlg.ShowDialog() != true) return null;
try
{
var snap = _cart.ToArray(); // 打包快照: 打包期间的 UI 操作只影响下一次下载
var missing = new List<string>();
var csv = new StringBuilder();
csv.AppendLine("序号,型号编码,系列代码,系列名,数量,参数摘要,STEP文件名");
// 非标档位 + 生成服务可用 → 批量在线生成 → 子 zip 并入主 zip (型号子文件夹多格式)
var nonStd = snap.Where(x => string.IsNullOrEmpty(x.StepPath)).ToList();
var subZipPath = "";
if (nonStd.Count > 0 && !string.IsNullOrEmpty(_genApi))
{
try { subZipPath = await GenBatch(nonStd); }
catch (Exception ex) { return _lang.T("非标生成失败: ", "Generation failed: ") + ex.Message; }
}
using (var zip = ZipFile.Open(dlg.FileName, ZipArchiveMode.Create))
{
// 先并入在线生成的子 zip (每型号一个子文件夹, 检索命中的已留存型号也在内)
if (!string.IsNullOrEmpty(subZipPath))
using (var sub = ZipFile.OpenRead(subZipPath))
foreach (var e in sub.Entries)
{
var entry = zip.CreateEntry(e.FullName);
using (var es = entry.Open())
using (var ss = e.Open())
ss.CopyTo(es);
}
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < snap.Length; i++)
{
var it = snap[i];
// 非标档位: 生成服务可用时已合并为型号子文件夹; 不可用则显式注明 (不静默跳过)
if (string.IsNullOrEmpty(it.StepPath))
{
csv.AppendLine(CsvEscape(new[] {
(i + 1).ToString(), it.ModelCode, it.SeriesCode, it.SeriesName,
it.Qty.ToString(), it.ParamsSummary,
string.IsNullOrEmpty(_genApi)
? _lang.T("(非标档位,无 STEP)", "(non-standard, no STEP)")
: _lang.T("(在线生成, 型号子文件夹)", "(generated online, subfolder)") }));
continue;
}
var ext = Path.GetExtension((it.StepPath ?? "").Replace('\\', '/'));
if (string.IsNullOrEmpty(ext)) ext = ".step";
var name = it.ModelCode + ext;
if (used.Contains(name)) name = it.ModelCode + "_" + it.SeriesCode + ext; // 重名冲突加系列码
used.Add(name);
try
{
var bytes = _pkg.ReadAsset(it.StepPath);
var entry = zip.CreateEntry(name);
using (var es = entry.Open()) es.Write(bytes, 0, bytes.Length);
}
catch { missing.Add(it.ModelCode); }
csv.AppendLine(CsvEscape(new[] {
(i + 1).ToString(), it.ModelCode, it.SeriesCode, it.SeriesName,
it.Qty.ToString(), it.ParamsSummary, name }));
}
var bom = zip.CreateEntry("BOM.csv");
using (var es = bom.Open()) { var b = new UTF8Encoding(true).GetBytes(csv.ToString()); es.Write(b, 0, b.Length); }
var txt = zip.CreateEntry("清单.txt");
using (var es = txt.Open()) { var b = new UTF8Encoding(true).GetBytes(csv.ToString().Replace(",", " ")); es.Write(b, 0, b.Length); }
}
if (!string.IsNullOrEmpty(subZipPath)) try { File.Delete(subZipPath); } catch { }
if (missing.Count > 0)
return _lang.T("打包完成, 但以下型号文件缺失: ", "Packed, but missing: ") + string.Join(", ", missing);
MessageBox.Show(_lang.T("已保存: ", "Saved: ") + dlg.FileName, Title, MessageBoxButton.OK, MessageBoxImage.Information);
return null;
}
catch (Exception ex)
{
return _lang.T("打包失败: ", "Pack failed: ") + ex.Message;
}
}
// NX 按需生成 (二期 A 版, 单型号): 提交 → 轮询 → 下载全格式 zip (与网页版同语义/文案)
async void GenSingle(ParamResult p)
{
if (string.IsNullOrEmpty(_genApi) || p == null) return;
var api = _genApi;
_btnDownload.IsEnabled = false;
_btnDownload.Content = _lang.T("正在生成数模…", "Generating…");
try
{
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
string body = ser.Serialize(new { series = _series.code, code = p.ModelCode, @params = p.Params });
string respText;
using (var wc = new System.Net.WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers[System.Net.HttpRequestHeader.ContentType] = "application/json";
respText = await wc.UploadStringTaskAsync(api + "/gen", body);
}
var resp = ser.Deserialize<Dictionary<string, object>>(respText);
if (resp.ContainsKey("immediate"))
{
_status.Text = _lang.T("数模已就绪,开始下载", "Ready — downloading");
}
else
{
var jobId = Convert.ToString(resp["jobId"]);
bool done = false;
for (int i = 0; i < 150 && !done; i++)
{
await System.Threading.Tasks.Task.Delay(3000);
string stText;
using (var wc = new System.Net.WebClient()) { wc.Encoding = Encoding.UTF8; stText = await wc.DownloadStringTaskAsync(api + "/status?job=" + Uri.EscapeDataString(jobId)); }
var st = ser.Deserialize<Dictionary<string, object>>(stText);
var state = Convert.ToString(st.ContainsKey("state") ? st["state"] : "");
if (state == "queued") { _btnDownload.Content = _lang.T("生成队列繁忙,请稍候…", "Queued…"); continue; }
if (state == "running") { _btnDownload.Content = _lang.T("正在生成数模…", "Generating…"); continue; }
if (state == "done" && Convert.ToBoolean(st.ContainsKey("ok") && st["ok"] != null ? st["ok"] : false)) { done = true; break; }
_status.Text = _lang.T("生成失败,请重试或联系销售", "Generation failed — retry or contact sales");
return;
}
if (!done) { _status.Text = _lang.T("生成超时,请重试", "Generation timed out — retry"); return; }
}
// 输出已留存: 按所选格式直接下载 (生成服务 /download, 与网页一致)
var fmtItem = _btnGenFmt != null ? _btnGenFmt.SelectedItem as ComboItem : null;
var fmtKey = fmtItem != null && fmtItem.Value != null ? fmtItem.Value.ToString() : "zip";
var extMap = new Dictionary<string, string> { { "zip", ".zip" }, { "step", ".stp" }, { "parasolid", ".x_t" }, { "iges", ".igs" }, { "stl", ".stl" } };
var url = api + "/download?series=" + Uri.EscapeDataString(_series.code) + "&code=" + Uri.EscapeDataString(p.ModelCode)
+ (fmtKey != "zip" ? "&fmt=" + Uri.EscapeDataString(fmtKey) : "");
var dlg = new SaveFileDialog { FileName = p.ModelCode + (extMap.ContainsKey(fmtKey) ? extMap[fmtKey] : ".zip"), Filter = "文件 (*.*)|*.*" };
if (dlg.ShowDialog() != true) return;
using (var wc = new System.Net.WebClient()) await wc.DownloadFileTaskAsync(url, dlg.FileName);
_status.Text = _lang.T("已保存: ", "Saved: ") + dlg.FileName;
}
catch (Exception ex)
{
_status.Text = _lang.T("生成失败: ", "Generation failed: ") + ex.Message;
}
finally
{
_btnDownload.IsEnabled = true;
_btnDownload.Content = _lang.T("在线生成数模", "Generate 3D model online");
}
}
// 批量在线生成: 提交 → 轮询 → 下载整体 zip 到临时文件 (返回临时文件路径)
async System.Threading.Tasks.Task<string> GenBatch(List<CartItem> nonStd)
{
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
var api = _genApi;
string body = ser.Serialize(new
{
items = nonStd.Select(x => new { series = x.SeriesCode, code = x.ModelCode, @params = x.Params ?? new Dictionary<string, object>() }).ToList()
});
string respText;
using (var wc = new System.Net.WebClient())
{
wc.Encoding = Encoding.UTF8;
wc.Headers[System.Net.HttpRequestHeader.ContentType] = "application/json";
respText = await wc.UploadStringTaskAsync(api + "/gen-batch", body);
}
var resp = ser.Deserialize<Dictionary<string, object>>(respText);
if (resp.ContainsKey("error")) throw new Exception(Convert.ToString(resp["error"]));
var jobId = Convert.ToString(resp["jobId"]);
var token = Convert.ToString(resp["token"]);
bool done = false;
for (int i = 0; i < 200 && !done; i++)
{
await System.Threading.Tasks.Task.Delay(3000);
string stText;
using (var wc = new System.Net.WebClient()) { wc.Encoding = Encoding.UTF8; stText = await wc.DownloadStringTaskAsync(api + "/status?job=" + Uri.EscapeDataString(jobId)); }
var st = ser.Deserialize<Dictionary<string, object>>(stText);
var state = Convert.ToString(st.ContainsKey("state") ? st["state"] : "");
if (state == "done") { done = true; break; }
if (state == "failed") throw new Exception(Convert.ToString(st.ContainsKey("error") ? st["error"] : "failed"));
_status.Text = state == "queued"
? _lang.T("生成队列繁忙,请稍候…", "Queued…")
: _lang.T("正在生成数模…", "Generating…");
}
if (!done) throw new Exception(_lang.T("生成超时,请重试", "Generation timed out — retry"));
var tmp = Path.Combine(Path.GetTempPath(), "ounibo_gen_" + jobId + ".zip");
using (var wc = new System.Net.WebClient()) await wc.DownloadFileTaskAsync(api + "/files?job=" + Uri.EscapeDataString(jobId) + "&token=" + Uri.EscapeDataString(token), tmp);
return tmp;
}
static string CsvEscape(string[] cells)
{
return string.Join(",", cells.Select(c =>
{
c = c ?? "";
if (c.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0)
c = "\"" + c.Replace("\"", "\"\"") + "\"";
return c;
}));
}
// 参数表勾选行 → 批量加入清单 (原"批量导出所选"改造, 统一走清单)
void ExportSelected()
{
var rows = _grid.SelectedItems.OfType<DataRowView>().Select(dr => dr["modelCode"].ToString()).ToList();
if (rows.Count == 0)
{
MessageBox.Show(_lang.T("请先在参数表勾选型号 (Ctrl/Shift 多选)", "Select models in the parameter grid first (Ctrl/Shift)"), Title);
return;
}
int added = 0;
foreach (var model in rows)
{
var v = _cfg.ValidVariants().FirstOrDefault(x => x.modelCode == model);
if (v == null) continue;
if (_cart.Any(x => string.Equals(x.ModelCode, v.modelCode, StringComparison.OrdinalIgnoreCase))) continue;
_cart.Add(new CartItem
{
ModelCode = v.modelCode,
SeriesCode = _series == null ? "" : _series.code,
SeriesName = _series == null ? "" : (_lang.Code == "en" ? _series.nameEn : _series.nameZh),
StepPath = v.step,
ParamsSummary = BuildParamsSummary(v)
});
added++;
}
UpdateCartBadge();
_status.Text = _lang.T("已加入清单 " + added + " 项", added + " items added to cart");
}
// ---- 搜索 ----
void DoSearch()
{
if (_search == null) return;
var q = _searchBox.Text.Trim();
if (q.Length == 0) return;
var items = _search.Query(q, _lang.Code);
// 平替模糊搜索 (2026-08-24): 竞品型号包含关键词 → 对应我方型号列入结果 (精确命中走引擎 AltCodes 900 分)
foreach (var cr in _pkg.Catalog.crossRefs ?? new List<CrossRefRow>())
{
if (cr.foreignModel == null || cr.foreignModel.IndexOf(q, StringComparison.OrdinalIgnoreCase) < 0) continue;
if (items.Any(x => string.Equals(x.ModelCode, cr.ourModel, StringComparison.OrdinalIgnoreCase))) continue;
foreach (var s in _pkg.Catalog.series ?? new List<Series>())
{
var vv = (s.variants ?? new List<Variant>()).FirstOrDefault(x => string.Equals(x.modelCode, cr.ourModel, StringComparison.OrdinalIgnoreCase));
if (vv == null) continue;
items.Add(new SearchEngine.SearchItem { Series = s, Variant = vv, Note = cr.brand + " " + cr.foreignModel });
break;
}
}
_searchResult = items;
var dt = new DataTable();
dt.Columns.Add("ModelCode");
dt.Columns.Add("SeriesName");
dt.Columns.Add("Step");
foreach (var it in items)
{
var row = dt.NewRow();
row["ModelCode"] = it.ModelCode;
row["SeriesName"] = _lang.Code == "en" ? it.SeriesNameEn : it.SeriesNameZh;
row["Step"] = it.Step;
dt.Rows.Add(row);
}
_searchGrid.ItemsSource = dt.DefaultView;
((TabItem)_tabs.Items[2]).IsSelected = true;
_status.Text = _lang.T("搜索 \"" + q + "\": " + items.Count + " 条结果", "Search \"" + q + "\": " + items.Count + " result(s)");
}
void JumpToSelected()
{
var row = _searchGrid.SelectedItem as DataRowView;
if (row == null || _searchResult == null) return;
var idx = _searchGrid.SelectedIndex;
if (idx < 0 || idx >= _searchResult.Count) return;
NavigateToModel(_searchResult[idx].ModelCode);
}
// ---- 平替对照 Tab (竞品型号 → 我方型号, 源 catalog\crossref.csv) ----
DockPanel BuildCrossRefTab()
{
var panel = new DockPanel { Margin = new Thickness(6) };
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
top.Children.Add(new TextBlock { Text = "品牌筛选:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) });
_crossBrand = new ComboBox { Width = 170 };
_crossBrand.SelectionChanged += (s, e) => RefreshCrossRef();
top.Children.Add(_crossBrand);
top.Children.Add(new TextBlock { Text = " 只显示当前型号的平替数据; 双击行跳转 (搜索框输入竞品型号也可直达)", VerticalAlignment = VerticalAlignment.Center, Foreground = System.Windows.Media.Brushes.Gray, Margin = new Thickness(10, 0, 0, 0) });
DockPanel.SetDock(top, Dock.Top);
panel.Children.Add(top);
_crossGrid = new DataGrid { IsReadOnly = true, AutoGenerateColumns = false };
_crossGrid.Columns.Add(new DataGridTextColumn { Header = "品牌 Brand", Binding = new System.Windows.Data.Binding("Brand"), Width = 120 });
_crossGrid.Columns.Add(new DataGridTextColumn { Header = "竞品型号 Foreign Model", Binding = new System.Windows.Data.Binding("Foreign"), Width = 220 });
_crossGrid.Columns.Add(new DataGridTextColumn { Header = "我方型号 Our Model", Binding = new System.Windows.Data.Binding("Our"), Width = 180 });
_crossGrid.Columns.Add(new DataGridTextColumn { Header = "备注 Note", Binding = new System.Windows.Data.Binding("Note"), Width = new System.Windows.Controls.DataGridLength(1, System.Windows.Controls.DataGridLengthUnitType.Star) });
_crossGrid.MouseDoubleClick += (s, e) =>
{
var row = _crossGrid.SelectedItem as DataRowView;
if (row != null) NavigateToModel(Convert.ToString(row["Our"]));
};
panel.Children.Add(_crossGrid);
return panel;
}
void RefreshCrossRef()
{
if (_crossGrid == null || _pkg == null) return;
var brandSel = _crossBrand != null && _crossBrand.SelectedItem != null ? Convert.ToString(_crossBrand.SelectedItem) : null;
var dt = new DataTable();
dt.Columns.Add("Brand"); dt.Columns.Add("Foreign"); dt.Columns.Add("Our"); dt.Columns.Add("Note");
foreach (var cr in _pkg.Catalog.crossRefs ?? new List<CrossRefRow>())
{
if (brandSel != null && !string.Equals(cr.brand, brandSel, StringComparison.OrdinalIgnoreCase)) continue;
// 2026-08-24: 只显示与当前已选型号匹配的平替行 (未选型号则显示全部)
if (!string.IsNullOrEmpty(_curModelCode) && !string.Equals(cr.ourModel, _curModelCode, StringComparison.OrdinalIgnoreCase)) continue;
dt.Rows.Add(cr.brand, cr.foreignModel, cr.ourModel, cr.note);
}
_crossGrid.ItemsSource = dt.DefaultView;
if (_crossBrand != null && _crossBrand.Items.Count == 0)
{
_crossBrand.Items.Add(_lang.T("全部品牌", "All brands"));
foreach (var b in (_pkg.Catalog.crossRefs ?? new List<CrossRefRow>()).Select(x => x.brand).Distinct().OrderBy(x => x))
_crossBrand.Items.Add(b);
_crossBrand.SelectedIndex = 0;
}
}
// 按我方型号跳转: 定位系列 → 设参数 → 选型完成 (搜索/平替共用)
void NavigateToModel(string modelCode)
{
foreach (var s in _pkg.Catalog.series ?? new List<Series>())
{
var v = (s.variants ?? new List<Variant>()).FirstOrDefault(x => string.Equals(x.modelCode, modelCode, StringComparison.OrdinalIgnoreCase));
if (v == null) continue;
foreach (TreeViewItem catNode in _tree.Items)
foreach (TreeViewItem node in catNode.Items)
if (node.Tag == s) node.IsSelected = true;
LoadSeries(s);
foreach (var kv in v.@params)
_sel[kv.Key] = kv.Value;
RefreshOptions();
((TabItem)_tabs.Items[0]).IsSelected = true;
return;
}
_status.Text = _lang.T("型号未找到: ", "Model not found: ") + modelCode;
}
// ---- 下载与数据表 ----
void DownloadStep()
{
var v = _cfg == null ? null : _cfg.Resolve(_sel);
if (v == null)
{
MessageBox.Show(_lang.T("请先完成选型", "Complete the selection first"), Title);
return;
}
var dlg = new SaveFileDialog
{
FileName = v.modelCode + ".step",
Filter = "STEP 文件 (*.step)|*.step",
Title = _lang.T("保存 STEP 文件", "Save STEP file")
};
if (dlg.ShowDialog() == true)
{
try
{
_pkg.ExtractStepTo(v, dlg.FileName);
MessageBox.Show(_lang.T("已保存: ", "Saved: ") + dlg.FileName, Title, MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(_lang.T("保存失败: ", "Save failed: ") + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
void DownloadDatasheet()
{
if (_series == null) return;
var ds = _series.attachments.FirstOrDefault(a => a.kind == "datasheet" && a.lang == _lang.Code)
?? _series.attachments.FirstOrDefault(a => a.kind == "datasheet");
if (ds == null) { MessageBox.Show(_lang.T("该系列无数据表", "No datasheet for this series"), Title); return; }
var dlg = new SaveFileDialog
{
Filter = "PDF (*.pdf)|*.pdf",
FileName = Path.GetFileName(ds.path.Replace('\\', '/'))
};
if (dlg.ShowDialog() != true) return;
try
{
File.WriteAllBytes(dlg.FileName, _pkg.ReadAsset(ds.path));
MessageBox.Show(_lang.T("已保存: ", "Saved: ") + dlg.FileName, Title, MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(_lang.T("保存失败: ", "Save failed: ") + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
void OpenDatasheet()
{
if (_series == null) return;
var ds = _series.attachments.FirstOrDefault(a => a.kind == "datasheet" && a.lang == _lang.Code)
?? _series.attachments.FirstOrDefault(a => a.kind == "datasheet");
if (ds == null) { MessageBox.Show(_lang.T("该系列无数据表", "No datasheet for this series"), Title); return; }
try
{
var tmp = Path.Combine(Path.GetTempPath(), "ounibo", ds.path.Replace('/', '\\'));
Directory.CreateDirectory(Path.GetDirectoryName(tmp));
File.WriteAllBytes(tmp, _pkg.ReadAsset(ds.path));
Process.Start(tmp);
}
catch (Exception ex)
{
MessageBox.Show(_lang.T("打开失败: ", "Open failed: ") + ex.Message, Title);
}
}
// ---- 语言切换 ----
void ApplyLang()
{
Title = _lang.T("欧霓博气动目录 ONEBOT Catalog", "ONEBOT Pneumatic Catalog");
_btnOpen.Content = _lang.T("打开数据包", "Open Catalog");
_btnDownload.Content = _lang.T("下载 STEP", "Download STEP");
_btnDownloadPdf.Content = _lang.T("下载 PDF", "Download PDF");
_btnLang.Content = _lang.Code == "zh" ? "EN" : "中";
#if !CUSTOMER_BUILD
_btnAdmin.Content = _lang.T("维护入口", "Admin");
#endif
if (_btnBrowser3D != null)
_btnBrowser3D.Content = _lang.T("在浏览器中打开 3D 预览", "Open 3D Preview in Browser");
_btnSearch.Content = _lang.T("搜索", "Search");
_btnExport.Content = _lang.T("加入清单", "Add to Cart");
_btnRangeApply.Content = _lang.T("应用", "Apply");
_btnRangeReset.Content = _lang.T("重置", "Reset");
_btnAddCart.Content = _lang.T("加入清单", "Add to Cart");
_btnCart.Content = _lang.T("清单 (" + _cart.Count + ")", "Cart (" + _cart.Count + ")");
if (_tabs != null && _tabs.Items.Count >= 4)
{
((TabItem)_tabs.Items[0]).Header = _lang.T("3D / 尺寸图", "3D / Dimension");
((TabItem)_tabs.Items[1]).Header = _lang.T("参数表", "Parameters");
((TabItem)_tabs.Items[2]).Header = _lang.T("搜索", "Search");
((TabItem)_tabs.Items[3]).Header = _lang.T("平替对照", "Cross-Reference");
}
if (_pkg != null)
{
foreach (TreeViewItem catNode in _tree.Items)
{
var cat = (Category)catNode.Tag;
catNode.Header = _lang.T(cat.nameZh, cat.nameEn);
foreach (TreeViewItem sNode in catNode.Items)
{
var s = (Series)sNode.Tag;
sNode.Header = _lang.T(s.nameZh, s.nameEn);
}
}
}
foreach (var row in _paramPanel.Children.OfType<StackPanel>())
{
var tb = row.Children.OfType<TextBlock>().FirstOrDefault();
var cb = row.Children.OfType<ComboBox>().FirstOrDefault();
if (tb != null && cb != null)
{
tb.Text = ParamLabel((ParameterDef)cb.Tag);
if (cb.ItemsSource != null)
{
var p = (ParameterDef)cb.Tag;
var items = cb.Items.OfType<ComboItem>().ToList();
cb.ItemsSource = items.Select(x => new ComboItem(x.Value, DisplayText(p, x.Value))).ToList();
}
}
}
if (_grid != null && _grid.Columns.Count > 0 && _series != null)
{
_grid.Columns[0].Header = _lang.T("型号 Model", "Model");
for (int i = 0; i < _series.parameters.Count && i + 1 < _grid.Columns.Count; i++)
{
var p = _series.parameters[i];
_grid.Columns[i + 1].Header = _lang.T(p.nameZh, p.nameEn) + (string.IsNullOrEmpty(p.unit) ? "" : " (" + p.unit + ")");
}
}
if (_pkgInfo != null && _pkg != null)
_pkgInfo.Text = string.Format("{0} v{1}", _lang.T(_pkg.Catalog.catalogName, _pkg.Catalog.catalogNameEn), _pkg.Catalog.catalogVersion);
if (_status != null && _cfg != null)
TryUpdateSelection();
}
}
}