首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)
This commit is contained in:
122
src/App/AdminWindow.cs
Normal file
122
src/App/AdminWindow.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
#if !CUSTOMER_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Ounibo.Catalog.Core;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
/// <summary>
|
||||
/// 维护入口 (M1): 数据包信息 + 系列清单 + 加载/替换 + 资源完整性校验。
|
||||
/// M2 将扩展 Builder 编辑功能。
|
||||
/// </summary>
|
||||
public class AdminWindow : Window
|
||||
{
|
||||
TextBlock _info;
|
||||
DataGrid _seriesGrid;
|
||||
ListBox _report;
|
||||
MainWindow _owner;
|
||||
|
||||
public AdminWindow(MainWindow owner)
|
||||
{
|
||||
_owner = owner;
|
||||
Title = "维护入口 - 数据包管理 / Admin - Package";
|
||||
Width = 640; Height = 520; MinWidth = 520; MinHeight = 400;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
Owner = owner;
|
||||
|
||||
var panel = new DockPanel { Margin = new Thickness(12) };
|
||||
_info = new TextBlock();
|
||||
DockPanel.SetDock(_info, Dock.Top);
|
||||
panel.Children.Add(_info);
|
||||
|
||||
var btns = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 8) };
|
||||
var btnLoad = new Button { Content = "加载新数据包 / Load Package", Width = 170, Margin = new Thickness(0, 0, 8, 0) };
|
||||
btnLoad.Click += (s, e) => LoadPackage();
|
||||
var btnCheck = new Button { Content = "校验 / Validate", Width = 120, Margin = new Thickness(0, 0, 8, 0) };
|
||||
btnCheck.Click += (s, e) => Validate();
|
||||
var btnBuilder = new Button { Content = "目录制作器 / Builder", Width = 150, Margin = new Thickness(0, 0, 8, 0) };
|
||||
btnBuilder.Click += (s, e) => { var w = new BuilderWindow(_owner); w.ShowDialog(); };
|
||||
var btnClose = new Button { Content = "关闭 / Close", Width = 90 };
|
||||
btnClose.Click += (s, e) => Close();
|
||||
btns.Children.Add(btnLoad); btns.Children.Add(btnCheck); btns.Children.Add(btnBuilder); btns.Children.Add(btnClose);
|
||||
DockPanel.SetDock(btns, Dock.Top);
|
||||
panel.Children.Add(btns);
|
||||
|
||||
_seriesGrid = new DataGrid { IsReadOnly = true, AutoGenerateColumns = false, Height = 220, Margin = new Thickness(0, 4, 0, 4) };
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "系列代码", Binding = new System.Windows.Data.Binding("Code"), Width = 80 });
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "中文名", Binding = new System.Windows.Data.Binding("Zh"), Width = 210 });
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "变体数", Binding = new System.Windows.Data.Binding("Variants"), Width = 70 });
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "参数", Binding = new System.Windows.Data.Binding("Params"), Width = 70 });
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "规则", Binding = new System.Windows.Data.Binding("Rules"), Width = 60 });
|
||||
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "附件", Binding = new System.Windows.Data.Binding("Atts"), Width = 60 });
|
||||
panel.Children.Add(_seriesGrid);
|
||||
|
||||
_report = new ListBox();
|
||||
panel.Children.Add(_report);
|
||||
|
||||
Content = panel;
|
||||
RefreshInfo();
|
||||
}
|
||||
|
||||
void RefreshInfo()
|
||||
{
|
||||
var p = _owner.Package;
|
||||
if (p == null)
|
||||
{
|
||||
_info.Text = "未加载数据包";
|
||||
_seriesGrid.ItemsSource = null;
|
||||
return;
|
||||
}
|
||||
var c = p.Catalog;
|
||||
_info.Text = string.Format(
|
||||
"数据包: {0} 文件: {1}\n目录版本: {2} | 格式版本: {3} 语言: {4}\n系列数: {5} | 变体数: {6} (与主窗口当前加载一致)",
|
||||
c.catalogName, System.IO.Path.GetFileName(p.Path), c.catalogVersion, c.schemaVersion,
|
||||
string.Join(",", c.langs ?? new List<string>()),
|
||||
c.series.Count, c.series.Sum(s => s.variants == null ? 0 : s.variants.Count));
|
||||
var dt = new DataTable();
|
||||
dt.Columns.Add("Code"); dt.Columns.Add("Zh"); dt.Columns.Add("Variants", typeof(int));
|
||||
dt.Columns.Add("Params", typeof(int)); dt.Columns.Add("Rules", typeof(int)); dt.Columns.Add("Atts", typeof(int));
|
||||
foreach (var s in c.series)
|
||||
dt.Rows.Add(s.code, s.nameZh, s.variants == null ? 0 : s.variants.Count,
|
||||
s.parameters == null ? 0 : s.parameters.Count, s.rules == null ? 0 : s.rules.Count,
|
||||
s.attachments == null ? 0 : s.attachments.Count);
|
||||
_seriesGrid.ItemsSource = dt.DefaultView;
|
||||
}
|
||||
|
||||
void Validate()
|
||||
{
|
||||
var p = _owner.Package;
|
||||
_report.Items.Clear();
|
||||
if (p == null) { _report.Items.Add("未加载数据包"); return; }
|
||||
var issues = PackageValidator.Validate(p);
|
||||
foreach (var i in issues)
|
||||
_report.Items.Add("[" + i.Severity + "] " + (string.IsNullOrEmpty(i.Target) ? "" : i.Target + " ") + i.Message);
|
||||
_report.Items.Add(string.Format("校验完成: {0} 个错误, {1} 个警告, 共 {2} 个变体",
|
||||
issues.Count(x => x.Severity == "ERROR"), issues.Count(x => x.Severity == "WARNING"),
|
||||
p.Catalog.series.Sum(s => s.variants == null ? 0 : s.variants.Count)));
|
||||
}
|
||||
|
||||
void LoadPackage()
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Filter = "目录数据包 (*.opc)|*.opc",
|
||||
Title = "选择 .opc 数据包"
|
||||
};
|
||||
if (dlg.ShowDialog() == true)
|
||||
{
|
||||
if (_owner.ReplacePackage(dlg.FileName))
|
||||
{
|
||||
MessageBox.Show("数据包已替换", "维护入口", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
RefreshInfo();
|
||||
_report.Items.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
284
src/App/App.cs
Normal file
284
src/App/App.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Ounibo.Catalog.Core;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
/// <summary>嵌入资源加载 (logo 等编译进 exe 的资源)。</summary>
|
||||
public static class AppResources
|
||||
{
|
||||
public static System.Windows.Media.Imaging.BitmapImage LoadPng(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
|
||||
if (stream == null) return null;
|
||||
var bmp = new System.Windows.Media.Imaging.BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.StreamSource = stream;
|
||||
bmp.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||||
bmp.EndInit();
|
||||
return bmp;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 程序入口。
|
||||
/// 开发版: OnebotCatalog.exe [数据包.opc] [--admin] [--selftest 数据包.opc]
|
||||
/// 客户版 (CUSTOMER_BUILD): 启动时先找 exe 旁 catalog.opc, 找不到读嵌入资源;
|
||||
/// 无维护入口、无自测。
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
#if !CUSTOMER_BUILD
|
||||
// 自测模式: 无界面运行核心逻辑, 结果写 selftest.log
|
||||
if (args.Length >= 2 && args[0] == "--selftest")
|
||||
{
|
||||
Environment.ExitCode = SelfTest.Run(args[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI 打包模式 (与向导同一 Builder 内核):
|
||||
// --build <csv> <stepDir> <out.opc> [--series CODE] [--meta 元数据.json]
|
||||
// 无 --meta 时参数自动识别 (无规则/附件/关键词); 结果写 <out 同目录>\build.log
|
||||
if (args.Length >= 4 && args[0] == "--build")
|
||||
{
|
||||
var csv = args[1]; var stepDir = args[2]; var outP = args[3];
|
||||
string seriesCode = "S1";
|
||||
string metaPath = null;
|
||||
for (int i = 4; i + 1 < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "--series") seriesCode = args[i + 1];
|
||||
if (args[i] == "--meta") metaPath = args[i + 1];
|
||||
}
|
||||
|
||||
Series series;
|
||||
var atts = new List<BuildAttachmentSpec>();
|
||||
if (metaPath != null)
|
||||
{
|
||||
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
|
||||
var meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
|
||||
series = meta.series ?? new Series();
|
||||
if (series.parameters == null || series.parameters.Count == 0)
|
||||
series.parameters = OpcBuilder.AutoDetectParameters(csv);
|
||||
atts = meta.attachments ?? new List<BuildAttachmentSpec>();
|
||||
}
|
||||
else
|
||||
{
|
||||
series = new Series { parameters = OpcBuilder.AutoDetectParameters(csv) };
|
||||
}
|
||||
series.code = string.IsNullOrEmpty(series.code) ? seriesCode : series.code;
|
||||
series.nameZh = string.IsNullOrEmpty(series.nameZh) ? seriesCode : series.nameZh;
|
||||
series.nameEn = string.IsNullOrEmpty(series.nameEn) ? seriesCode : series.nameEn;
|
||||
series.naming = series.naming ?? new Naming { stepNameTemplate = "{model}.step" };
|
||||
series.attachments = series.attachments ?? new List<Attachment>();
|
||||
series.variants = new List<Variant>();
|
||||
|
||||
var res = OpcBuilder.Build(outP, "欧霓博气动目录", "0.1", "ACT", "产品", "Products",
|
||||
series, csv, stepDir, atts);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(res.Ok ? "BUILD OK: " + outP : "BUILD FAILED");
|
||||
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
|
||||
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
|
||||
if (res.Ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var built = OpcPackage.Open(outP))
|
||||
{
|
||||
var issues = PackageValidator.Validate(built);
|
||||
sb.AppendLine("变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
|
||||
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
|
||||
foreach (var i in issues) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); Environment.ExitCode = 1; return; }
|
||||
}
|
||||
else Environment.ExitCode = 1;
|
||||
File.WriteAllText(Path.Combine(Path.GetDirectoryName(outP) ?? ".", "build.log"), sb.ToString(), Encoding.UTF8);
|
||||
return;
|
||||
}
|
||||
|
||||
// CLI 全量打包 (统一路线): --buildfull <源目录> <out.opc>
|
||||
// 源目录结构见 FullCatalogBuilder; 结果写 <out 同目录>\build.log
|
||||
if (args.Length >= 3 && args[0] == "--buildfull")
|
||||
{
|
||||
var res = FullCatalogBuilder.BuildFromSourceDir(args[1], args[2]);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(res.Ok ? "BUILDFULL OK: " + args[2] : "BUILDFULL FAILED");
|
||||
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
|
||||
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
|
||||
if (res.Ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var built = OpcPackage.Open(args[2]))
|
||||
{
|
||||
sb.AppendLine("系列数: " + built.Catalog.series.Count + " | 变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
|
||||
var issues = PackageValidator.Validate(built);
|
||||
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
|
||||
foreach (var i in issues) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); Environment.ExitCode = 1; }
|
||||
}
|
||||
else Environment.ExitCode = 1;
|
||||
File.WriteAllText(Path.Combine(Path.GetDirectoryName(args[2]) ?? ".", "build.log"), sb.ToString(), Encoding.UTF8);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
string opc = null;
|
||||
foreach (var a in args)
|
||||
{
|
||||
if (a.EndsWith(".opc", StringComparison.OrdinalIgnoreCase)) opc = a;
|
||||
}
|
||||
|
||||
#if CUSTOMER_BUILD
|
||||
OpcPackage pkg = null;
|
||||
if (opc != null)
|
||||
{
|
||||
try { pkg = OpcPackage.Open(opc); }
|
||||
catch (Exception ex) { ShowFatal(ex.Message); return; }
|
||||
}
|
||||
else pkg = TryOpenEmbedded();
|
||||
if (pkg == null)
|
||||
{
|
||||
// 无嵌入数据且无外置数据包 → 让用户选择 .opc
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "目录数据包 (*.opc)|*.opc", Title = "选择 .opc 数据包" };
|
||||
if (dlg.ShowDialog() == true)
|
||||
{
|
||||
try { pkg = OpcPackage.Open(dlg.FileName); }
|
||||
catch (Exception ex) { ShowFatal(ex.Message); return; }
|
||||
}
|
||||
else return;
|
||||
}
|
||||
Run(pkg, false);
|
||||
#else
|
||||
bool admin = false;
|
||||
foreach (var a in args) if (a == "--admin") admin = true;
|
||||
if (admin && !PromptPassword())
|
||||
return;
|
||||
if (opc == null)
|
||||
opc = FindDefaultPackage();
|
||||
OpcPackage pkg = null;
|
||||
if (opc != null)
|
||||
{
|
||||
try { pkg = OpcPackage.Open(opc); }
|
||||
catch (Exception ex) { ShowFatal(ex.Message); return; }
|
||||
}
|
||||
else pkg = TryOpenEmbedded();
|
||||
Run(pkg, admin);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void Run(OpcPackage pkg, bool admin)
|
||||
{
|
||||
var app = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose };
|
||||
// 全局异常日志 (crash.log, 与 exe 同目录), UI 异常不崩溃
|
||||
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "crash.log");
|
||||
app.DispatcherUnhandledException += (s, e) =>
|
||||
{
|
||||
try { File.AppendAllText(logPath, DateTime.Now + " UI异常: " + e.Exception + "\r\n", Encoding.UTF8); } catch { }
|
||||
e.Handled = true;
|
||||
};
|
||||
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
|
||||
{
|
||||
try { File.AppendAllText(logPath, DateTime.Now + " 未处理异常: " + e.ExceptionObject + "\r\n", Encoding.UTF8); } catch { }
|
||||
};
|
||||
app.Run(new MainWindow(pkg, admin));
|
||||
}
|
||||
|
||||
static void ShowFatal(string msg)
|
||||
{
|
||||
MessageBox.Show("数据包打开失败: " + msg, "欧霓博气动目录 ONEBOT Catalog", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 先找 exe 旁 catalog.opc (外置数据包, 支持增量更新), 找不到读嵌入资源。
|
||||
/// </summary>
|
||||
static OpcPackage TryOpenEmbedded()
|
||||
{
|
||||
var external = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "catalog.opc");
|
||||
if (File.Exists(external))
|
||||
{
|
||||
try { return OpcPackage.Open(external); }
|
||||
catch { /* 外置包损坏则回退嵌入资源 */ }
|
||||
}
|
||||
try
|
||||
{
|
||||
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("catalog.opc");
|
||||
if (stream != null)
|
||||
return OpcPackage.OpenStream(stream, "内置数据包 (catalog.opc)");
|
||||
}
|
||||
catch { /* 无嵌入资源 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
#if !CUSTOMER_BUILD
|
||||
static string FindDefaultPackage()
|
||||
{
|
||||
var dirs = new[]
|
||||
{
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "sample"),
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample")
|
||||
};
|
||||
foreach (var d in dirs)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(d)) continue;
|
||||
var f = Directory.GetFiles(d, "*.opc").FirstOrDefault();
|
||||
if (f != null) return f;
|
||||
}
|
||||
catch { /* 忽略目录访问失败 */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 维护入口密码。TODO: 移到配置文件, 支持修改。
|
||||
/// </summary>
|
||||
static bool PromptPassword()
|
||||
{
|
||||
var win = new Window
|
||||
{
|
||||
Title = "维护入口 Admin",
|
||||
Width = 360, Height = 180, MinWidth = 320, MinHeight = 150,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
};
|
||||
var panel = new StackPanel { Margin = new Thickness(16) };
|
||||
panel.Children.Add(new TextBlock { Text = "请输入维护密码 / Enter admin password:", Margin = new Thickness(0, 0, 0, 8) });
|
||||
var box = new PasswordBox { Margin = new Thickness(0, 0, 0, 12) };
|
||||
panel.Children.Add(box);
|
||||
var btns = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
var ok = new Button { Content = "确定 OK", Width = 90, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
|
||||
var cancel = new Button { Content = "取消 Cancel", Width = 90, IsCancel = true };
|
||||
btns.Children.Add(ok); btns.Children.Add(cancel);
|
||||
panel.Children.Add(btns);
|
||||
win.Content = panel;
|
||||
|
||||
bool granted = false;
|
||||
ok.Click += (s, e) =>
|
||||
{
|
||||
if (box.Password == "onebot888") { granted = true; win.Close(); }
|
||||
else MessageBox.Show("密码错误 / Wrong password", "维护入口", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
};
|
||||
win.ShowDialog();
|
||||
return granted;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
840
src/App/BuilderWindow.cs
Normal file
840
src/App/BuilderWindow.cs
Normal file
@@ -0,0 +1,840 @@
|
||||
#if !CUSTOMER_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using Ounibo.Catalog.Core;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
public class ParamRow
|
||||
{
|
||||
public string Code { get; set; }
|
||||
public string NameZh { get; set; }
|
||||
public string NameEn { get; set; }
|
||||
public string Unit { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Values { get; set; }
|
||||
public string Keywords { get; set; }
|
||||
public string Display { get; set; } // 显示名映射, 格式 值:显示,值:显示 (如 :基本型,M:附磁石 M)
|
||||
}
|
||||
|
||||
public class RuleRow
|
||||
{
|
||||
public string CondParam { get; set; }
|
||||
public string CondOp { get; set; }
|
||||
public string CondValue { get; set; }
|
||||
public string ThenParam { get; set; }
|
||||
public string ThenOp { get; set; }
|
||||
public string ThenValues { get; set; }
|
||||
}
|
||||
|
||||
public class AttachmentRow
|
||||
{
|
||||
public string Kind { get; set; }
|
||||
public string Lang { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string Model { get; set; }
|
||||
}
|
||||
|
||||
public class SeriesChoice
|
||||
{
|
||||
public Series Series;
|
||||
public override string ToString()
|
||||
{
|
||||
return Series.code + " - " + Series.nameZh + " (" + (Series.variants == null ? 0 : Series.variants.Count) + " 变体)";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 维护入口内的 Builder (M2): 5 步向导
|
||||
/// 导入 CSV+STEP 目录 → 参数定义 → 编码确认 → 选型规则 → 附件与生成 .opc。
|
||||
/// </summary>
|
||||
public class BuilderWindow : Window
|
||||
{
|
||||
MainWindow _owner;
|
||||
TextBox _tbSeriesCode, _tbSeriesZh, _tbSeriesEn, _tbCatName, _tbVer;
|
||||
TextBox _tbCsv, _tbStepDir;
|
||||
TextBlock _lbPreviewCount;
|
||||
DataGrid _gridPreview, _gridParams, _gridRules, _gridAttach;
|
||||
TextBox _tbOut;
|
||||
TextBlock _tbResult;
|
||||
TextBox _tbSrcDir;
|
||||
ComboBox _cbSeries;
|
||||
string _loadedMetaPath;
|
||||
BindingList<ParamRow> _paramRows = new BindingList<ParamRow>();
|
||||
BindingList<RuleRow> _ruleRows = new BindingList<RuleRow>();
|
||||
BindingList<AttachmentRow> _attRows = new BindingList<AttachmentRow>();
|
||||
string[] _header;
|
||||
List<string[]> _csvRows = new List<string[]>();
|
||||
|
||||
public BuilderWindow(MainWindow owner)
|
||||
{
|
||||
_owner = owner;
|
||||
Title = "目录制作器 Builder (维护入口)";
|
||||
Width = 900; Height = 720; MinWidth = 760; MinHeight = 560;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
Owner = owner;
|
||||
|
||||
var root = new DockPanel { Margin = new Thickness(10) };
|
||||
|
||||
// 系列与目录信息
|
||||
var info = new Grid { Margin = new Thickness(0, 0, 0, 8) };
|
||||
for (int i = 0; i < 4; i++) info.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
info.RowDefinitions.Add(new RowDefinition()); info.RowDefinitions.Add(new RowDefinition()); info.RowDefinitions.Add(new RowDefinition());
|
||||
_tbSeriesCode = AddField(info, 0, 0, "系列代码 Series code");
|
||||
_tbSeriesZh = AddField(info, 1, 0, "系列中文名 Name (zh)");
|
||||
_tbSeriesEn = AddField(info, 2, 0, "系列英文名 Name (en)");
|
||||
_tbCatName = AddField(info, 3, 0, "分类名 Category");
|
||||
_tbVer = AddField(info, 0, 1, "目录版本 Version");
|
||||
var cfgBtns = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
|
||||
var btnSaveCfg = new Button { Content = "保存配置", Width = 100, Margin = new Thickness(0, 0, 8, 0) };
|
||||
btnSaveCfg.Click += (s, e) => SaveConfig();
|
||||
var btnLoadCfg = new Button { Content = "加载配置", Width = 100 };
|
||||
btnLoadCfg.Click += (s, e) => LoadConfig();
|
||||
cfgBtns.Children.Add(btnSaveCfg); cfgBtns.Children.Add(btnLoadCfg);
|
||||
Grid.SetColumn(cfgBtns, 1); Grid.SetRow(cfgBtns, 1);
|
||||
info.Children.Add(cfgBtns);
|
||||
DockPanel.SetDock(info, Dock.Top);
|
||||
root.Children.Add(info);
|
||||
|
||||
var tabs = new TabControl();
|
||||
tabs.Items.Add(BuildStep1());
|
||||
tabs.Items.Add(BuildStep2());
|
||||
tabs.Items.Add(BuildStep3());
|
||||
tabs.Items.Add(BuildStep4());
|
||||
tabs.Items.Add(BuildStep5());
|
||||
root.Children.Add(tabs);
|
||||
Content = root;
|
||||
ReloadSeriesList();
|
||||
}
|
||||
|
||||
TextBox AddField(Grid g, int col, int row, string hint)
|
||||
{
|
||||
var tb = new TextBox { Margin = new Thickness(0, 0, 8, 4) };
|
||||
Grid.SetColumn(tb, col); Grid.SetRow(tb, row);
|
||||
g.Children.Add(tb);
|
||||
if (!string.IsNullOrEmpty(hint)) tb.ToolTip = hint;
|
||||
return tb;
|
||||
}
|
||||
|
||||
// ---- Step1: 导入 ----
|
||||
TabItem BuildStep1()
|
||||
{
|
||||
var panel = new DockPanel { Margin = new Thickness(6) };
|
||||
|
||||
// 数据源目录 (统一路线): 选目录 → 选系列 → 编辑 → 重建全量目录
|
||||
var srcRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
srcRow.Children.Add(new TextBlock { Text = "数据源目录:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) });
|
||||
_tbSrcDir = new TextBox { Width = 300, Margin = new Thickness(0, 0, 6, 0), Text = DefaultSourceDir() };
|
||||
var btnSrc = new Button { Content = "选择...", Width = 60, Margin = new Thickness(0, 0, 10, 0) };
|
||||
btnSrc.Click += (s, e) =>
|
||||
{
|
||||
var dlg = new System.Windows.Forms.FolderBrowserDialog { Description = "选择目录源目录 (含 catalog.json + series\\ + csv\\ + step\\)" };
|
||||
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
_tbSrcDir.Text = dlg.SelectedPath;
|
||||
ReloadSeriesList();
|
||||
}
|
||||
};
|
||||
_cbSeries = new ComboBox { Width = 150, Margin = new Thickness(0, 0, 10, 0) };
|
||||
_cbSeries.SelectionChanged += (s, e) => OpenSeriesFromSource();
|
||||
var btnRebuild = new Button { Content = "重建全量目录", Width = 120, FontWeight = System.Windows.FontWeights.Bold };
|
||||
btnRebuild.Click += (s, e) => RebuildFull();
|
||||
srcRow.Children.Add(_tbSrcDir); srcRow.Children.Add(btnSrc); srcRow.Children.Add(_cbSeries); srcRow.Children.Add(btnRebuild);
|
||||
DockPanel.SetDock(srcRow, Dock.Top);
|
||||
panel.Children.Add(srcRow);
|
||||
|
||||
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
var btnCsv = new Button { Content = "选择参数表 CSV", Width = 130, Margin = new Thickness(0, 0, 6, 0) };
|
||||
btnCsv.Click += (s, e) => LoadCsv();
|
||||
_tbCsv = new TextBox { Width = 320, Margin = new Thickness(0, 0, 10, 0), IsReadOnly = true };
|
||||
var btnDir = new Button { Content = "选择 STEP 目录", Width = 130, Margin = new Thickness(0, 0, 6, 0) };
|
||||
btnDir.Click += (s, e) => PickStepDir();
|
||||
_tbStepDir = new TextBox { Width = 240, IsReadOnly = true };
|
||||
top.Children.Add(btnCsv); top.Children.Add(_tbCsv); top.Children.Add(btnDir); top.Children.Add(_tbStepDir);
|
||||
var btnImport = new Button { Content = "从当前数据包导入系列", Width = 170, Margin = new Thickness(10, 0, 0, 0) };
|
||||
btnImport.Click += (s, e) => ImportFromPackage();
|
||||
top.Children.Add(btnImport);
|
||||
DockPanel.SetDock(top, Dock.Top);
|
||||
panel.Children.Add(top);
|
||||
|
||||
_lbPreviewCount = new TextBlock { Margin = new Thickness(0, 0, 0, 4) };
|
||||
DockPanel.SetDock(_lbPreviewCount, Dock.Top);
|
||||
panel.Children.Add(_lbPreviewCount);
|
||||
|
||||
_gridPreview = new DataGrid { IsReadOnly = true, AutoGenerateColumns = true };
|
||||
panel.Children.Add(_gridPreview);
|
||||
var tab = new TabItem { Header = "1. 导入 Import" };
|
||||
tab.Content = panel;
|
||||
return tab;
|
||||
}
|
||||
|
||||
void LoadCsv()
|
||||
{
|
||||
var dlg = new OpenFileDialog { Filter = "CSV 参数表 (*.csv)|*.csv", Title = "选择参数表 (首列=型号编码, 含 step_file 列)" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
_tbCsv.Text = dlg.FileName;
|
||||
PreviewCsv(dlg.FileName);
|
||||
AutoParams();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("CSV 读取失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
void PreviewCsv(string path)
|
||||
{
|
||||
var lines = File.ReadAllLines(path, System.Text.Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
|
||||
_header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
|
||||
_csvRows = lines.Skip(1).Select(l => l.Split(',')).Where(c => c.Length >= _header.Length && !string.IsNullOrWhiteSpace(c[0])).ToList();
|
||||
|
||||
var dt = new DataTable();
|
||||
foreach (var h in _header) dt.Columns.Add(h);
|
||||
foreach (var r in _csvRows.Take(50))
|
||||
{
|
||||
var row = dt.NewRow();
|
||||
for (int i = 0; i < _header.Length; i++) row[i] = r[i];
|
||||
dt.Rows.Add(row);
|
||||
}
|
||||
_gridPreview.ItemsSource = dt.DefaultView;
|
||||
_lbPreviewCount.Text = string.Format("共 {0} 行数据, 预览前 50 行", _csvRows.Count);
|
||||
}
|
||||
|
||||
// ---- 从当前数据包导入系列: 向导数据与运行目录保持一致 ----
|
||||
void ImportFromPackage()
|
||||
{
|
||||
var pkg = _owner.Package;
|
||||
if (pkg == null) { MessageBox.Show("主窗口未加载数据包, 请先打开 .opc", Title); return; }
|
||||
var cat = pkg.Catalog;
|
||||
|
||||
var picker = new Window { Title = "从当前数据包选择系列", Width = 420, Height = 420, MinWidth = 300, MinHeight = 300, WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this };
|
||||
var list = new ListBox { Margin = new Thickness(8) };
|
||||
foreach (var s in cat.series) list.Items.Add(new SeriesChoice { Series = s });
|
||||
var ok = new Button { Content = "确定 OK", Width = 90, IsDefault = true, Margin = new Thickness(8), HorizontalAlignment = HorizontalAlignment.Right };
|
||||
var dp = new DockPanel();
|
||||
ok.Click += (s2, e2) => picker.DialogResult = true;
|
||||
DockPanel.SetDock(ok, Dock.Bottom);
|
||||
dp.Children.Add(ok); dp.Children.Add(list);
|
||||
picker.Content = dp;
|
||||
if (picker.ShowDialog() != true || list.SelectedItem == null) return;
|
||||
var series = ((SeriesChoice)list.SelectedItem).Series;
|
||||
|
||||
try
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), "ounibo_builder", series.code);
|
||||
Directory.CreateDirectory(Path.Combine(tmp, "step"));
|
||||
|
||||
// CSV: model_code + 参数列 + step_file
|
||||
var sb = new StringBuilder();
|
||||
var header = new List<string> { "model_code" };
|
||||
foreach (var p in series.parameters ?? new List<ParameterDef>()) header.Add(p.code);
|
||||
header.Add("step_file");
|
||||
sb.AppendLine(string.Join(",", header));
|
||||
foreach (var v in series.variants ?? new List<Variant>())
|
||||
{
|
||||
var row = new List<string> { v.modelCode };
|
||||
foreach (var p in series.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
object val; string vs = v.@params != null && v.@params.TryGetValue(p.code, out val) ? Convert.ToString(val, CultureInfo.InvariantCulture) : "";
|
||||
if (vs.Contains(",") || vs.Contains("\"")) vs = "\"" + vs.Replace("\"", "\"\"") + "\"";
|
||||
row.Add(vs);
|
||||
}
|
||||
row.Add(Path.GetFileName((v.step ?? "").Replace('\\', '/')));
|
||||
sb.AppendLine(string.Join(",", row));
|
||||
}
|
||||
var csvPath = Path.Combine(tmp, series.code + ".csv");
|
||||
File.WriteAllText(csvPath, sb.ToString(), Encoding.UTF8);
|
||||
|
||||
// STEP 导出 (去重)
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var v in series.variants ?? new List<Variant>())
|
||||
{
|
||||
if (v == null || string.IsNullOrEmpty(v.step) || !seen.Add(v.step)) continue;
|
||||
try { File.WriteAllBytes(Path.Combine(tmp, "step", Path.GetFileName(v.step.Replace('\\', '/'))), pkg.ReadAsset(v.step)); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
// 填向导字段
|
||||
_tbSeriesCode.Text = series.code;
|
||||
_tbSeriesZh.Text = series.nameZh ?? "";
|
||||
_tbSeriesEn.Text = series.nameEn ?? "";
|
||||
_tbVer.Text = cat.catalogVersion ?? "";
|
||||
var catName = cat.categories.FirstOrDefault(c2 => c2.series != null && c2.series.Contains(series.code));
|
||||
_tbCatName.Text = catName != null ? (catName.nameZh ?? "") : "";
|
||||
_tbCsv.Text = csvPath;
|
||||
_tbStepDir.Text = Path.Combine(tmp, "step");
|
||||
PreviewCsv(csvPath);
|
||||
|
||||
// 参数行
|
||||
_paramRows.Clear();
|
||||
foreach (var p in series.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
_paramRows.Add(new ParamRow
|
||||
{
|
||||
Code = p.code,
|
||||
NameZh = p.nameZh ?? "",
|
||||
NameEn = p.nameEn ?? "",
|
||||
Unit = p.unit ?? "",
|
||||
Type = p.type == "number" ? "number" : "enum",
|
||||
Values = string.Join(",", (p.values ?? new List<object>()).Select(x => Convert.ToString(x, CultureInfo.InvariantCulture))),
|
||||
Keywords = p.keywords ?? ""
|
||||
});
|
||||
}
|
||||
_gridParams.ItemsSource = _paramRows;
|
||||
|
||||
// 规则行
|
||||
_ruleRows.Clear();
|
||||
foreach (var r in series.rules ?? new List<Ounibo.Catalog.Core.Rule>())
|
||||
{
|
||||
_ruleRows.Add(new RuleRow
|
||||
{
|
||||
CondParam = r.If != null ? r.If.Param : "",
|
||||
CondOp = r.If != null ? r.If.Op : "eq",
|
||||
CondValue = r.If != null ? ValToStr(r.If.Value) : "",
|
||||
ThenParam = r.Then != null ? r.Then.Param : "",
|
||||
ThenOp = r.Then != null ? r.Then.Op : "in",
|
||||
ThenValues = r.Then != null ? ValToStr(r.Then.Value) : ""
|
||||
});
|
||||
}
|
||||
_gridRules.ItemsSource = _ruleRows;
|
||||
|
||||
// 附件 (manual 等包内附件导出为文件)
|
||||
_attRows.Clear();
|
||||
foreach (var a in series.attachments ?? new List<Attachment>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(a.path)) continue;
|
||||
var f = Path.Combine(tmp, "manual", Path.GetFileName(a.path.Replace('\\', '/')));
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(f));
|
||||
File.WriteAllBytes(f, pkg.ReadAsset(a.path));
|
||||
_attRows.Add(new AttachmentRow { Kind = a.kind, Lang = a.lang ?? "", Source = f, Model = "" });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
_gridAttach.ItemsSource = _attRows;
|
||||
|
||||
_tbOut.Text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", series.code + "_edited.opc");
|
||||
MessageBox.Show(string.Format("已导入系列 {0}: {1} 个变体, {2} 个参数, {3} 条规则, {4} 个附件\n\n源文件在 {5}", series.code,
|
||||
series.variants == null ? 0 : series.variants.Count, _paramRows.Count, _ruleRows.Count, _attRows.Count, tmp), Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("导入失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
void PickStepDir()
|
||||
{
|
||||
var dlg = new System.Windows.Forms.FolderBrowserDialog { Description = "选择 STEP 文件目录" };
|
||||
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
_tbStepDir.Text = dlg.SelectedPath;
|
||||
}
|
||||
|
||||
// ---- 数据源目录 (统一路线) ----
|
||||
|
||||
string DefaultSourceDir()
|
||||
{
|
||||
// exe: <工作区>\OnebotCatalog\bin\ → 源目录 OnebotCatalog\onebot-data\catalog (自包含布局)
|
||||
return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "onebot-data", "catalog"));
|
||||
}
|
||||
|
||||
string GetSourceDir()
|
||||
{
|
||||
var d = _tbSrcDir != null ? _tbSrcDir.Text.Trim() : "";
|
||||
return Directory.Exists(d) ? d : null;
|
||||
}
|
||||
|
||||
void ReloadSeriesList()
|
||||
{
|
||||
_cbSeries.Items.Clear();
|
||||
var src = GetSourceDir();
|
||||
if (src == null) return;
|
||||
var dir = Path.Combine(src, "series");
|
||||
if (!Directory.Exists(dir)) return;
|
||||
foreach (var f in Directory.GetFiles(dir, "*.meta.json").OrderBy(x => x))
|
||||
{
|
||||
// KC.meta.json → KC (GetFileNameWithoutExtension 只剥 .json, 需再剥 .meta)
|
||||
_cbSeries.Items.Add(Path.GetFileName(f).Replace(".meta.json", ""));
|
||||
}
|
||||
}
|
||||
|
||||
void OpenSeriesFromSource()
|
||||
{
|
||||
if (_cbSeries.SelectedItem == null) return;
|
||||
var src = GetSourceDir();
|
||||
if (src == null) return;
|
||||
var code = (string)_cbSeries.SelectedItem;
|
||||
try
|
||||
{
|
||||
LoadMetaFile(Path.Combine(src, "series", code + ".meta.json"));
|
||||
var csvPath = Path.Combine(src, "csv", code + ".csv");
|
||||
if (File.Exists(csvPath)) { _tbCsv.Text = csvPath; PreviewCsv(csvPath); }
|
||||
_tbStepDir.Text = Path.Combine(src, "step");
|
||||
_tbVer.Text = ReadCatalogVersion(src) ?? "";
|
||||
_tbOut.Text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", code + "_edited.opc");
|
||||
}
|
||||
catch (Exception ex) { MessageBox.Show("打开系列失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
|
||||
}
|
||||
|
||||
string ReadCatalogVersion(string src)
|
||||
{
|
||||
try
|
||||
{
|
||||
var f = Path.Combine(src, "catalog.json");
|
||||
if (!File.Exists(f)) return null;
|
||||
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
|
||||
var cat = ser.Deserialize<Ounibo.Catalog.Core.Catalog>(File.ReadAllText(f, Encoding.UTF8));
|
||||
return cat == null ? null : cat.catalogVersion;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
void RebuildFull()
|
||||
{
|
||||
var src = GetSourceDir();
|
||||
if (src == null) { MessageBox.Show("数据源目录无效: " + (_tbSrcDir == null ? "" : _tbSrcDir.Text) + "\n(需含 catalog.json + series\\ + csv\\ + step\\)", Title); return; }
|
||||
var ver = ReadCatalogVersion(src) ?? "0.1";
|
||||
var outPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", "OnebotCatalog_" + ver + ".opc");
|
||||
var res = FullCatalogBuilder.BuildFromSourceDir(src, outPath);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(res.Ok ? "全量重建成功: " + outPath : "全量重建失败");
|
||||
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
|
||||
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
|
||||
if (res.Ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var built = OpcPackage.Open(outPath))
|
||||
{
|
||||
sb.AppendLine("系列数: " + built.Catalog.series.Count + " | 变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
|
||||
var issues = PackageValidator.Validate(built);
|
||||
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); }
|
||||
}
|
||||
if (res.Ok && MessageBox.Show(sb.ToString() + "\n\n是否加载到主窗口?", Title, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
|
||||
_owner.ReplacePackage(outPath);
|
||||
else
|
||||
MessageBox.Show(sb.ToString(), Title, MessageBoxButton.OK, res.Ok ? MessageBoxImage.Information : MessageBoxImage.Error);
|
||||
}
|
||||
|
||||
// 自动识别参数 (与 OpcBuilder.AutoDetectParameters 共用逻辑): 数值列 → number, 其他 → enum
|
||||
void AutoParams()
|
||||
{
|
||||
_paramRows.Clear();
|
||||
if (string.IsNullOrWhiteSpace(_tbCsv.Text)) return;
|
||||
foreach (var d in OpcBuilder.AutoDetectParameters(_tbCsv.Text))
|
||||
{
|
||||
_paramRows.Add(new ParamRow
|
||||
{
|
||||
Code = d.code,
|
||||
NameZh = d.code,
|
||||
NameEn = d.code,
|
||||
Unit = d.unit,
|
||||
Type = d.type,
|
||||
Values = string.Join(",", (d.values ?? new List<object>()).Select(v => Convert.ToString(v, CultureInfo.InvariantCulture))),
|
||||
Keywords = d.keywords ?? ""
|
||||
});
|
||||
}
|
||||
_gridParams.ItemsSource = _paramRows;
|
||||
}
|
||||
|
||||
// ---- Step2: 参数定义 ----
|
||||
TabItem BuildStep2()
|
||||
{
|
||||
_gridParams = new DataGrid { AutoGenerateColumns = false, Margin = new Thickness(6) };
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "参数代码 Code", Binding = new System.Windows.Data.Binding("Code"), IsReadOnly = true, Width = 100 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "中文名 NameZh", Binding = new System.Windows.Data.Binding("NameZh"), Width = 110 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "英文名 NameEn", Binding = new System.Windows.Data.Binding("NameEn"), Width = 110 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "单位 Unit", Binding = new System.Windows.Data.Binding("Unit"), Width = 55 });
|
||||
_gridParams.Columns.Add(new DataGridComboBoxColumn { Header = "类型 Type", SelectedItemBinding = new System.Windows.Data.Binding("Type"), ItemsSource = new[] { "number", "enum" }, Width = 85 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "取值 Values (逗号分隔)", Binding = new System.Windows.Data.Binding("Values"), Width = 170 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "搜索关键词 Keywords (拼音/英文)", Binding = new System.Windows.Data.Binding("Keywords"), Width = 150 });
|
||||
_gridParams.Columns.Add(new DataGridTextColumn { Header = "显示名映射 Display (值:显示,值:显示)", Binding = new System.Windows.Data.Binding("Display"), Width = 200 });
|
||||
_gridParams.ItemsSource = _paramRows;
|
||||
var hint = new TextBlock
|
||||
{
|
||||
Text = "参数定义: Code 不可改; 补全中英文名; Type: number(数值, 支持范围搜索) / enum(枚举); Values 逗号分隔; Keywords 填拼音等供搜索; Display 格式 值:显示,值:显示 (如 :基本型,M:附磁石)。",
|
||||
Margin = new Thickness(6), TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
var panel = new DockPanel();
|
||||
DockPanel.SetDock(hint, Dock.Top);
|
||||
panel.Children.Add(hint);
|
||||
panel.Children.Add(_gridParams);
|
||||
var tab = new TabItem { Header = "2. 参数 Parameters" };
|
||||
tab.Content = panel;
|
||||
return tab;
|
||||
}
|
||||
|
||||
// ---- Step3: 编码确认 ----
|
||||
TabItem BuildStep3()
|
||||
{
|
||||
var panel = new StackPanel { Margin = new Thickness(6) };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "型号编码 = 参数表首列 (model_code), 直接沿用, 不做模板反解。\nSTEP 文件命名 = 型号编码 + .step (对应 step_file 列)。\n生成时将校验: 编码唯一、STEP 文件存在、参数完整。",
|
||||
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 8)
|
||||
});
|
||||
var tab = new TabItem { Header = "3. 编码 Coding" };
|
||||
tab.Content = panel;
|
||||
return tab;
|
||||
}
|
||||
|
||||
// ---- Step4: 选型规则 ----
|
||||
TabItem BuildStep4()
|
||||
{
|
||||
var panel = new DockPanel { Margin = new Thickness(6) };
|
||||
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
var btnAdd = new Button { Content = "+ 添加规则", Width = 100, Margin = new Thickness(0, 0, 6, 0) };
|
||||
btnAdd.Click += (s, e) => _ruleRows.Add(new RuleRow { CondParam = "", CondOp = "eq", CondValue = "", ThenParam = "", ThenOp = "in", ThenValues = "" });
|
||||
var btnDel = new Button { Content = "- 删除所选", Width = 100 };
|
||||
btnDel.Click += (s, e) =>
|
||||
{
|
||||
var r = _gridRules.SelectedItem as RuleRow;
|
||||
if (r != null) _ruleRows.Remove(r);
|
||||
};
|
||||
top.Children.Add(btnAdd); top.Children.Add(btnDel);
|
||||
DockPanel.SetDock(top, Dock.Top);
|
||||
panel.Children.Add(top);
|
||||
|
||||
var hint = new TextBlock
|
||||
{
|
||||
Text = "规则: 若 条件(参数 运算符 值) 满足, 则 约束(参数 运算符 值列表) 必须满足, 否则该变体无效。\n例: 条件 bore eq 16 → 约束 stroke in 25,50 (即缸径16时行程仅可选25/50)。in 的值用逗号分隔。",
|
||||
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 6)
|
||||
};
|
||||
DockPanel.SetDock(hint, Dock.Top);
|
||||
panel.Children.Add(hint);
|
||||
|
||||
_gridRules = new DataGrid { AutoGenerateColumns = false };
|
||||
_gridRules.Columns.Add(new DataGridTextColumn { Header = "条件参数", Binding = new System.Windows.Data.Binding("CondParam"), Width = 105 });
|
||||
_gridRules.Columns.Add(new DataGridComboBoxColumn { Header = "条件运算符", SelectedItemBinding = new System.Windows.Data.Binding("CondOp"), ItemsSource = new[] { "eq", "ne", "in", "gt", "ge", "lt", "le", "between" }, Width = 95 });
|
||||
_gridRules.Columns.Add(new DataGridTextColumn { Header = "条件值", Binding = new System.Windows.Data.Binding("CondValue"), Width = 85 });
|
||||
_gridRules.Columns.Add(new DataGridTextColumn { Header = "约束参数", Binding = new System.Windows.Data.Binding("ThenParam"), Width = 105 });
|
||||
_gridRules.Columns.Add(new DataGridComboBoxColumn { Header = "约束运算符", SelectedItemBinding = new System.Windows.Data.Binding("ThenOp"), ItemsSource = new[] { "eq", "in" }, Width = 95 });
|
||||
_gridRules.Columns.Add(new DataGridTextColumn { Header = "约束值(逗号分隔)", Binding = new System.Windows.Data.Binding("ThenValues"), Width = 150 });
|
||||
_gridRules.ItemsSource = _ruleRows;
|
||||
panel.Children.Add(_gridRules);
|
||||
var tab = new TabItem { Header = "4. 规则 Rules" };
|
||||
tab.Content = panel;
|
||||
return tab;
|
||||
}
|
||||
|
||||
// ---- Step5: 附件与生成 ----
|
||||
TabItem BuildStep5()
|
||||
{
|
||||
var panel = new DockPanel { Margin = new Thickness(6) };
|
||||
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
||||
var btnAtt = new Button { Content = "+ 添加附件", Width = 100, Margin = new Thickness(0, 0, 6, 0) };
|
||||
btnAtt.Click += (s, e) =>
|
||||
{
|
||||
var dlg = new OpenFileDialog { Filter = "附件 (*.png;*.jpg;*.pdf)|*.png;*.jpg;*.pdf", Title = "选择附件 (尺寸图/数据表)" };
|
||||
if (dlg.ShowDialog() == true)
|
||||
_attRows.Add(new AttachmentRow { Kind = "dimDrawing", Lang = "zh", Source = dlg.FileName, Model = "" });
|
||||
};
|
||||
var btnDel = new Button { Content = "- 删除所选", Width = 100, Margin = new Thickness(0, 0, 10, 0) };
|
||||
btnDel.Click += (s, e) =>
|
||||
{
|
||||
var r = _gridAttach.SelectedItem as AttachmentRow;
|
||||
if (r != null) _attRows.Remove(r);
|
||||
};
|
||||
_tbOut = new TextBox { Width = 340, Text = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", "NewCatalog.opc") };
|
||||
top.Children.Add(btnAtt); top.Children.Add(btnDel); top.Children.Add(new TextBlock { Text = "输出 .opc:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }); top.Children.Add(_tbOut);
|
||||
var btnBuild = new Button { Content = "生成数据包 Build", Width = 130, Margin = new Thickness(10, 0, 0, 0) };
|
||||
btnBuild.Click += (s, e) => DoBuild();
|
||||
top.Children.Add(btnBuild);
|
||||
DockPanel.SetDock(top, Dock.Top);
|
||||
panel.Children.Add(top);
|
||||
|
||||
_gridAttach = new DataGrid { AutoGenerateColumns = false, Height = 200 };
|
||||
_gridAttach.Columns.Add(new DataGridComboBoxColumn { Header = "类型 Kind", SelectedItemBinding = new System.Windows.Data.Binding("Kind"), ItemsSource = new[] { "dimDrawing", "datasheet", "note", "manual" }, Width = 110 });
|
||||
_gridAttach.Columns.Add(new DataGridComboBoxColumn { Header = "语言 Lang", SelectedItemBinding = new System.Windows.Data.Binding("Lang"), ItemsSource = new[] { "zh", "en" }, Width = 65 });
|
||||
_gridAttach.Columns.Add(new DataGridTextColumn { Header = "源文件 Source", Binding = new System.Windows.Data.Binding("Source"), IsReadOnly = true, Width = 330 });
|
||||
_gridAttach.Columns.Add(new DataGridTextColumn { Header = "绑定型号 Model(可选)", Binding = new System.Windows.Data.Binding("Model"), Width = 120 });
|
||||
_gridAttach.ItemsSource = _attRows;
|
||||
DockPanel.SetDock(_gridAttach, Dock.Top);
|
||||
panel.Children.Add(_gridAttach);
|
||||
|
||||
_tbResult = new TextBlock { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 8, 0, 0) };
|
||||
panel.Children.Add(_tbResult);
|
||||
var tab = new TabItem { Header = "5. 附件与生成 Build" };
|
||||
tab.Content = panel;
|
||||
return tab;
|
||||
}
|
||||
|
||||
object ToNumOrStr(string s)
|
||||
{
|
||||
// 前导零的编码 (00/02/03) 保持字符串, 否则会被数值化成 0/2/3 破坏型号匹配
|
||||
if (s.Length > 1 && s.StartsWith("0") && s.All(char.IsDigit)) return s;
|
||||
double d;
|
||||
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return d;
|
||||
return s;
|
||||
}
|
||||
|
||||
List<object> ParseValues(string s)
|
||||
{
|
||||
// 注意: 保留空串 (枚举值 "" 合法, 如 磁石=无磁石)
|
||||
return s.Split(new[] { ',', ',', ';', ';' })
|
||||
.Select(x => x.Trim())
|
||||
.Select(x => (object)ToNumOrStr(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
Ounibo.Catalog.Core.Rule BuildRule(RuleRow r)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(r.CondParam) || string.IsNullOrWhiteSpace(r.ThenParam)) return null;
|
||||
RuleCond If, Then;
|
||||
If = new RuleCond { Param = r.CondParam.Trim(), Op = r.CondOp, Value = ToNumOrStr(r.CondValue.Trim()) };
|
||||
if (r.ThenOp == "in")
|
||||
Then = new RuleCond { Param = r.ThenParam.Trim(), Op = "in", Value = ParseValues(r.ThenValues).ToArray() };
|
||||
else
|
||||
Then = new RuleCond { Param = r.ThenParam.Trim(), Op = r.ThenOp, Value = ToNumOrStr(r.ThenValues.Trim()) };
|
||||
return new Ounibo.Catalog.Core.Rule { If = If, Then = Then };
|
||||
}
|
||||
|
||||
// ---- 配置保存/加载 (SeriesMeta 格式, 与源目录 series\*.meta.json 及 CLI --meta 完全通用) ----
|
||||
|
||||
SeriesMeta BuildMetaFromUi()
|
||||
{
|
||||
return new SeriesMeta
|
||||
{
|
||||
series = new Series
|
||||
{
|
||||
code = _tbSeriesCode.Text.Trim(),
|
||||
nameZh = _tbSeriesZh.Text.Trim(),
|
||||
nameEn = _tbSeriesEn.Text.Trim(),
|
||||
keywords = "",
|
||||
parameters = _paramRows.Where(p => !string.IsNullOrWhiteSpace(p.Code)).Select(p => new ParameterDef
|
||||
{
|
||||
code = p.Code,
|
||||
nameZh = p.NameZh,
|
||||
nameEn = p.NameEn,
|
||||
unit = p.Unit ?? "",
|
||||
type = p.Type == "number" ? "number" : "enum",
|
||||
keywords = p.Keywords ?? "",
|
||||
values = ParseValues(p.Values ?? ""),
|
||||
display = ParseDisplay(p.Display ?? "")
|
||||
}).ToList(),
|
||||
rules = _ruleRows.Select(BuildRule).Where(r => r != null).ToList(),
|
||||
naming = new Naming { stepNameTemplate = "{model}.step" },
|
||||
attachments = new List<Attachment>(),
|
||||
variants = new List<Variant>()
|
||||
},
|
||||
attachments = _attRows.Where(a => !string.IsNullOrWhiteSpace(a.Source)).Select(a => new BuildAttachmentSpec
|
||||
{
|
||||
kind = a.Kind,
|
||||
lang = a.Lang,
|
||||
source = a.Source,
|
||||
model = string.IsNullOrWhiteSpace(a.Model) ? null : a.Model.Trim()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, string> ParseDisplay(string s)
|
||||
{
|
||||
var d = new Dictionary<string, string>();
|
||||
if (string.IsNullOrWhiteSpace(s)) return d;
|
||||
foreach (var part in s.Split(new[] { ',', ',', ';', ';' }))
|
||||
{
|
||||
var kv = part.Split(new[] { ':' }, 2);
|
||||
if (kv.Length == 2) d[kv[0].Trim()] = kv[1].Trim();
|
||||
}
|
||||
return d.Count == 0 ? null : d;
|
||||
}
|
||||
|
||||
string FormatDisplay(Dictionary<string, string> d)
|
||||
{
|
||||
if (d == null || d.Count == 0) return "";
|
||||
return string.Join(",", d.Select(kv => kv.Key + ":" + kv.Value));
|
||||
}
|
||||
|
||||
// 保存到源目录 (series\<code>.meta.json) 或另存为
|
||||
void SaveConfig()
|
||||
{
|
||||
var code = _tbSeriesCode.Text.Trim();
|
||||
var srcDir = GetSourceDir();
|
||||
var target = Directory.Exists(srcDir) && !string.IsNullOrEmpty(code)
|
||||
? Path.Combine(srcDir, "series", code + ".meta.json")
|
||||
: null;
|
||||
if (target == null || (!File.Exists(target) && !string.IsNullOrEmpty(_loadedMetaPath)))
|
||||
{
|
||||
// 系列代码改名 → 沿用已加载文件路径
|
||||
if (!string.IsNullOrEmpty(_loadedMetaPath)) target = _loadedMetaPath;
|
||||
}
|
||||
if (target == null || !File.Exists(target))
|
||||
{
|
||||
var dlg = new SaveFileDialog { Filter = "Builder 配置 (*.json)|*.json", FileName = "builder-config.json", Title = "保存配置" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
target = dlg.FileName;
|
||||
}
|
||||
try
|
||||
{
|
||||
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
|
||||
var meta = BuildMetaFromUi();
|
||||
// 覆盖已有配置时保留系列关键词 (向导无此字段, 避免清空搜索关键词)
|
||||
if (File.Exists(target))
|
||||
{
|
||||
try
|
||||
{
|
||||
var old = ser.Deserialize<SeriesMeta>(File.ReadAllText(target, Encoding.UTF8));
|
||||
if (old != null && old.series != null && string.Equals(old.series.code, meta.series.code, StringComparison.OrdinalIgnoreCase))
|
||||
meta.series.keywords = old.series.keywords;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target));
|
||||
File.WriteAllText(target, ser.Serialize(meta), Encoding.UTF8);
|
||||
_loadedMetaPath = target;
|
||||
MessageBox.Show("配置已保存: " + target + (target.IndexOf("series" + Path.DirectorySeparatorChar) >= 0 ? "\n(点【重建全量目录】后生效)" : ""), Title);
|
||||
}
|
||||
catch (Exception ex) { MessageBox.Show("保存失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
|
||||
}
|
||||
|
||||
static string ValToStr(object v)
|
||||
{
|
||||
var arr = v as object[];
|
||||
if (arr != null) return string.Join(",", arr.Select(x => Convert.ToString(x, CultureInfo.InvariantCulture)));
|
||||
return v == null ? "" : Convert.ToString(v, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
void LoadConfig()
|
||||
{
|
||||
var dlg = new OpenFileDialog { Filter = "Builder 配置 (*.json)|*.json", Title = "加载配置" };
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
try { LoadMetaFile(dlg.FileName); }
|
||||
catch (Exception ex) { MessageBox.Show("加载失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
|
||||
}
|
||||
|
||||
void LoadMetaFile(string metaPath)
|
||||
{
|
||||
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
|
||||
var meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
|
||||
if (meta == null || meta.series == null) { MessageBox.Show("配置格式无效", Title); return; }
|
||||
_loadedMetaPath = metaPath;
|
||||
_tbSeriesCode.Text = meta.series.code ?? "";
|
||||
_tbSeriesZh.Text = meta.series.nameZh ?? "";
|
||||
_tbSeriesEn.Text = meta.series.nameEn ?? "";
|
||||
|
||||
_paramRows.Clear();
|
||||
foreach (var p in meta.series.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
_paramRows.Add(new ParamRow
|
||||
{
|
||||
Code = p.code,
|
||||
NameZh = p.nameZh,
|
||||
NameEn = p.nameEn,
|
||||
Unit = p.unit ?? "",
|
||||
Type = p.type,
|
||||
Keywords = p.keywords ?? "",
|
||||
Values = string.Join(",", (p.values ?? new List<object>()).Select(v => Convert.ToString(v, CultureInfo.InvariantCulture))),
|
||||
Display = FormatDisplay(p.display)
|
||||
});
|
||||
}
|
||||
_gridParams.ItemsSource = _paramRows;
|
||||
|
||||
_ruleRows.Clear();
|
||||
foreach (var r in meta.series.rules ?? new List<Ounibo.Catalog.Core.Rule>())
|
||||
{
|
||||
_ruleRows.Add(new RuleRow
|
||||
{
|
||||
CondParam = r.If != null ? r.If.Param : "",
|
||||
CondOp = r.If != null ? r.If.Op : "eq",
|
||||
CondValue = r.If != null ? ValToStr(r.If.Value) : "",
|
||||
ThenParam = r.Then != null ? r.Then.Param : "",
|
||||
ThenOp = r.Then != null ? r.Then.Op : "in",
|
||||
ThenValues = r.Then != null ? ValToStr(r.Then.Value) : ""
|
||||
});
|
||||
}
|
||||
_gridRules.ItemsSource = _ruleRows;
|
||||
|
||||
_attRows.Clear();
|
||||
foreach (var a in meta.attachments ?? new List<BuildAttachmentSpec>())
|
||||
{
|
||||
_attRows.Add(new AttachmentRow { Kind = a.kind, Lang = a.lang, Source = a.source, Model = a.model ?? "" });
|
||||
}
|
||||
_gridAttach.ItemsSource = _attRows;
|
||||
}
|
||||
|
||||
void DoBuild()
|
||||
{
|
||||
_tbResult.Text = "";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("开始打包...");
|
||||
|
||||
var series = new Series
|
||||
{
|
||||
code = _tbSeriesCode.Text.Trim(),
|
||||
nameZh = _tbSeriesZh.Text.Trim(),
|
||||
nameEn = _tbSeriesEn.Text.Trim(),
|
||||
keywords = "",
|
||||
parameters = _paramRows.Where(p => !string.IsNullOrWhiteSpace(p.Code)).Select(p => new ParameterDef
|
||||
{
|
||||
code = p.Code,
|
||||
nameZh = string.IsNullOrWhiteSpace(p.NameZh) ? p.Code : p.NameZh,
|
||||
nameEn = string.IsNullOrWhiteSpace(p.NameEn) ? p.Code : p.NameEn,
|
||||
unit = p.Unit ?? "",
|
||||
type = p.Type == "number" ? "number" : "enum",
|
||||
keywords = p.Keywords ?? "",
|
||||
values = ParseValues(p.Values ?? "")
|
||||
}).ToList(),
|
||||
rules = _ruleRows.Select(BuildRule).Where(r => r != null).ToList(),
|
||||
naming = new Naming { stepNameTemplate = "{model}.step" },
|
||||
attachments = new List<Attachment>(),
|
||||
variants = new List<Variant>()
|
||||
};
|
||||
if (string.IsNullOrEmpty(series.code)) { MessageBox.Show("请填写系列代码", Title); return; }
|
||||
if (_paramRows.Count == 0) { MessageBox.Show("请先在 Step1 导入参数表", Title); return; }
|
||||
|
||||
var res = OpcBuilder.Build(
|
||||
_tbOut.Text.Trim(),
|
||||
"欧霓博气动目录", _tbVer.Text.Trim(),
|
||||
"ACT", _tbCatName.Text.Trim(), _tbCatName.Text.Trim(),
|
||||
series,
|
||||
_tbCsv.Text, _tbStepDir.Text,
|
||||
_attRows.Where(a => !string.IsNullOrWhiteSpace(a.Source)).Select(a => new BuildAttachmentSpec
|
||||
{
|
||||
kind = a.Kind, lang = a.Lang, source = a.Source, model = string.IsNullOrWhiteSpace(a.Model) ? null : a.Model.Trim()
|
||||
}).ToList());
|
||||
|
||||
if (!res.Ok)
|
||||
{
|
||||
sb.AppendLine("打包失败:");
|
||||
foreach (var e in res.Errors.Take(20)) sb.AppendLine(" [错误] " + e);
|
||||
_tbResult.Text = sb.ToString();
|
||||
return;
|
||||
}
|
||||
sb.AppendLine("打包成功: " + res.OutputPath);
|
||||
foreach (var w in res.Warnings.Take(10)) sb.AppendLine(" [警告] " + w);
|
||||
|
||||
// 打包后立即校验
|
||||
try
|
||||
{
|
||||
using (var pkg = OpcPackage.Open(res.OutputPath))
|
||||
{
|
||||
var issues = PackageValidator.Validate(pkg);
|
||||
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
|
||||
foreach (var i in issues.Take(15)) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
|
||||
sb.AppendLine("变体数: " + pkg.Catalog.series.Sum(s2 => s2.variants.Count));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { sb.AppendLine("校验失败: " + ex.Message); }
|
||||
_tbResult.Text = sb.ToString();
|
||||
|
||||
if (MessageBox.Show("数据包已生成, 是否加载到主窗口?\n注意: 将替换主窗口当前数据包 (若只做了单系列, 加载后目录只剩该系列)", Title, MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes)
|
||||
_owner.ReplacePackage(res.OutputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
119
src/App/Cart.cs
Normal file
119
src/App/Cart.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
/// <summary>
|
||||
/// 购物车条目 (清单式批量导出; 2026-08-24 与网页版语义一致)。
|
||||
/// Qty 仅体现在 BOM 数量列, zip 中每型号只打包一份。
|
||||
/// </summary>
|
||||
public class CartItem : INotifyPropertyChanged
|
||||
{
|
||||
public string ModelCode; // 型号编码
|
||||
public string SeriesCode; // 系列代码
|
||||
public string SeriesName; // 系列名
|
||||
public string StepPath; // 包内 STEP 路径 (非标档位为 null, 打包时跳过并注明)
|
||||
public string ParamsSummary; // 参数摘要 "key=value; ..."
|
||||
public bool IsStandard = true;// 非标档位标记 (参数化引擎, 2026-08-25)
|
||||
public Dictionary<string, object> Params; // 参数原始值 (非标批量在线生成提交用, 二期)
|
||||
int _qty = 1;
|
||||
public int Qty { get { return _qty; } set { _qty = Math.Max(1, value); OnChanged("Qty"); } }
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
void OnChanged(string n) { if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(n)); }
|
||||
}
|
||||
|
||||
/// <summary>清单窗口 (非模态; 购物车数据在主窗口, 本窗口只是视图)。</summary>
|
||||
public class CartWindow : Window
|
||||
{
|
||||
List<CartItem> _cart;
|
||||
Action _onChanged; // 数量/删除/清空后回写角标
|
||||
Func<System.Threading.Tasks.Task<string>> _onDownload; // 打包回调 (异步, 含在线生成), 返回 null 成功 / 错误提示
|
||||
DataGrid _grid;
|
||||
TextBlock _countText;
|
||||
|
||||
public CartWindow(List<CartItem> cart, Action onChanged, Func<System.Threading.Tasks.Task<string>> onDownload)
|
||||
{
|
||||
_cart = cart;
|
||||
_onChanged = onChanged;
|
||||
_onDownload = onDownload;
|
||||
Title = "我的清单 My Cart";
|
||||
Width = 800; Height = 500; MinWidth = 580; MinHeight = 320;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
Owner = Application.Current != null ? Application.Current.MainWindow : null;
|
||||
|
||||
var root = new DockPanel { Margin = new Thickness(10) };
|
||||
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
|
||||
_countText = new TextBlock { VerticalAlignment = VerticalAlignment.Center, FontWeight = FontWeights.Bold, FontSize = 14 };
|
||||
top.Children.Add(_countText);
|
||||
DockPanel.SetDock(top, Dock.Top);
|
||||
root.Children.Add(top);
|
||||
|
||||
var bottom = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
|
||||
var btnClear = new Button { Content = "清空 Clear All", Width = 100, Margin = new Thickness(0, 0, 8, 0) };
|
||||
btnClear.Click += (s, e) => { _cart.Clear(); Refresh(); };
|
||||
var btnDownload = new Button { Content = "下载全部 (zip + BOM)", Width = 190, Padding = new Thickness(12, 6, 12, 6), FontSize = 14 };
|
||||
btnDownload.Click += async (s, e) =>
|
||||
{
|
||||
if (_cart.Count == 0) { MessageBox.Show("清单为空, 请先加入型号", Title); return; }
|
||||
btnDownload.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var err = await _onDownload();
|
||||
if (err != null) MessageBox.Show(err, Title, MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
finally { btnDownload.IsEnabled = true; }
|
||||
};
|
||||
bottom.Children.Add(btnClear); bottom.Children.Add(btnDownload);
|
||||
DockPanel.SetDock(bottom, Dock.Bottom);
|
||||
root.Children.Add(bottom);
|
||||
|
||||
_grid = new DataGrid { AutoGenerateColumns = false, IsReadOnly = false, CanUserAddRows = false, CanUserDeleteRows = false };
|
||||
_grid.Columns.Add(new DataGridTextColumn { Header = "型号 Model", Binding = new System.Windows.Data.Binding("ModelCode"), Width = 150, IsReadOnly = true });
|
||||
_grid.Columns.Add(new DataGridTextColumn { Header = "系列 Series", Binding = new System.Windows.Data.Binding("SeriesName"), Width = new System.Windows.Controls.DataGridLength(1, System.Windows.Controls.DataGridLengthUnitType.Star), IsReadOnly = true });
|
||||
_grid.Columns.Add(new DataGridTextColumn { Header = "规格 Params", Binding = new System.Windows.Data.Binding("ParamsSummary"), Width = 200, IsReadOnly = true });
|
||||
_grid.Columns.Add(new DataGridTextColumn { Header = "数量 Qty", Binding = new System.Windows.Data.Binding("Qty") { UpdateSourceTrigger = System.Windows.Data.UpdateSourceTrigger.LostFocus }, Width = 70 });
|
||||
var delCol = new DataGridTemplateColumn { Header = "操作", Width = 80 };
|
||||
delCol.CellTemplate = MakeTemplate(item => { _cart.Remove(item); Refresh(); });
|
||||
_grid.Columns.Add(delCol);
|
||||
_grid.CellEditEnding += (s, e) => { Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, new Action(Refresh)); };
|
||||
root.Children.Add(_grid);
|
||||
|
||||
Content = root;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
System.Windows.DataTemplate MakeTemplate(Action<CartItem> del)
|
||||
{
|
||||
var fact = new System.Windows.FrameworkElementFactory(typeof(Button));
|
||||
fact.SetValue(Button.ContentProperty, "删除");
|
||||
fact.SetValue(Button.PaddingProperty, new Thickness(6, 2, 6, 2));
|
||||
fact.SetValue(Button.FontSizeProperty, 12.0);
|
||||
fact.SetValue(Button.FocusableProperty, false); // 不抢焦点, 避免干扰网格行交互
|
||||
fact.AddHandler(Button.ClickEvent, new RoutedEventHandler((s, e) =>
|
||||
{
|
||||
var dc = ((FrameworkElement)s).DataContext as CartItem; // 模板列取 DataContext 删行
|
||||
if (dc != null) del(dc);
|
||||
e.Handled = true;
|
||||
}));
|
||||
var tpl = new System.Windows.DataTemplate();
|
||||
tpl.VisualTree = fact;
|
||||
return tpl;
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
_countText.Text = "共 " + _cart.Count + " 项 | 数量只在 BOM 中体现, zip 每型号一份";
|
||||
_grid.ItemsSource = null;
|
||||
_grid.ItemsSource = _cart;
|
||||
if (_onChanged != null) _onChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/App/LocalViewerServer.cs
Normal file
133
src/App/LocalViewerServer.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
/// <summary>
|
||||
/// 本机 3D 预览微型服务器 (真实数模用, 完全离线):
|
||||
/// 在 127.0.0.1 随机端口服务 新迪查看器本地副本 + 临时 STEP 文件,
|
||||
/// 桌面版点【在浏览器中打开 3D 预览】即用系统浏览器打开, 无外部依赖。
|
||||
/// </summary>
|
||||
public class LocalViewerServer : IDisposable
|
||||
{
|
||||
readonly TcpListener _listener;
|
||||
readonly Thread _thread;
|
||||
readonly string _viewerRoot;
|
||||
readonly string _stepDir;
|
||||
volatile bool _running = true;
|
||||
public int Port { get; private set; }
|
||||
|
||||
static readonly Dictionary<string, string> Mime = new Dictionary<string, string>
|
||||
{
|
||||
{ ".html", "text/html; charset=utf-8" }, { ".js", "application/javascript; charset=utf-8" },
|
||||
{ ".css", "text/css; charset=utf-8" }, { ".png", "image/png" }, { ".jpg", "image/jpeg" },
|
||||
{ ".wasm", "application/wasm" }, { ".ico", "image/x-icon" }, { ".json", "application/json; charset=utf-8" },
|
||||
{ ".step", "application/octet-stream" }, { ".stp", "application/octet-stream" }
|
||||
};
|
||||
|
||||
public LocalViewerServer(string viewerRoot)
|
||||
{
|
||||
_viewerRoot = Path.GetFullPath(viewerRoot);
|
||||
_stepDir = Path.Combine(Path.GetTempPath(), "onebot_preview");
|
||||
Directory.CreateDirectory(_stepDir);
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
_thread = new Thread(Loop) { IsBackground = true, Name = "LocalViewerServer" };
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
void Loop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
TcpClient client = null;
|
||||
try { client = _listener.AcceptTcpClient(); }
|
||||
catch { if (!_running) break; Thread.Sleep(500); continue; }
|
||||
try { Handle(client); }
|
||||
catch { }
|
||||
try { client.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
void Handle(TcpClient client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
stream.ReadTimeout = 3000;
|
||||
stream.WriteTimeout = 15000;
|
||||
var reader = new StreamReader(stream, Encoding.ASCII, false, 4096, true);
|
||||
var reqLine = reader.ReadLine();
|
||||
if (string.IsNullOrEmpty(reqLine)) return;
|
||||
var line = reader.ReadLine();
|
||||
var guard = 0;
|
||||
while (line != null && line != "")
|
||||
{
|
||||
line = reader.ReadLine();
|
||||
if (++guard > 100) break;
|
||||
}
|
||||
|
||||
var path = "/";
|
||||
var parts = reqLine.Split(' ');
|
||||
if (parts.Length >= 2) path = parts[1];
|
||||
var qi = path.IndexOf('?');
|
||||
if (qi >= 0) path = path.Substring(0, qi);
|
||||
if (path == "/") path = "/STEPViewer/index.html";
|
||||
|
||||
// 双根: /STEPViewer/... → 查看器副本; /step/... → 临时 STEP 目录
|
||||
string root, rel;
|
||||
if (path.StartsWith("/step/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
root = _stepDir;
|
||||
rel = path.Substring("/step/".Length).Replace('/', '\\');
|
||||
}
|
||||
else
|
||||
{
|
||||
root = _viewerRoot;
|
||||
rel = path.Substring(1).Replace('/', '\\');
|
||||
}
|
||||
|
||||
byte[] bytes; string status = "200 OK"; string ctype = "application/octet-stream";
|
||||
var full = Path.GetFullPath(Path.Combine(root, rel));
|
||||
if (full.StartsWith(Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase) && File.Exists(full))
|
||||
{
|
||||
bytes = File.ReadAllBytes(full);
|
||||
var ext = Path.GetExtension(full).ToLowerInvariant();
|
||||
if (Mime.ContainsKey(ext)) ctype = Mime[ext];
|
||||
}
|
||||
else
|
||||
{
|
||||
status = "404 Not Found";
|
||||
bytes = Encoding.UTF8.GetBytes("404 Not Found");
|
||||
ctype = "text/plain; charset=utf-8";
|
||||
}
|
||||
var head = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 " + status + "\r\nContent-Type: " + ctype +
|
||||
"\r\nCache-Control: no-cache\r\nContent-Length: " + bytes.Length +
|
||||
"\r\nConnection: close\r\n\r\n");
|
||||
stream.Write(head, 0, head.Length);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
/// <summary>把 STEP 内容写入临时服务目录, 返回浏览器可打开的查看器 URL (嵌入+精简菜单+无模型树)。</summary>
|
||||
public string PublishStep(string fileName, byte[] content)
|
||||
{
|
||||
var safe = Path.GetFileName(fileName.Replace('\\', '/'));
|
||||
File.WriteAllBytes(Path.Combine(_stepDir, safe), content);
|
||||
return string.Format(
|
||||
"http://127.0.0.1:{0}/STEPViewer/index.html?file=/step/{1}&embed=1&bg=grey&menu=min",
|
||||
Port, Uri.EscapeDataString(safe));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_running = false;
|
||||
try { _listener.Stop(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
1576
src/App/MainWindow.cs
Normal file
1576
src/App/MainWindow.cs
Normal file
File diff suppressed because it is too large
Load Diff
108
src/App/ViewerControl.cs
Normal file
108
src/App/ViewerControl.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using Ounibo.Catalog.Viewer3D;
|
||||
|
||||
namespace Ounibo.Catalog.App
|
||||
{
|
||||
/// <summary>
|
||||
/// 3D 预览控件: Viewport3D + 鼠标左键拖拽旋转 + 滚轮缩放。
|
||||
/// </summary>
|
||||
public class ViewerControl : UserControl
|
||||
{
|
||||
Viewport3D _vp;
|
||||
PerspectiveCamera _cam;
|
||||
Model3DGroup _root;
|
||||
Point _last;
|
||||
double _theta = 0.6, _phi = 0.9, _dist = 300;
|
||||
bool _dragging;
|
||||
|
||||
public ViewerControl()
|
||||
{
|
||||
_root = new Model3DGroup();
|
||||
_root.Children.Add(new DirectionalLight(Colors.White, new Vector3D(-1, -1, -2)));
|
||||
_root.Children.Add(new DirectionalLight(Colors.White, new Vector3D(1, 0.5, 1)));
|
||||
|
||||
_cam = new PerspectiveCamera
|
||||
{
|
||||
Position = new Point3D(0, 0, 300),
|
||||
LookDirection = new Vector3D(0, 0, -1),
|
||||
UpDirection = new Vector3D(0, 1, 0),
|
||||
FieldOfView = 45
|
||||
};
|
||||
_vp = new Viewport3D { Camera = _cam };
|
||||
_vp.Children.Add(new ModelVisual3D { Content = _root });
|
||||
Content = new Border { Background = Brushes.White, Child = _vp };
|
||||
|
||||
_vp.MouseLeftButtonDown += (s, e) => { _dragging = true; _last = e.GetPosition(_vp); _vp.CaptureMouse(); };
|
||||
_vp.MouseMove += (s, e) =>
|
||||
{
|
||||
if (!_dragging) return;
|
||||
var p = e.GetPosition(_vp);
|
||||
var dx = p.X - _last.X;
|
||||
var dy = p.Y - _last.Y;
|
||||
_last = p;
|
||||
_theta -= dx * 0.01;
|
||||
_phi = Math.Max(-1.45, Math.Min(1.45, _phi + dy * 0.01));
|
||||
UpdateCamera();
|
||||
};
|
||||
_vp.MouseLeftButtonUp += (s, e) => { _dragging = false; _vp.ReleaseMouseCapture(); };
|
||||
_vp.MouseWheel += (s, e) =>
|
||||
{
|
||||
_dist *= (e.Delta > 0) ? 0.88 : 1.13;
|
||||
_dist = Math.Max(20, Math.Min(4000, _dist));
|
||||
UpdateCamera();
|
||||
};
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
var lights = new List<Light>();
|
||||
foreach (var m in _root.Children)
|
||||
if (m is Light) lights.Add((Light)m);
|
||||
_root.Children.Clear();
|
||||
foreach (var l in lights) _root.Children.Add(l);
|
||||
}
|
||||
|
||||
/// <summary>加载圆柱列表并自动取景。</summary>
|
||||
public void Load(List<Cyl> cyls)
|
||||
{
|
||||
Clear();
|
||||
if (cyls == null || cyls.Count == 0) return;
|
||||
|
||||
// 颜色: 0 后盖 / 1 缸体 / 2 前盖 / 3 活塞杆
|
||||
var brushes = new[]
|
||||
{
|
||||
new SolidColorBrush(Color.FromRgb(0x8C, 0x8C, 0x8C)),
|
||||
new SolidColorBrush(Color.FromRgb(0x2F, 0x6F, 0xB3)),
|
||||
new SolidColorBrush(Color.FromRgb(0x8C, 0x8C, 0x8C)),
|
||||
new SolidColorBrush(Color.FromRgb(0xC8, 0xC8, 0xC8))
|
||||
};
|
||||
for (int i = 0; i < cyls.Count; i++)
|
||||
{
|
||||
var mesh = StepMesh.BuildMesh(cyls[i]);
|
||||
var mat = new DiffuseMaterial(brushes[i % brushes.Length]);
|
||||
_root.Children.Add(new GeometryModel3D(mesh, mat) { BackMaterial = mat });
|
||||
}
|
||||
|
||||
double cx, cy, cz, r, h;
|
||||
StepMesh.Bounds(cyls, out cx, out cy, out cz, out r, out h);
|
||||
_dist = Math.Max(120, Math.Max(r * 5.0, h * 1.6));
|
||||
_theta = 0.55; _phi = 0.85;
|
||||
UpdateCamera();
|
||||
}
|
||||
|
||||
void UpdateCamera()
|
||||
{
|
||||
double x = _dist * Math.Sin(_theta) * Math.Cos(_phi);
|
||||
double y = _dist * Math.Sin(_phi);
|
||||
double z = _dist * Math.Cos(_theta) * Math.Cos(_phi);
|
||||
_cam.Position = new Point3D(x, y, z);
|
||||
_cam.LookDirection = new Vector3D(-x, -y, -z);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/CatalogCore/Configurator.cs
Normal file
130
src/CatalogCore/Configurator.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 选型引擎: 规则求值 + 允许值过滤 + 变体解析。
|
||||
/// 规则语义: 若某变体满足规则 If 条件, 则必须同时满足 Then 条件, 否则该变体无效。
|
||||
/// </summary>
|
||||
public class Configurator
|
||||
{
|
||||
public Series Series { get; private set; }
|
||||
private readonly List<Variant> _all;
|
||||
|
||||
public Configurator(Series s)
|
||||
{
|
||||
Series = s;
|
||||
_all = s.variants ?? new List<Variant>();
|
||||
}
|
||||
|
||||
/// <summary>参数化模式 (mode=parametric): 选型可超出 CSV 网格, 由 ParametricEngine 生成非标组合。</summary>
|
||||
public bool IsParametric { get { return ParametricEngine.IsParametric(Series); } }
|
||||
|
||||
// 值比较统一走字符串 (枚举值如 16 / "16.0" / "S" 均可比)
|
||||
static string S(object o) { return o == null ? "" : Convert.ToString(o, CultureInfo.InvariantCulture); }
|
||||
static bool Eq(object a, object b) { return string.Equals(S(a), S(b), StringComparison.OrdinalIgnoreCase); }
|
||||
static double D(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); }
|
||||
|
||||
public bool CondMatches(RuleCond c, Dictionary<string, object> p)
|
||||
{
|
||||
if (c == null) return true;
|
||||
object val;
|
||||
if (!p.TryGetValue(c.Param, out val)) return false;
|
||||
switch (c.Op)
|
||||
{
|
||||
case "eq": return Eq(val, c.Value);
|
||||
case "ne": return !Eq(val, c.Value);
|
||||
case "in":
|
||||
var arr = c.Value as object[];
|
||||
return arr != null && arr.Any(x => Eq(val, x));
|
||||
case "gt": return D(val) > D(c.Value);
|
||||
case "ge": return D(val) >= D(c.Value);
|
||||
case "lt": return D(val) < D(c.Value);
|
||||
case "le": return D(val) <= D(c.Value);
|
||||
case "between":
|
||||
var r = c.Value as object[];
|
||||
return r != null && r.Length == 2 && D(val) >= D(r[0]) && D(val) <= D(r[1]);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RuleViolated(Variant v)
|
||||
{
|
||||
foreach (var rule in Series.rules ?? Enumerable.Empty<Rule>())
|
||||
if (CondMatches(rule.If, v.@params) && !CondMatches(rule.Then, v.@params))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 部分选择下的规则违背判定 (参数化联动用): Then 参数未选时视为"尚可满足", 不判违背 —
|
||||
/// 否则选 mount=YB 会瞬间清空其他未选参数的选项 (参数化引擎一期, 与网页同语义)。
|
||||
/// </summary>
|
||||
public bool RuleViolatedPartial(Dictionary<string, object> p)
|
||||
{
|
||||
foreach (var rule in Series.rules ?? Enumerable.Empty<Rule>())
|
||||
if (CondMatches(rule.If, p) && p.ContainsKey(rule.Then.Param) && !CondMatches(rule.Then, p))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<Variant> ValidVariants()
|
||||
{
|
||||
return _all.Where(v => !RuleViolated(v)).ToList();
|
||||
}
|
||||
|
||||
public bool Matches(Variant v, Dictionary<string, object> sel)
|
||||
{
|
||||
foreach (var kv in sel)
|
||||
{
|
||||
object val;
|
||||
if (!v.@params.TryGetValue(kv.Key, out val) || !Eq(val, kv.Value))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每个参数在"当前已选 + 规则"约束下的允许值 (保持参数定义顺序)。
|
||||
/// 已选参数按"除自身外的其他已选"计算可改值 (选完也能调整),
|
||||
/// 未选参数按全部已选计算; 均用于 UI 自动清除被置为非法的选择。
|
||||
/// </summary>
|
||||
public Dictionary<string, List<object>> AllowedOptions(Dictionary<string, object> sel)
|
||||
{
|
||||
var result = new Dictionary<string, List<object>>();
|
||||
foreach (var p in Series.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
// 候选值: 枚举/数值参数取定义值; range 参数取 gridValues 快捷值
|
||||
var cands = p.type == "range"
|
||||
? (p.gridValues ?? p.values ?? new List<object>())
|
||||
: (p.values ?? new List<object>());
|
||||
var selForP = sel.Where(kv => kv.Key != p.code).ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
var allowed = new List<object>();
|
||||
// 联动按"域+规则"判合法 (参数化引擎一期): 自由 range 值没有对应网格行,
|
||||
// 若按网格行存在性过滤会把其他参数选项瞬间清空 — 与网页同语义
|
||||
foreach (var val in cands)
|
||||
{
|
||||
var combo = new Dictionary<string, object>(selForP) { [p.code] = val };
|
||||
if (ParametricEngine.ValidatePartial(Series, combo) == null)
|
||||
allowed.Add(val);
|
||||
}
|
||||
// range 参数: 保留当前已选的自由输入值 (域内合法) — 防止联动刷新时清掉自定义值
|
||||
if (p.type == "range" && sel.ContainsKey(p.code) &&
|
||||
!allowed.Any(x => Eq(x, sel[p.code])) && ParametricEngine.DomainValid(p, sel[p.code]))
|
||||
allowed.Add(sel[p.code]);
|
||||
result[p.code] = allowed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>参数选满且合法时返回变体, 否则 null。</summary>
|
||||
public Variant Resolve(Dictionary<string, object> sel)
|
||||
{
|
||||
if (sel.Count < (Series.parameters?.Count ?? 0)) return null;
|
||||
return ValidVariants().FirstOrDefault(v => Matches(v, sel));
|
||||
}
|
||||
}
|
||||
}
|
||||
235
src/CatalogCore/FullCatalogBuilder.cs
Normal file
235
src/CatalogCore/FullCatalogBuilder.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 系列配置 (源目录 series\<CODE>.meta.json 的格式, 与向导 保存配置 完全同构)。
|
||||
/// </summary>
|
||||
public class SeriesMeta
|
||||
{
|
||||
public Series series { get; set; }
|
||||
public List<BuildAttachmentSpec> attachments { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全量目录构建器 (统一路线, 2026-08-22):
|
||||
/// 唯一数据源 = 源目录 (默认 onebot-data\catalog\), 结构:
|
||||
/// catalog.json 目录定义 (名称/版本/分类/系列顺序, 与包内 catalog.json 同构, series 部分留空)
|
||||
/// series\<CODE>.meta.json 每系列定义 (参数/规则/附件, 向导"保存配置"直接写这里)
|
||||
/// csv\<CODE>.csv 参数表 (model_code + 参数列 + step_file 列)
|
||||
/// step\*.step STEP 文件 (几何生成器或 NX 导出)
|
||||
/// manual\... 等附件源文件 (meta 里 attachments.source 为相对源目录的路径)
|
||||
/// 构建: 全部系列 → 一个 .opc (单一路线, 不再需要 PowerShell 打包)。
|
||||
/// </summary>
|
||||
public static class FullCatalogBuilder
|
||||
{
|
||||
public static BuildResult BuildFromSourceDir(string sourceDir, string outPath)
|
||||
{
|
||||
var r = new BuildResult { Ok = true };
|
||||
try
|
||||
{
|
||||
var catFile = Path.Combine(sourceDir, "catalog.json");
|
||||
if (!File.Exists(catFile)) { r.Errors.Add("源目录缺少 catalog.json: " + sourceDir); r.Ok = false; return r; }
|
||||
var ser = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
|
||||
var cat = ser.Deserialize<Catalog>(File.ReadAllText(catFile, Encoding.UTF8));
|
||||
if (cat == null || cat.categories == null || cat.categories.Count == 0)
|
||||
{ r.Errors.Add("catalog.json 无效 (缺 categories)"); r.Ok = false; return r; }
|
||||
|
||||
var stepDir = Path.Combine(sourceDir, "step");
|
||||
if (!Directory.Exists(stepDir)) { r.Errors.Add("STEP 目录不存在: " + stepDir); r.Ok = false; return r; }
|
||||
|
||||
// 系列代码按分类顺序收集
|
||||
var codes = new List<string>();
|
||||
foreach (var c in cat.categories)
|
||||
foreach (var sc in c.series ?? new List<string>())
|
||||
if (!codes.Contains(sc)) codes.Add(sc);
|
||||
|
||||
// 每系列: meta + CSV → 变体 + 附件
|
||||
var builtSeries = new List<Series>();
|
||||
var filesToCopy = new List<Tuple<string, string>>(); // (源绝对路径, 包内相对路径)
|
||||
foreach (var code in codes)
|
||||
{
|
||||
var metaPath = Path.Combine(sourceDir, "series", code + ".meta.json");
|
||||
if (!File.Exists(metaPath)) { r.Errors.Add("系列配置缺失: " + metaPath); continue; }
|
||||
SeriesMeta meta;
|
||||
try { meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8)); }
|
||||
catch (Exception ex) { r.Errors.Add("系列配置解析失败 " + code + ": " + ex.Message); continue; }
|
||||
var s = meta == null ? null : meta.series;
|
||||
if (s == null) { r.Errors.Add("系列配置无效: " + metaPath); continue; }
|
||||
|
||||
var csvPath = Path.Combine(sourceDir, "csv", code + ".csv");
|
||||
if (!File.Exists(csvPath)) { r.Errors.Add("CSV 缺失: " + csvPath); continue; }
|
||||
var vr = new BuildResult { Ok = true };
|
||||
var variants = OpcBuilder.ReadCsvVariants(s, csvPath, stepDir, vr);
|
||||
foreach (var e in vr.Errors) r.Errors.Add("[" + code + "] " + e);
|
||||
if (variants == null || variants.Count == 0) { r.Errors.Add("系列 " + code + " 无有效变体"); continue; }
|
||||
s.variants = variants;
|
||||
|
||||
// 参数化系列 (mode=parametric): 域校验 + 模板对拍门禁 (CSV 全部编码必须能被模板重新生成且一一对应)
|
||||
if (ParametricEngine.IsParametric(s))
|
||||
{
|
||||
if (string.IsNullOrEmpty(s.modelCodeTemplate))
|
||||
r.Errors.Add("[" + code + "] 参数化系列缺少 modelCodeTemplate");
|
||||
foreach (var p in s.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
if (p.type != "range") continue;
|
||||
if (p.min == null || p.max == null || p.min.Value > p.max.Value)
|
||||
{ r.Errors.Add("[" + code + "] 参数 " + p.code + " range 域非法 (min/max)"); continue; }
|
||||
if ((p.step ?? 1) <= 0) { r.Errors.Add("[" + code + "] 参数 " + p.code + " step 非法"); continue; }
|
||||
foreach (var gv in p.gridValues ?? new List<object>())
|
||||
if (!ParametricEngine.DomainValid(p, gv))
|
||||
r.Errors.Add("[" + code + "] 参数 " + p.code + " gridValues 含域外值: " + gv);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(s.modelCodeTemplate))
|
||||
{
|
||||
var codeToSig = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in variants)
|
||||
{
|
||||
var regen = ParametricEngine.RenderCode(s.modelCodeTemplate, v.@params);
|
||||
if (regen == null || !string.Equals(regen, v.modelCode, StringComparison.OrdinalIgnoreCase))
|
||||
r.Errors.Add("[" + code + "] 对拍门禁失败: " + v.modelCode + " 模板重生成=" + (regen ?? "null"));
|
||||
else
|
||||
{
|
||||
var sig = string.Join("|", (s.parameters ?? new List<ParameterDef>())
|
||||
.Select(x => x.code + "=" + (v.@params.ContainsKey(x.code) ? v.@params[x.code].ToString() : "")));
|
||||
string prev;
|
||||
if (codeToSig.TryGetValue(regen, out prev))
|
||||
{ if (prev != sig) r.Errors.Add("[" + code + "] 模板多对一: " + regen + " 对应两个不同组合"); }
|
||||
else codeToSig[regen] = sig;
|
||||
}
|
||||
}
|
||||
if (r.Errors.Count == 0)
|
||||
r.Warnings.Add("[" + code + "] 参数化对拍门禁通过: " + variants.Count + " 个编码全部一致");
|
||||
}
|
||||
}
|
||||
|
||||
// 附件: 源文件(相对源目录) → 包内路径 (manual 走 manual/<系列>/ 布局)
|
||||
s.attachments = new List<Attachment>();
|
||||
foreach (var a in meta.attachments ?? new List<BuildAttachmentSpec>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(a.source)) continue;
|
||||
var src = Path.Combine(sourceDir, a.source.Replace('/', '\\'));
|
||||
if (!File.Exists(src)) { r.Warnings.Add("附件缺失, 跳过: " + a.source); continue; }
|
||||
string rel;
|
||||
if (a.kind == "manual")
|
||||
rel = "manual/" + code + "/" + Path.GetFileName(a.source);
|
||||
else
|
||||
{
|
||||
var folder = a.kind == "dimDrawing" ? "dim-drawing" : a.kind;
|
||||
rel = "docs/" + folder + "/" + Path.GetFileName(a.source);
|
||||
}
|
||||
s.attachments.Add(new Attachment { kind = a.kind, lang = a.lang, path = rel, model = a.model });
|
||||
filesToCopy.Add(new Tuple<string, string>(src, rel));
|
||||
}
|
||||
builtSeries.Add(s);
|
||||
}
|
||||
if (builtSeries.Count == 0) { r.Errors.Add("无有效系列"); r.Ok = false; return r; }
|
||||
if (r.Errors.Count > 0) { r.Ok = false; return r; }
|
||||
|
||||
cat.series = builtSeries;
|
||||
|
||||
// 平替对照表 (可选): catalog\crossref.csv → 校验我方型号存在 → 嵌入 catalog.json
|
||||
var crossRefPath = Path.Combine(sourceDir, "crossref.csv");
|
||||
if (File.Exists(crossRefPath))
|
||||
{
|
||||
var validCodes = new HashSet<string>(
|
||||
builtSeries.SelectMany(s => s.variants ?? new List<Variant>()).Select(v => v.modelCode));
|
||||
var rows = new List<CrossRefRow>();
|
||||
var crLines = File.ReadAllLines(crossRefPath, Encoding.UTF8)
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
|
||||
if (crLines.Length > 1)
|
||||
{
|
||||
var crHeader = crLines[0].Split(',').Select(x => x.Trim()).ToArray();
|
||||
var colBrand = Array.IndexOf(crHeader, "brand");
|
||||
var colForeign = Array.IndexOf(crHeader, "foreign_model");
|
||||
var colOur = Array.IndexOf(crHeader, "our_model");
|
||||
var colNote = Array.IndexOf(crHeader, "note");
|
||||
if (colBrand < 0 || colForeign < 0 || colOur < 0)
|
||||
r.Errors.Add("crossref.csv 表头需含 brand,foreign_model,our_model 列");
|
||||
else
|
||||
{
|
||||
for (int i = 1; i < crLines.Length; i++)
|
||||
{
|
||||
var c = crLines[i].Split(',');
|
||||
if (c.Length < 4) { r.Errors.Add("crossref.csv 第 " + (i + 1) + " 行字段不足"); continue; }
|
||||
var brand = c[colBrand].Trim();
|
||||
var foreignModel = c[colForeign].Trim();
|
||||
var ourModel = c[colOur].Trim();
|
||||
if (string.IsNullOrEmpty(brand) || string.IsNullOrEmpty(foreignModel) || string.IsNullOrEmpty(ourModel))
|
||||
{ r.Errors.Add("crossref.csv 第 " + (i + 1) + " 行有空字段"); continue; }
|
||||
if (!validCodes.Contains(ourModel))
|
||||
{ r.Errors.Add("crossref.csv: 我方型号不存在 " + ourModel + " (" + brand + " " + foreignModel + ")"); continue; }
|
||||
rows.Add(new CrossRefRow
|
||||
{
|
||||
brand = brand,
|
||||
foreignModel = foreignModel,
|
||||
ourModel = ourModel,
|
||||
note = (colNote >= 0 && colNote < c.Length) ? c[colNote].Trim() : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.Errors.Count > 0) { r.Ok = false; return r; }
|
||||
cat.crossRefs = rows;
|
||||
}
|
||||
|
||||
// 参数化测试向量 (双端对拍, 2026-08-25): 源目录根 parametric-vectors.json → 包内根
|
||||
// 桌面 selftest 与网页无头 E2E 读同一份文件对拍, 保证 C#/JS 解释器语义一致
|
||||
var vecPath = Path.Combine(sourceDir, "parametric-vectors.json");
|
||||
if (File.Exists(vecPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
ser.DeserializeObject(File.ReadAllText(vecPath, Encoding.UTF8));
|
||||
filesToCopy.Add(new Tuple<string, string>(vecPath, "parametric-vectors.json"));
|
||||
}
|
||||
catch (Exception ex) { r.Errors.Add("parametric-vectors.json 解析失败: " + ex.Message); }
|
||||
}
|
||||
|
||||
// 组装临时目录 → zip
|
||||
var tmp = Path.Combine(Path.GetTempPath(), "opcbuild_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
foreach (var s in builtSeries)
|
||||
foreach (var v in s.variants ?? new List<Variant>())
|
||||
{
|
||||
var dest = Path.Combine(tmp, v.step.Replace('/', '\\'));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest));
|
||||
var srcName = OpcBuilder.ResolveStepFile(stepDir, Path.GetFileName(v.step.Replace('/', '\\')));
|
||||
File.Copy(Path.Combine(stepDir, srcName), dest, true);
|
||||
}
|
||||
foreach (var f in filesToCopy)
|
||||
{
|
||||
var dest = Path.Combine(tmp, f.Item2.Replace('/', '\\'));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest));
|
||||
File.Copy(f.Item1, dest, true);
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(tmp, "catalog.json"), ser.Serialize(cat), new UTF8Encoding(true));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
|
||||
if (File.Exists(outPath)) File.Delete(outPath);
|
||||
ZipFile.CreateFromDirectory(tmp, outPath, CompressionLevel.Optimal, false);
|
||||
r.OutputPath = outPath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tmp, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
r.Errors.Add("全量打包异常: " + ex.Message);
|
||||
r.Ok = false;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/CatalogCore/Model.cs
Normal file
103
src/CatalogCore/Model.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
// ---- .opc 数据包模型 (字段名与 catalog.json 键一一对应) ----
|
||||
|
||||
public class Catalog
|
||||
{
|
||||
public string schemaVersion { get; set; }
|
||||
public string catalogName { get; set; }
|
||||
public string catalogNameEn { get; set; }
|
||||
public string catalogVersion { get; set; }
|
||||
public string defaultLang { get; set; }
|
||||
public string genApi { get; set; } // NX 按需生成服务地址 (空 = 非标下载维持置灰; 二期 2026-08-25)
|
||||
public List<string> langs { get; set; }
|
||||
public List<Category> categories { get; set; }
|
||||
public List<Series> series { get; set; }
|
||||
public List<CrossRefRow> crossRefs { get; set; } // 平替对照 (可空)
|
||||
}
|
||||
|
||||
public class Category
|
||||
{
|
||||
public string code { get; set; }
|
||||
public string nameZh { get; set; }
|
||||
public string nameEn { get; set; }
|
||||
public string keywords { get; set; } // 搜索关键词 (英文/拼音等, 空格分隔)
|
||||
public List<string> series { get; set; }
|
||||
}
|
||||
|
||||
public class Series
|
||||
{
|
||||
public string code { get; set; }
|
||||
public string nameZh { get; set; }
|
||||
public string nameEn { get; set; }
|
||||
public string keywords { get; set; } // 搜索关键词
|
||||
public string mode { get; set; } // "parametric"=参数化引擎; 缺失/其他=枚举模式 (现行为)
|
||||
public string modelCodeTemplate { get; set; } // 参数化: 型号编码模板, 如 "KC{type}{bore}-{stroke}{magnet}{mount}" ({field[:padN]})
|
||||
public List<ParameterDef> parameters { get; set; }
|
||||
public List<Rule> rules { get; set; }
|
||||
public Naming naming { get; set; }
|
||||
public List<Attachment> attachments { get; set; }
|
||||
public List<Variant> variants { get; set; }
|
||||
}
|
||||
|
||||
public class ParameterDef
|
||||
{
|
||||
public string code { get; set; }
|
||||
public string nameZh { get; set; }
|
||||
public string nameEn { get; set; }
|
||||
public string keywords { get; set; } // 搜索关键词 (如拼音 gangjing)
|
||||
public string unit { get; set; }
|
||||
public string type { get; set; } // enum / number / range (range=连续参数化域)
|
||||
public List<object> values { get; set; }
|
||||
public Dictionary<string, string> display { get; set; }
|
||||
// ---- range 域 (type=range 时生效) ----
|
||||
public double? min { get; set; } // 域下限 (含)
|
||||
public double? max { get; set; } // 域上限 (含)
|
||||
public double? step { get; set; } // 步长 (缺省 1)
|
||||
public List<object> gridValues { get; set; } // 标准档位快捷值 (仅 UI 快捷选择, 网格真源仍是 CSV)
|
||||
}
|
||||
|
||||
public class Rule
|
||||
{
|
||||
public RuleCond If { get; set; }
|
||||
public RuleCond Then { get; set; }
|
||||
}
|
||||
|
||||
public class RuleCond
|
||||
{
|
||||
public string Param { get; set; }
|
||||
public string Op { get; set; } // eq/ne/in/gt/ge/lt/le/between
|
||||
public object Value { get; set; } // 标量或数组 (op=in/between)
|
||||
}
|
||||
|
||||
public class Naming
|
||||
{
|
||||
public string stepNameTemplate { get; set; }
|
||||
}
|
||||
|
||||
public class Attachment
|
||||
{
|
||||
public string kind { get; set; } // dimDrawing / datasheet / note
|
||||
public string lang { get; set; }
|
||||
public string path { get; set; }
|
||||
public string model { get; set; } // dimDrawing 可绑定具体型号
|
||||
}
|
||||
|
||||
public class Variant
|
||||
{
|
||||
public string modelCode { get; set; }
|
||||
public Dictionary<string, object> @params { get; set; } // C# 保留字用 @ 前缀
|
||||
public string step { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>平替对照行 (竞品型号 → 我方型号, 源: catalog\crossref.csv)。</summary>
|
||||
public class CrossRefRow
|
||||
{
|
||||
public string brand { get; set; } // 竞品品牌 (SMC/亚德客/FESTO/...)
|
||||
public string foreignModel { get; set; } // 竞品型号
|
||||
public string ourModel { get; set; } // 我方型号 (必须存在于目录变体)
|
||||
public string note { get; set; } // 备注 (完全互换/近似平替/需确认)
|
||||
}
|
||||
}
|
||||
220
src/CatalogCore/OpcBuilder.cs
Normal file
220
src/CatalogCore/OpcBuilder.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
public class BuildAttachmentSpec
|
||||
{
|
||||
public string kind; // dimDrawing / datasheet / note
|
||||
public string lang; // zh / en
|
||||
public string source; // 源文件绝对路径
|
||||
public string model; // 可选, 绑定型号
|
||||
}
|
||||
|
||||
public class BuildResult
|
||||
{
|
||||
public bool Ok;
|
||||
public List<string> Errors = new List<string>();
|
||||
public List<string> Warnings = new List<string>();
|
||||
public string OutputPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// .opc 打包器 (M2 Builder 内核, 也供脚本/自测使用):
|
||||
/// CSV (model_code + 参数列 + step_file 列) + STEP 目录 → zip 数据包。
|
||||
/// </summary>
|
||||
public static class OpcBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 从 CSV 自动识别参数定义 (与向导 Step2 共用):
|
||||
/// 数值列 → type=number (支持范围搜索), 其他 → enum (取值按出现顺序去重)。
|
||||
/// </summary>
|
||||
public static List<ParameterDef> AutoDetectParameters(string csvPath)
|
||||
{
|
||||
var result = new List<ParameterDef>();
|
||||
var lines = File.ReadAllLines(csvPath, Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
|
||||
if (lines.Length < 2) return result;
|
||||
var header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
|
||||
var dataRows = lines.Skip(1).Select(l => l.Split(',')).Where(c => c.Length >= header.Length && !string.IsNullOrWhiteSpace(c[0])).ToList();
|
||||
|
||||
for (int i = 1; i < header.Length; i++)
|
||||
{
|
||||
var code = header[i];
|
||||
if (code == "step_file") continue;
|
||||
// 枚举列保留空值 (如 磁石="" 表示无磁石, 是合法取值); 数值列过滤空值
|
||||
var rawVals = dataRows.Select(r => r[i].Trim()).Distinct().ToList();
|
||||
var nonEmpty = rawVals.Where(v => v.Length > 0).ToList();
|
||||
bool numeric = nonEmpty.Count > 0 && nonEmpty.All(v =>
|
||||
{
|
||||
double d;
|
||||
return double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out d);
|
||||
});
|
||||
var values = numeric
|
||||
? nonEmpty.Select(v => double.Parse(v, CultureInfo.InvariantCulture)).OrderBy(d => d).Cast<object>().ToList()
|
||||
: rawVals.Cast<object>().ToList();
|
||||
result.Add(new ParameterDef
|
||||
{
|
||||
code = code,
|
||||
nameZh = code,
|
||||
nameEn = code,
|
||||
unit = numeric ? "mm" : "",
|
||||
type = numeric ? "number" : "enum",
|
||||
values = values
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public static BuildResult Build(
|
||||
string outPath,
|
||||
string catalogName, string catalogVersion,
|
||||
string catCode, string catNameZh, string catNameEn,
|
||||
Series series,
|
||||
string csvPath, string stepDir,
|
||||
List<BuildAttachmentSpec> attachments)
|
||||
{
|
||||
var r = new BuildResult { Ok = true };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) 读 CSV: 首列 = 型号编码, step_file 列 = STEP 文件名, 其余 = 参数
|
||||
if (!File.Exists(csvPath)) { r.Errors.Add("CSV 不存在: " + csvPath); r.Ok = false; return r; }
|
||||
if (!Directory.Exists(stepDir)) { r.Errors.Add("STEP 目录不存在: " + stepDir); r.Ok = false; return r; }
|
||||
|
||||
var variants = ReadCsvVariants(series, csvPath, stepDir, r);
|
||||
if (r.Errors.Count > 0 || variants == null || variants.Count == 0)
|
||||
{
|
||||
if (variants == null || variants.Count == 0) r.Errors.Add("无有效变体");
|
||||
r.Ok = false; return r;
|
||||
}
|
||||
|
||||
// 2) 目录与系列
|
||||
var cat = new Catalog
|
||||
{
|
||||
schemaVersion = "1.0",
|
||||
catalogName = catalogName,
|
||||
catalogNameEn = catalogName,
|
||||
catalogVersion = catalogVersion,
|
||||
defaultLang = "zh",
|
||||
langs = new List<string> { "zh", "en" },
|
||||
categories = new List<Category>
|
||||
{
|
||||
new Category { code = catCode, nameZh = catNameZh, nameEn = catNameEn, series = new List<string> { series.code } }
|
||||
},
|
||||
series = new List<Series> { series }
|
||||
};
|
||||
series.variants = variants;
|
||||
|
||||
// 3) 组装临时目录 → zip
|
||||
var tmp = Path.Combine(Path.GetTempPath(), "opcbuild_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
foreach (var v in variants)
|
||||
{
|
||||
var dest = Path.Combine(tmp, v.step.Replace('/', '\\'));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest));
|
||||
var srcName = ResolveStepFile(stepDir, Path.GetFileName(v.step.Replace('/', '\\')));
|
||||
File.Copy(Path.Combine(stepDir, srcName), dest);
|
||||
}
|
||||
foreach (var a in attachments ?? new List<BuildAttachmentSpec>())
|
||||
{
|
||||
if (!File.Exists(a.source)) { r.Warnings.Add("附件源文件缺失, 跳过: " + a.source); continue; }
|
||||
// 包内目录统一用 kebab-case (dimDrawing → dim-drawing), 与 sample-data 布局一致
|
||||
var folder = a.kind == "dimDrawing" ? "dim-drawing" : a.kind;
|
||||
var rel = "docs/" + folder + "/" + Path.GetFileName(a.source);
|
||||
Directory.CreateDirectory(Path.Combine(tmp, "docs", folder));
|
||||
File.Copy(a.source, Path.Combine(tmp, rel.Replace('/', '\\')), true);
|
||||
series.attachments = series.attachments ?? new List<Attachment>();
|
||||
series.attachments.Add(new Attachment { kind = a.kind, lang = a.lang, path = rel, model = a.model });
|
||||
}
|
||||
|
||||
var ser = new JavaScriptSerializer();
|
||||
var json = ser.Serialize(cat);
|
||||
File.WriteAllText(Path.Combine(tmp, "catalog.json"), json, new UTF8Encoding(true));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
|
||||
if (File.Exists(outPath)) File.Delete(outPath);
|
||||
ZipFile.CreateFromDirectory(tmp, outPath, CompressionLevel.Optimal, false);
|
||||
r.OutputPath = outPath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tmp, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
r.Errors.Add("打包异常: " + ex.Message);
|
||||
r.Ok = false;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// STEP 物理文件查找: 优先 CSV 写的 .step, 兼容同名 .stp (NX 导出常用后缀)。
|
||||
/// 返回物理文件名, 找不到返回 null。
|
||||
/// </summary>
|
||||
public static string ResolveStepFile(string stepDir, string stepName)
|
||||
{
|
||||
if (File.Exists(Path.Combine(stepDir, stepName))) return stepName;
|
||||
var alt = Path.ChangeExtension(stepName, ".stp");
|
||||
return File.Exists(Path.Combine(stepDir, alt)) ? alt : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV → 变体列表 (与 Build 共用内核; 错误写进 r.Errors, 返回值可能为 null)。
|
||||
/// 首列 = 型号编码, step_file 列 = STEP 文件名 (需存在于 stepDir, .stp/.step 兼容), 其余 = 参数列。
|
||||
/// </summary>
|
||||
public static List<Variant> ReadCsvVariants(Series series, string csvPath, string stepDir, BuildResult r)
|
||||
{
|
||||
var lines = File.ReadAllLines(csvPath, Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
|
||||
if (lines.Length < 2) { r.Errors.Add("CSV 无数据行: " + csvPath); return null; }
|
||||
var header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
|
||||
if (!header.Contains("step_file")) { r.Errors.Add("CSV 缺少 step_file 列: " + csvPath); return null; }
|
||||
|
||||
var variants = new List<Variant>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var c = line.Split(',');
|
||||
if (c.Length < header.Length) { r.Errors.Add("CSV 行字段不足: " + line); continue; }
|
||||
var model = c[0].Trim();
|
||||
if (string.IsNullOrEmpty(model)) { r.Errors.Add("CSV 行缺型号编码: " + line); continue; }
|
||||
if (!seen.Add(model)) { r.Errors.Add("型号编码重复: " + model); continue; }
|
||||
|
||||
var p = new Dictionary<string, object>();
|
||||
for (int i = 1; i < header.Length; i++)
|
||||
{
|
||||
var code = header[i];
|
||||
if (code == "step_file") continue;
|
||||
var def = (series.parameters ?? new List<ParameterDef>()).FirstOrDefault(x => x.code == code);
|
||||
if (def == null) continue; // 未定义的列跳过
|
||||
var raw = (i < c.Length) ? c[i].Trim() : "";
|
||||
if (def.type == "number" || def.type == "range")
|
||||
{
|
||||
double d;
|
||||
if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
|
||||
p[code] = d;
|
||||
else { r.Errors.Add("参数值非数字: " + model + " / " + code + "=" + raw); continue; }
|
||||
}
|
||||
else p[code] = raw;
|
||||
}
|
||||
if (p.Count < (series.parameters ?? new List<ParameterDef>()).Count)
|
||||
{ r.Errors.Add("参数不全: " + model); continue; }
|
||||
|
||||
var stepCol = Array.IndexOf(header, "step_file");
|
||||
var stepName = (stepCol < c.Length) ? c[stepCol].Trim() : "";
|
||||
if (ResolveStepFile(stepDir, stepName) == null)
|
||||
{ r.Errors.Add("STEP 文件缺失: " + stepName); continue; }
|
||||
variants.Add(new Variant { modelCode = model, @params = p, step = "step/" + stepName });
|
||||
}
|
||||
return variants.Count == 0 ? null : variants;
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/CatalogCore/OpcPackage.cs
Normal file
145
src/CatalogCore/OpcPackage.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// .opc 数据包 (zip: catalog.json + assets)。读写入口。
|
||||
/// </summary>
|
||||
public class OpcPackage : IDisposable
|
||||
{
|
||||
/// <summary>软件支持的数据包格式版本。</summary>
|
||||
public const string SupportedSchemaVersion = "1.0";
|
||||
|
||||
public Catalog Catalog { get; private set; }
|
||||
public string Path { get; private set; }
|
||||
|
||||
private ZipArchive _zip;
|
||||
private Stream _stream;
|
||||
|
||||
public static OpcPackage Open(string path)
|
||||
{
|
||||
var p = new OpcPackage { Path = path };
|
||||
// 整包读入内存, 立即释放文件句柄: 软件运行中仍可重建/覆盖同一 .opc (踩坑 #19)
|
||||
byte[] bytes;
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
fs.CopyTo(ms);
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
p._stream = new MemoryStream(bytes);
|
||||
p._zip = new ZipArchive(p._stream, ZipArchiveMode.Read);
|
||||
p.LoadCatalog();
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>从流打开 (客户版嵌入资源场景)。流的所有权转移给本对象。</summary>
|
||||
public static OpcPackage OpenStream(Stream stream, string sourceName)
|
||||
{
|
||||
var p = new OpcPackage { Path = sourceName, _stream = stream };
|
||||
p._zip = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
p.LoadCatalog();
|
||||
return p;
|
||||
}
|
||||
|
||||
void LoadCatalog()
|
||||
{
|
||||
var entry = _zip.GetEntry("catalog.json");
|
||||
if (entry == null)
|
||||
throw new InvalidDataException("数据包缺少 catalog.json: " + Path);
|
||||
string json;
|
||||
using (var sr = new StreamReader(entry.Open(), Encoding.UTF8))
|
||||
json = sr.ReadToEnd();
|
||||
// 大目录 (数万变体) 的 catalog.json 可能超过默认 2M 字符限制
|
||||
var ser = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
|
||||
Catalog = ser.Deserialize<Catalog>(json);
|
||||
if (Catalog == null || Catalog.series == null)
|
||||
throw new InvalidDataException("catalog.json 解析失败: " + Path);
|
||||
|
||||
// 版本校验: 过新提示升级软件, 过旧/未知提示数据包损坏或过旧
|
||||
var ver = Catalog.schemaVersion ?? "";
|
||||
if (ver != SupportedSchemaVersion)
|
||||
{
|
||||
if (string.Compare(ver, SupportedSchemaVersion, StringComparison.Ordinal) > 0)
|
||||
throw new InvalidDataException(
|
||||
"数据包格式版本 " + ver + " 高于软件支持的 " + SupportedSchemaVersion + ", 请升级软件");
|
||||
throw new InvalidDataException("数据包格式版本不兼容或数据包损坏: " + (ver.Length == 0 ? "(无版本号)" : ver));
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] ReadAsset(string path)
|
||||
{
|
||||
var entry = FindEntry(path);
|
||||
if (entry == null)
|
||||
throw new FileNotFoundException("数据包内资源缺失: " + path);
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
using (var s = entry.Open())
|
||||
s.CopyTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public string ReadAssetText(string path)
|
||||
{
|
||||
return Encoding.UTF8.GetString(ReadAsset(path));
|
||||
}
|
||||
|
||||
public bool HasAsset(string path)
|
||||
{
|
||||
return FindEntry(path) != null;
|
||||
}
|
||||
|
||||
// zip 条目分隔符可能是 '\' (Windows) 或 '/', 两种都尝试
|
||||
private ZipArchiveEntry FindEntry(string path)
|
||||
{
|
||||
var e = _zip.GetEntry(path);
|
||||
if (e == null && path != null && path.Contains('/'))
|
||||
e = _zip.GetEntry(path.Replace('/', '\\'));
|
||||
if (e == null && path != null && path.Contains('\\'))
|
||||
e = _zip.GetEntry(path.Replace('\\', '/'));
|
||||
return e;
|
||||
}
|
||||
|
||||
public IEnumerable<string> EntryNames
|
||||
{
|
||||
get { return _zip.Entries.Select(e => e.FullName); }
|
||||
}
|
||||
|
||||
/// <summary>把变体的 STEP 写入目标完整路径 (命名模板见 Series.Naming)。</summary>
|
||||
public string ExtractStepTo(Variant v, string fullPath)
|
||||
{
|
||||
var bytes = ReadAsset(v.step);
|
||||
File.WriteAllBytes(fullPath, bytes);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/// <summary>把变体的 STEP 按默认命名 (型号.step) 写入目录,返回完整路径。</summary>
|
||||
public string ExtractStep(Variant v, string destDir)
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
var name = v.modelCode + ".step";
|
||||
return ExtractStepTo(v, System.IO.Path.Combine(destDir, name));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_zip != null)
|
||||
{
|
||||
_zip.Dispose();
|
||||
_zip = null;
|
||||
}
|
||||
if (_stream != null)
|
||||
{
|
||||
_stream.Dispose();
|
||||
_stream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/CatalogCore/PackageValidator.cs
Normal file
101
src/CatalogCore/PackageValidator.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
public class ValidationIssue
|
||||
{
|
||||
public string Severity; // ERROR / WARNING
|
||||
public string Message;
|
||||
public string Target; // 如 "SC / SC32x50S-LB"
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据包校验器 (对标 qacheck 思想): 资源缺失/编码重复/规则冲突/附件缺失。
|
||||
/// 校验不通过不允许编译发布 (M3 硬门槛)。
|
||||
/// </summary>
|
||||
public static class PackageValidator
|
||||
{
|
||||
static string S(object o) { return o == null ? "" : Convert.ToString(o, System.Globalization.CultureInfo.InvariantCulture); }
|
||||
|
||||
public static List<ValidationIssue> Validate(OpcPackage pkg)
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
var cat = pkg.Catalog;
|
||||
if (cat == null) { issues.Add(new ValidationIssue { Severity = "ERROR", Message = "catalog.json 缺失" }); return issues; }
|
||||
if (cat.series == null || cat.series.Count == 0)
|
||||
{ issues.Add(new ValidationIssue { Severity = "ERROR", Message = "无系列数据" }); return issues; }
|
||||
|
||||
foreach (var s in cat.series)
|
||||
{
|
||||
// 变体资源与编码
|
||||
foreach (var v in s.variants ?? new List<Variant>())
|
||||
{
|
||||
if (!pkg.HasAsset(v.step))
|
||||
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "STEP 文件缺失", Target = s.code + " / " + v.modelCode + " -> " + v.step });
|
||||
var missing = (s.parameters ?? new List<ParameterDef>()).Where(p => !v.@params.ContainsKey(p.code)).Select(p => p.code).ToList();
|
||||
if (missing.Count > 0)
|
||||
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "变体缺少参数: " + string.Join(",", missing), Target = s.code + " / " + v.modelCode });
|
||||
}
|
||||
var dup = (s.variants ?? new List<Variant>()).GroupBy(v => v.modelCode).Where(g => g.Count() > 1).ToList();
|
||||
foreach (var d in dup)
|
||||
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "型号编码重复 x" + d.Count(), Target = s.code + " / " + d.Key });
|
||||
|
||||
// 规则引用检查
|
||||
foreach (var r in s.rules ?? new List<Rule>())
|
||||
{
|
||||
CheckCond(r.If, s, issues, s.code);
|
||||
CheckCond(r.Then, s, issues, s.code);
|
||||
if (r.If != null && r.Then != null)
|
||||
{
|
||||
// 冲突: If 与 Then 针对同一参数且条件矛盾
|
||||
if (r.If.Param == r.Then.Param && CondAlwaysFalse(r.If, r.Then))
|
||||
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "规则条件自相矛盾 (If/Then 同一参数互斥)", Target = s.code + " / " + r.If.Param });
|
||||
}
|
||||
}
|
||||
|
||||
// 附件
|
||||
foreach (var a in s.attachments ?? new List<Attachment>())
|
||||
{
|
||||
if (!pkg.HasAsset(a.path))
|
||||
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "附件缺失 (" + a.kind + " " + a.lang + ")", Target = a.path });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
static void CheckCond(RuleCond c, Series s, List<ValidationIssue> issues, string seriesCode)
|
||||
{
|
||||
if (c == null) return;
|
||||
var p = (s.parameters ?? new List<ParameterDef>()).FirstOrDefault(x => x.code == c.Param);
|
||||
if (p == null)
|
||||
{
|
||||
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "规则引用了不存在的参数: " + c.Param, Target = seriesCode });
|
||||
return;
|
||||
}
|
||||
// 约束值必须在参数取值范围内
|
||||
var vals = c.Value as object[];
|
||||
if (vals != null)
|
||||
{
|
||||
var allowed = (p.values ?? new List<object>()).Select(x => S(x)).ToList();
|
||||
foreach (var v in vals)
|
||||
if (!allowed.Contains(S(v)))
|
||||
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "规则值不在参数取值内: " + S(v), Target = seriesCode + " / " + c.Param });
|
||||
}
|
||||
}
|
||||
|
||||
static bool CondAlwaysFalse(RuleCond a, RuleCond b)
|
||||
{
|
||||
// 仅判断简单互斥: eq x vs ne x / in [..] vs eq y 且 y 不在列表
|
||||
if (a.Op == "eq" && b.Op == "ne" && S(a.Value) == S(b.Value)) return true;
|
||||
if (a.Op == "ne" && b.Op == "eq" && S(a.Value) == S(b.Value)) return true;
|
||||
if (a.Op == "eq" && b.Op == "in")
|
||||
{
|
||||
var arr = b.Value as object[];
|
||||
if (arr != null && arr.Length > 0 && !arr.Any(x => S(x) == S(a.Value))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/CatalogCore/ParametricEngine.cs
Normal file
156
src/CatalogCore/ParametricEngine.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>参数化选型结果: 标准档位命中 → Variant; 非标 → 模板生成的编码+参数 (无 STEP)。</summary>
|
||||
public class ParamResult
|
||||
{
|
||||
public string ModelCode;
|
||||
public Dictionary<string, object> Params;
|
||||
public bool IsStandard;
|
||||
public Variant Variant; // IsStandard 时非空 (即 CSV 网格行)
|
||||
public string Error; // 非法原因键: param=参数不全 / domain=域外 / rule=违背规则 / template=模板错误 (上层本地化)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 参数化引擎 (一期, 2026-08-25):
|
||||
/// 参数域校验 + 型号编码模板渲染 + 非标组合生成 + 最近标准档位。
|
||||
/// 与网页 app.js 的参数化函数语义一一对应 (同一份规则 JSON + 同一份测试向量 parametric-vectors.json 对拍)。
|
||||
/// </summary>
|
||||
public static class ParametricEngine
|
||||
{
|
||||
public static bool IsParametric(Series s) { return s != null && s.mode == "parametric"; }
|
||||
|
||||
static string S(object o) { return o == null ? "" : Convert.ToString(o, CultureInfo.InvariantCulture); }
|
||||
static bool Eq(object a, object b) { return string.Equals(S(a), S(b), StringComparison.OrdinalIgnoreCase); }
|
||||
static double D(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); }
|
||||
|
||||
/// <summary>模板渲染: {field} 或 {field:padN} (padN=前补零到 N 位)。未知字段/缺值/非法 token → null。</summary>
|
||||
public static string RenderCode(string template, Dictionary<string, object> p)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return null;
|
||||
var sb = new StringBuilder();
|
||||
int i = 0;
|
||||
while (i < template.Length)
|
||||
{
|
||||
var c = template[i];
|
||||
if (c == '{')
|
||||
{
|
||||
var close = template.IndexOf('}', i + 1);
|
||||
if (close < 0) return null;
|
||||
var token = template.Substring(i + 1, close - i - 1);
|
||||
var parts = token.Split(':');
|
||||
var field = parts[0].Trim();
|
||||
object val;
|
||||
if (!p.TryGetValue(field, out val)) return null;
|
||||
var sv = S(val);
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
int pad;
|
||||
var padTok = parts[1].Trim();
|
||||
if (padTok.StartsWith("pad", StringComparison.OrdinalIgnoreCase)) padTok = padTok.Substring(3);
|
||||
if (!int.TryParse(padTok, out pad) || pad < 1) return null;
|
||||
sv = sv.PadLeft(pad, '0');
|
||||
}
|
||||
sb.Append(sv);
|
||||
i = close + 1;
|
||||
}
|
||||
else { sb.Append(c); i++; }
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>参数取值在其域内: enum → values 命中; range → [min,max] 且按 step 对齐 (相对 min 的整数倍)。</summary>
|
||||
public static bool DomainValid(ParameterDef p, object v)
|
||||
{
|
||||
if (v == null) return false;
|
||||
if (p.type == "range")
|
||||
{
|
||||
double d;
|
||||
if (!double.TryParse(S(v), NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return false;
|
||||
if (p.min != null && d < p.min.Value) return false;
|
||||
if (p.max != null && d > p.max.Value) return false;
|
||||
var st = p.step ?? 1;
|
||||
var baseV = p.min ?? 0;
|
||||
if (st > 0 && Math.Abs(d - baseV - Math.Round((d - baseV) / st) * st) > 1e-6) return false;
|
||||
return true;
|
||||
}
|
||||
return (p.values ?? new List<object>()).Any(x => Eq(x, v));
|
||||
}
|
||||
|
||||
/// <summary>组合校验: 参数齐全 + 域 + 规则。返回错误键 (null=合法)。</summary>
|
||||
public static string ValidateCombo(Series s, Dictionary<string, object> sel)
|
||||
{
|
||||
foreach (var p in s.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
object v;
|
||||
if (!sel.TryGetValue(p.code, out v)) return "param";
|
||||
if (!DomainValid(p, v)) return "domain";
|
||||
}
|
||||
var cfg = new Configurator(s);
|
||||
var fake = new Variant { modelCode = "", @params = sel };
|
||||
if (cfg.RuleViolated(fake)) return "rule";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 部分选择校验 (联动用): 只校验已选参数的域 + 规则 (Then 参数未选不判违背)。
|
||||
/// 与 Configurator.RuleViolatedPartial 配套, 与网页 validatePartial 同语义。
|
||||
/// </summary>
|
||||
public static string ValidatePartial(Series s, Dictionary<string, object> sel)
|
||||
{
|
||||
foreach (var p in s.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
object v;
|
||||
if (!sel.TryGetValue(p.code, out v)) continue; // 未选参数不校验
|
||||
if (!DomainValid(p, v)) return "domain";
|
||||
}
|
||||
var cfg = new Configurator(s);
|
||||
if (cfg.RuleViolatedPartial(sel)) return "rule";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>生成结果: 网格命中 → Variant (IsStandard); 否则非标 (模板生成编码, 无 STEP)。不合法 → Error 非空。</summary>
|
||||
public static ParamResult Generate(Series s, Configurator cfg, Dictionary<string, object> sel)
|
||||
{
|
||||
var r = new ParamResult { Params = sel };
|
||||
if (sel.Count < (s.parameters ?? new List<ParameterDef>()).Count) { r.Error = "param"; return r; }
|
||||
r.Error = ValidateCombo(s, sel);
|
||||
if (r.Error != null) return r;
|
||||
r.ModelCode = RenderCode(s.modelCodeTemplate, sel);
|
||||
if (r.ModelCode == null) { r.Error = "template"; return r; }
|
||||
var v = cfg.ValidVariants().FirstOrDefault(x => string.Equals(x.modelCode, r.ModelCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (v != null) { r.IsStandard = true; r.Variant = v; }
|
||||
return r;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最近标准档位: 全部有效网格变体按参数距离排序 — range 参数按数值差累加, 枚举不等记大惩罚 (1e6);
|
||||
/// 距离相同取编码字典序小者 (与网页同语义)。
|
||||
/// </summary>
|
||||
public static Variant NearestStandard(Series s, Configurator cfg, Dictionary<string, object> sel)
|
||||
{
|
||||
Variant best = null;
|
||||
double bestDist = double.MaxValue;
|
||||
foreach (var v in cfg.ValidVariants())
|
||||
{
|
||||
double dist = 0;
|
||||
foreach (var p in s.parameters ?? new List<ParameterDef>())
|
||||
{
|
||||
object a, b;
|
||||
if (!sel.TryGetValue(p.code, out a) || !v.@params.TryGetValue(p.code, out b)) continue;
|
||||
if (p.type == "range") dist += Math.Abs(D(a) - D(b));
|
||||
else if (!Eq(a, b)) dist += 1e6;
|
||||
}
|
||||
if (best == null || dist < bestDist - 1e-9 ||
|
||||
(Math.Abs(dist - bestDist) < 1e-9 && string.CompareOrdinal(v.modelCode, best.modelCode) < 0))
|
||||
{ best = v; bestDist = dist; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/CatalogCore/SearchEngine.cs
Normal file
129
src/CatalogCore/SearchEngine.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 内存搜索引擎: 型号编码/系列名/参数名/关键词 (英文+拼音) 全文匹配,
|
||||
/// 支持数值参数的区间过滤。数据量 ≤ 10 万级内存扫描足够。
|
||||
/// </summary>
|
||||
public class SearchEngine
|
||||
{
|
||||
public class SearchItem
|
||||
{
|
||||
public Series Series;
|
||||
public Variant Variant;
|
||||
public string Note; // 平替来源标注 (如 "SMC SQ32-50"), 显示在系列名后
|
||||
public string ModelCode { get { return Variant.modelCode; } }
|
||||
public string SeriesNameZh { get { return Note == null ? Series.nameZh : Series.nameZh + " ← 平替 " + Note; } }
|
||||
public string SeriesNameEn { get { return Note == null ? Series.nameEn : Series.nameEn + " (Alt: " + Note + ")"; } }
|
||||
public string Step { get { return Variant.step; } }
|
||||
public double Score;
|
||||
public List<string> AltCodes = new List<string>(); // 平替对照: 指向此变体的竞品型号 (品牌 型号)
|
||||
}
|
||||
|
||||
List<SearchItem> _items = new List<SearchItem>();
|
||||
|
||||
public SearchEngine(Catalog cat)
|
||||
{
|
||||
foreach (var s in cat.series ?? new List<Series>())
|
||||
foreach (var v in s.variants ?? new List<Variant>())
|
||||
_items.Add(new SearchItem { Series = s, Variant = v });
|
||||
|
||||
// 平替对照: 竞品型号挂到对应我方变体上 (搜竞品型号 = 命中我方型号)
|
||||
foreach (var cr in cat.crossRefs ?? new List<CrossRefRow>())
|
||||
{
|
||||
var it = _items.FirstOrDefault(x => string.Equals(x.ModelCode, cr.ourModel, StringComparison.OrdinalIgnoreCase));
|
||||
if (it != null) it.AltCodes.Add(cr.brand + " " + cr.foreignModel);
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { return _items.Count; } }
|
||||
|
||||
string LangName(Series s, string lang)
|
||||
{
|
||||
return lang == "en" ? (s.nameEn ?? s.nameZh) : (s.nameZh ?? s.nameEn);
|
||||
}
|
||||
|
||||
string LangParamName(ParameterDef p, string lang)
|
||||
{
|
||||
return lang == "en" ? (p.nameEn ?? p.nameZh) : (p.nameZh ?? p.nameEn);
|
||||
}
|
||||
|
||||
/// <summary>构建变体的可搜索文本 (不含型号编码本身)。</summary>
|
||||
string BuildText(SearchItem it, string lang)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
parts.Add(LangName(it.Series, lang));
|
||||
parts.Add(it.Series.code);
|
||||
parts.Add(it.Series.keywords ?? "");
|
||||
foreach (var kv in it.Variant.@params)
|
||||
{
|
||||
var p = it.Series.parameters.FirstOrDefault(x => x.code == kv.Key);
|
||||
if (p != null)
|
||||
{
|
||||
parts.Add(LangParamName(p, lang));
|
||||
parts.Add(p.keywords ?? "");
|
||||
var disp = p.display != null && p.display.ContainsKey(Convert.ToString(kv.Value)) ? p.display[Convert.ToString(kv.Value)] : null;
|
||||
if (disp != null) parts.Add(disp);
|
||||
}
|
||||
parts.Add(Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
foreach (var alt in it.AltCodes) parts.Add(alt); // 平替对照: 竞品品牌+型号可搜
|
||||
return string.Join(" ", parts).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全文查询: 空格分词, 全部词都命中才算; 按 精确编码 > 编码前缀 > 其他文本 排序。
|
||||
/// </summary>
|
||||
public List<SearchItem> Query(string q, string lang)
|
||||
{
|
||||
var tokens = (q ?? "").ToLowerInvariant()
|
||||
.Split(new[] { ' ', ',', ',', ';', ';' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(t => t.Trim())
|
||||
.Where(t => t.Length > 0)
|
||||
.ToList();
|
||||
if (tokens.Count == 0) return new List<SearchItem>();
|
||||
|
||||
foreach (var it in _items)
|
||||
{
|
||||
var code = it.ModelCode.ToLowerInvariant();
|
||||
var alts = it.AltCodes.Select(a => a.ToLowerInvariant()).ToList();
|
||||
var text = BuildText(it, lang);
|
||||
it.Score = 0;
|
||||
if (!tokens.All(t => code.Contains(t) || alts.Any(a => a.Contains(t)) || text.Contains(t)))
|
||||
continue;
|
||||
if (tokens.Any(t => code == t)) it.Score = 1000;
|
||||
else if (tokens.Any(t => alts.Any(a => a == t))) it.Score = 900; // 竞品型号精确命中
|
||||
else if (tokens.All(t => code.Contains(t))) it.Score = 800;
|
||||
else if (tokens.All(t => alts.Any(a => a.Contains(t)) || text.Contains(t))) it.Score = 500;
|
||||
else it.Score = 100;
|
||||
}
|
||||
return _items.Where(x => x.Score > 0).OrderByDescending(x => x.Score).ThenBy(x => x.ModelCode).ToList();
|
||||
}
|
||||
|
||||
/// <summary>数值参数区间过滤 (range 的键为参数 code, 值为 min/max 数组, null 表示不限制)。</summary>
|
||||
public List<SearchItem> RangeFilter(List<SearchItem> items, Dictionary<string, double?[]> ranges)
|
||||
{
|
||||
if (ranges == null || ranges.Count == 0) return items;
|
||||
return items.Where(it =>
|
||||
{
|
||||
foreach (var r in ranges)
|
||||
{
|
||||
var p = it.Series.parameters.FirstOrDefault(x => x.code == r.Key);
|
||||
if (p == null || p.type != "number") continue;
|
||||
object val;
|
||||
if (!it.Variant.@params.TryGetValue(r.Key, out val)) continue;
|
||||
double d;
|
||||
if (!double.TryParse(Convert.ToString(val, System.Globalization.CultureInfo.InvariantCulture),
|
||||
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out d))
|
||||
continue;
|
||||
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;
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
342
src/CatalogCore/SelfTest.cs
Normal file
342
src/CatalogCore/SelfTest.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
#if !CUSTOMER_BUILD
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Ounibo.Catalog.Viewer3D;
|
||||
|
||||
namespace Ounibo.Catalog.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 自测 (--selftest): 通用目录包检查 + 样例包(SC/MAQ)专项回归。
|
||||
/// 结果写入数据包同目录 selftest.log, 退出码 0=全部通过。
|
||||
/// </summary>
|
||||
public static class SelfTest
|
||||
{
|
||||
public static int Run(string opcPath)
|
||||
{
|
||||
var log = new StringBuilder();
|
||||
int pass = 0, fail = 0;
|
||||
Action<string, bool> check = (name, ok) =>
|
||||
{
|
||||
log.AppendLine((ok ? "PASS " : "FAIL ") + name);
|
||||
if (ok) pass++; else fail++;
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using (var pkg = OpcPackage.Open(opcPath))
|
||||
{
|
||||
var cat = pkg.Catalog;
|
||||
check("catalog.json 读取 (schemaVersion=" + cat.schemaVersion + ")", !string.IsNullOrEmpty(cat.schemaVersion));
|
||||
check("分类数 >= 1 (" + (cat.categories == null ? 0 : cat.categories.Count) + ")", cat.categories != null && cat.categories.Count >= 1);
|
||||
check("系列数 >= 1 (" + (cat.series == null ? 0 : cat.series.Count) + ")", cat.series != null && cat.series.Count >= 1);
|
||||
|
||||
bool isSample = cat.series.Count >= 2 && cat.series[0].code == "SC";
|
||||
|
||||
// ============ 通用检查 (任意目录包) ============
|
||||
int totalV = cat.series.Sum(x => x.variants == null ? 0 : x.variants.Count);
|
||||
check("变体总数 " + totalV, totalV > 0);
|
||||
|
||||
bool allValid = true;
|
||||
foreach (var sAny in cat.series)
|
||||
if (sAny.variants != null && new Configurator(sAny).ValidVariants().Count != sAny.variants.Count)
|
||||
allValid = false;
|
||||
check("所有变体满足各自系列规则", allValid);
|
||||
|
||||
var s0 = cat.series[0];
|
||||
var cfg0 = new Configurator(s0);
|
||||
var v0 = cfg0.ValidVariants().FirstOrDefault();
|
||||
check("首个系列可解析出有效变体 (" + s0.code + ")", v0 != null);
|
||||
|
||||
// 回归: AllowedOptions 必须包含"已选参数"的键 (曾导致选择被 UI 清空的 bug)
|
||||
var selReg = new Dictionary<string, object> { { s0.parameters[0].code, s0.parameters[0].values[0] } };
|
||||
var allowedReg = cfg0.AllowedOptions(selReg);
|
||||
check("回归: AllowedOptions 含已选参数键 (" + s0.parameters[0].code + ")", allowedReg.ContainsKey(s0.parameters[0].code));
|
||||
|
||||
// 回归: 完整选型后已选参数仍可改 (曾因自身值锁死可选列表)
|
||||
var fullSel = new Dictionary<string, object>();
|
||||
foreach (var p in s0.parameters) fullSel[p.code] = v0.@params[p.code];
|
||||
var allowedFull = cfg0.AllowedOptions(fullSel);
|
||||
var pAlt = s0.parameters.FirstOrDefault(x => (x.values ?? new List<object>()).Count > 1);
|
||||
bool changeable = pAlt != null && allowedFull[pAlt.code].Count > 1;
|
||||
check("回归: 完整选型后参数可改 (" + (pAlt == null ? "?" : pAlt.code) + " 可选项 " + (pAlt == null ? 0 : allowedFull[pAlt.code].Count) + ")", changeable);
|
||||
|
||||
var engine0 = new SearchEngine(cat);
|
||||
var q0 = engine0.Query(s0.code, "zh");
|
||||
check("按系列代码搜索命中 (" + q0.Count + ")", q0.Count > 0);
|
||||
|
||||
var tmpG = Path.Combine(Path.GetTempPath(), "ounibo_selftest");
|
||||
Directory.CreateDirectory(tmpG);
|
||||
var fG = pkg.ExtractStep(v0, tmpG);
|
||||
var contentG = File.ReadAllText(fG);
|
||||
check("STEP 提取成功且含实体", File.Exists(fG) && contentG.Contains("MANIFOLD_SOLID_BREP"));
|
||||
|
||||
var cylsG = StepMesh.Parse(contentG);
|
||||
var cylCount = cylsG == null ? 0 : cylsG.Count;
|
||||
var realStep = contentG.Contains("MANIFOLD_SOLID_BREP") || contentG.Contains("ADVANCED_BREP_SHAPE_REPRESENTATION");
|
||||
check("STEP 可解析 (" + cylCount + " 圆柱" + (realStep && cylCount < 3 ? ", 真实 B-rep → 网页走新迪查看器" : "") + ")",
|
||||
cylCount >= 3 || realStep);
|
||||
|
||||
var issuesG = PackageValidator.Validate(pkg);
|
||||
check("目录包校验无 ERROR (" + issuesG.Count(i => i.Severity == "ERROR") + " 错误, " + issuesG.Count(i => i.Severity == "WARNING") + " 警告)",
|
||||
issuesG.All(i => i.Severity != "ERROR"));
|
||||
|
||||
// 手册页附件 (kind=manual): 有则抽查首个系列可读 (尺寸图窗口对照)
|
||||
var manSeries = cat.series.FirstOrDefault(x => x.attachments != null && x.attachments.Any(a => a.kind == "manual"));
|
||||
if (manSeries != null)
|
||||
{
|
||||
var manAtt = manSeries.attachments.First(a => a.kind == "manual");
|
||||
byte[] manBytes = null;
|
||||
try { manBytes = pkg.ReadAsset(manAtt.path); } catch { }
|
||||
check("手册页附件可读 (" + manSeries.code + " " + manAtt.path + ")", manBytes != null && manBytes.Length > 1000);
|
||||
}
|
||||
|
||||
// 每个系列: 初始可选值非空 + 可解析出完整选型 (UI 可用性诊断)
|
||||
foreach (var sAny in cat.series)
|
||||
{
|
||||
var cfgAny = new Configurator(sAny);
|
||||
var allowedAny = cfgAny.AllowedOptions(new Dictionary<string, object>());
|
||||
bool ok = true;
|
||||
foreach (var p in sAny.parameters)
|
||||
{
|
||||
List<object> vals;
|
||||
if (!allowedAny.TryGetValue(p.code, out vals) || vals.Count == 0)
|
||||
{
|
||||
check("系列 " + sAny.code + " 参数 " + p.code + " 初始可选值非空", false);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (ok)
|
||||
{
|
||||
var selAny = new Dictionary<string, object>();
|
||||
foreach (var p in sAny.parameters)
|
||||
selAny[p.code] = allowedAny[p.code][0];
|
||||
var vAny = cfgAny.Resolve(selAny);
|
||||
check("系列 " + sAny.code + " 可解析出完整选型 (" + (vAny == null ? "null" : vAny.modelCode) + ")", vAny != null);
|
||||
}
|
||||
}
|
||||
|
||||
// 规则过滤通用验证: 找第一个含 eq→in 规则的系列, 验证过滤后的允许值都在 Then 列表内
|
||||
var rSeries = cat.series.FirstOrDefault(x => x.rules != null && x.rules.Count > 0);
|
||||
if (rSeries != null)
|
||||
{
|
||||
var r = rSeries.rules[0];
|
||||
if (r.If != null && r.Then != null && r.If.Op == "eq" && r.Then.Op == "in")
|
||||
{
|
||||
var cfgR = new Configurator(rSeries);
|
||||
var selR = new Dictionary<string, object> { { r.If.Param, r.If.Value } };
|
||||
var allowedR = cfgR.AllowedOptions(selR);
|
||||
List<object> thenVals;
|
||||
if (allowedR.TryGetValue(r.Then.Param, out thenVals))
|
||||
{
|
||||
var allowedStr = ((object[])r.Then.Value).Select(x => Convert.ToString(x, System.Globalization.CultureInfo.InvariantCulture)).ToList();
|
||||
bool ruleOk = thenVals.Count > 0 && thenVals.All(x => allowedStr.Contains(Convert.ToString(x, System.Globalization.CultureInfo.InvariantCulture)));
|
||||
check("规则过滤生效 (" + rSeries.code + ": " + r.If.Param + "=" + Convert.ToString(r.If.Value) + " → " + r.Then.Param + " 仅 " + thenVals.Count + " 值)", ruleOk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 参数化引擎 (mode=parametric 系列, 一期 2026-08-25) ============
|
||||
var pSeries = cat.series.FirstOrDefault(x => ParametricEngine.IsParametric(x));
|
||||
if (pSeries != null)
|
||||
{
|
||||
var ren1 = ParametricEngine.RenderCode("KC{type}{bore}-{stroke}{magnet}{mount}",
|
||||
new Dictionary<string, object> { { "type", "00" }, { "bore", 32 }, { "stroke", 10 }, { "magnet", "" }, { "mount", "" } });
|
||||
check("参数化: 模板渲染 KC0032-10 (" + ren1 + ")", ren1 == "KC0032-10");
|
||||
var ren2 = ParametricEngine.RenderCode("A{type:pad2}", new Dictionary<string, object> { { "type", "3" } });
|
||||
check("参数化: pad 补零 A03 (" + ren2 + ")", ren2 == "A03");
|
||||
var ren3 = ParametricEngine.RenderCode("A{nofield}", new Dictionary<string, object> { { "type", "3" } });
|
||||
check("参数化: 未知字段 → null", ren3 == null);
|
||||
|
||||
// 对拍回归: 参数化系列全部变体编码可被模板重新生成 (构建门禁的运行时复核)
|
||||
var cfgP = new Configurator(pSeries);
|
||||
var bad = (pSeries.variants ?? new List<Variant>()).Count(v =>
|
||||
ParametricEngine.RenderCode(pSeries.modelCodeTemplate, v.@params) != v.modelCode);
|
||||
check("参数化: " + pSeries.code + " 全部 " + (pSeries.variants == null ? 0 : pSeries.variants.Count) +
|
||||
" 编码重生成一致 (不一致 " + bad + ")", bad == 0);
|
||||
|
||||
// 测试向量文件 (与网页同源): 包内 parametric-vectors.json → 逐条对拍
|
||||
if (pkg.EntryNames.Contains("parametric-vectors.json"))
|
||||
{
|
||||
var vecText = Encoding.UTF8.GetString(pkg.ReadAsset("parametric-vectors.json"));
|
||||
var vec = new System.Web.Script.Serialization.JavaScriptSerializer().DeserializeObject(vecText) as Dictionary<string, object>;
|
||||
var cases = vec != null && vec.ContainsKey("cases") ? (object[])vec["cases"] : new object[0];
|
||||
int vecOk = 0;
|
||||
var vecFails = new List<string>();
|
||||
foreach (var cObj in cases)
|
||||
{
|
||||
var c = cObj as Dictionary<string, object>;
|
||||
if (c == null) { vecFails.Add("向量格式错误"); continue; }
|
||||
var sCode = Convert.ToString(c["series"]);
|
||||
var cs = cat.series.FirstOrDefault(x => x.code == sCode);
|
||||
if (cs == null) { vecFails.Add("系列不存在 " + sCode); continue; }
|
||||
var cc = new Configurator(cs);
|
||||
var pars = new Dictionary<string, object>();
|
||||
foreach (var kv in (Dictionary<string, object>)c["params"]) pars[kv.Key] = kv.Value;
|
||||
var res = ParametricEngine.Generate(cs, cc, pars);
|
||||
var errExp = c.ContainsKey("expectError") ? Convert.ToString(c["expectError"]) : null;
|
||||
bool ok;
|
||||
if (errExp != null)
|
||||
ok = res != null && res.Error == errExp;
|
||||
else
|
||||
{
|
||||
ok = res != null && res.Error == null &&
|
||||
Convert.ToString(c["expectCode"]) == res.ModelCode &&
|
||||
(!c.ContainsKey("expectStandard") || Convert.ToBoolean(c["expectStandard"]) == res.IsStandard);
|
||||
if (ok && c.ContainsKey("expectNearest"))
|
||||
{
|
||||
var nr = ParametricEngine.NearestStandard(cs, cc, pars);
|
||||
ok = nr != null && nr.modelCode == Convert.ToString(c["expectNearest"]);
|
||||
}
|
||||
}
|
||||
if (ok) vecOk++;
|
||||
else vecFails.Add(sCode + " → " + (res == null ? "null" : (res.ModelCode ?? res.Error)));
|
||||
}
|
||||
check("参数化: 测试向量对拍 " + vecOk + "/" + cases.Length +
|
||||
(vecFails.Count > 0 ? " (失败: " + string.Join("; ", vecFails.Take(5)) + ")" : ""),
|
||||
cases.Length > 0 && vecOk == cases.Length);
|
||||
}
|
||||
else
|
||||
check("参数化: 测试向量文件存在于包内", false);
|
||||
}
|
||||
|
||||
// ============ 样例包专项回归 (SC/MAQ 虚构样例) ============
|
||||
if (isSample)
|
||||
{
|
||||
var s = cat.series[0];
|
||||
var cfg = new Configurator(s);
|
||||
var valid = cfg.ValidVariants();
|
||||
check("样例: SC 有效变体 72 个 (实际 " + valid.Count + ")", valid.Count == 72);
|
||||
check("样例: 有效变体全部满足规则", valid.All(v => !cfg.RuleViolated(v)));
|
||||
|
||||
var sel1 = new Dictionary<string, object> { { "bore", 16 } };
|
||||
var opts1 = cfg.AllowedOptions(sel1);
|
||||
var strokes = opts1["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).OrderBy(x => x).ToList();
|
||||
check("样例: R1 缸径16→行程仅25/50", strokes.Count == 2 && strokes[0] == 25 && strokes[1] == 50);
|
||||
check("样例: R2 缸径16→磁石仅空值", opts1["magnet"].Count == 1 && string.IsNullOrEmpty(Convert.ToString(opts1["magnet"][0])));
|
||||
|
||||
var sel2 = new Dictionary<string, object> { { "magnet", "S" } };
|
||||
var bores = cfg.AllowedOptions(sel2)["bore"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).ToList();
|
||||
check("样例: R2 磁石S→缸径不含16", !bores.Contains(16) && bores.Count == 3);
|
||||
|
||||
var selCb = new Dictionary<string, object> { { "mount", "CB" } };
|
||||
var strokesCb = cfg.AllowedOptions(selCb)["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).ToList();
|
||||
check("样例: R3 中摆CB→行程不含100", !strokesCb.Contains(100) && strokesCb.Count == 3);
|
||||
|
||||
var sM = cat.series[1];
|
||||
check("样例: 系列2 = MAQ", sM.code == "MAQ");
|
||||
var cfgM = new Configurator(sM);
|
||||
var selM = new Dictionary<string, object> { { "bore", 6 } };
|
||||
var strokesM = cfgM.AllowedOptions(selM)["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).OrderBy(x => x).ToList();
|
||||
check("样例: MAQ 缸径6→行程仅10/20", strokesM.Count == 2 && strokesM[0] == 10 && strokesM[1] == 20);
|
||||
var vM = cfgM.Resolve(new Dictionary<string, object> { { "bore", 10 }, { "stroke", 40 }, { "magnet", "S" }, { "mount", "LB" } });
|
||||
check("样例: MAQ 选型命中 MAQ10x40S-LB", vM != null && vM.modelCode == "MAQ10x40S-LB");
|
||||
|
||||
var sel3 = new Dictionary<string, object> { { "bore", 32 }, { "stroke", 50 }, { "magnet", "S" }, { "mount", "LB" } };
|
||||
var v3 = cfg.Resolve(sel3);
|
||||
check("样例: 完整选型命中 SC32x50S-LB", v3 != null && v3.modelCode == "SC32x50S-LB");
|
||||
var tmp = Path.Combine(Path.GetTempPath(), "ounibo_selftest");
|
||||
Directory.CreateDirectory(tmp);
|
||||
var f = pkg.ExtractStep(v3, tmp);
|
||||
var content = File.ReadAllText(f);
|
||||
int solidCount = content.Split(new[] { "MANIFOLD_SOLID_BREP" }, StringSplitOptions.None).Length - 1;
|
||||
check("样例: STEP 提取成功且含 4 个实体", File.Exists(f) && solidCount == 4);
|
||||
|
||||
var cyls = StepMesh.Parse(content);
|
||||
check("样例: STEP 解析出 4 个圆柱", cyls != null && cyls.Count == 4);
|
||||
if (cyls != null && cyls.Count == 4)
|
||||
{
|
||||
check("样例: 缸体R=18 杆R=6.4", Math.Abs(cyls[1].R - 18) < 0.01 && Math.Abs(cyls[3].R - 6.4) < 0.01);
|
||||
var mesh = StepMesh.BuildMesh(cyls[0]);
|
||||
check("样例: 网格生成", mesh.Positions.Count > 0 && mesh.TriangleIndices.Count % 3 == 0);
|
||||
}
|
||||
|
||||
check("样例: 尺寸图资源存在", pkg.EntryNames.Any(n => n.Contains("dim-drawing")));
|
||||
check("样例: 数据表 PDF 资源存在", pkg.EntryNames.Any(n => n.EndsWith(".pdf")));
|
||||
|
||||
var r1 = engine0.Query("SC32", "zh");
|
||||
check("样例: 搜索 SC32 命中 SC32x50S-LB", r1.Any(x => x.ModelCode == "SC32x50S-LB"));
|
||||
var r2 = engine0.Query("gangjing", "zh");
|
||||
var hasKw = (s.keywords ?? "").Length > 0 ||
|
||||
(s.parameters ?? new List<ParameterDef>()).Any(p => (p.keywords ?? "").Length > 0);
|
||||
if (hasKw)
|
||||
check("样例: 拼音关键词 gangjing 命中", r2.Count > 0);
|
||||
else
|
||||
log.AppendLine("SKIP 拼音关键词 (数据包未提供关键词, 属已知限制)");
|
||||
var r3 = engine0.Query("magnet", "en");
|
||||
check("样例: 英文关键词 magnet 命中", r3.Count > 0);
|
||||
var r4 = engine0.Query("32x50", "zh");
|
||||
check("样例: 编码片段 32x50 命中 SC32x50S-LB", r4.Any(x => x.ModelCode == "SC32x50S-LB"));
|
||||
|
||||
var allItems = engine0.Query("SC", "zh");
|
||||
var ranged = engine0.RangeFilter(allItems, new Dictionary<string, double?[]> { { "bore", new double?[] { 10, 30 } } });
|
||||
var rangedBores = ranged.Select(x => Convert.ToInt32(x.Variant.@params["bore"], System.Globalization.CultureInfo.InvariantCulture)).Distinct().OrderBy(x => x).ToList();
|
||||
check("样例: 缸径 10~30 过滤 → 仅16/25", ranged.Count > 0 && rangedBores.Count == 2 && rangedBores[0] == 16 && rangedBores[1] == 25);
|
||||
|
||||
// Builder 打包回路 (样例数据 → 新 .opc)
|
||||
var sdDir = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(opcPath), "..", "..", "sample-data", "onb-sc"));
|
||||
var tmpOpc = Path.Combine(Path.GetTempPath(), "ounibo_selftest_build.opc");
|
||||
var spec = new Series
|
||||
{
|
||||
code = "SC",
|
||||
nameZh = "SC 系列标准气缸",
|
||||
nameEn = "SC Series Standard Cylinder",
|
||||
parameters = new List<ParameterDef>
|
||||
{
|
||||
new ParameterDef { code = "bore", nameZh = "缸径", nameEn = "Bore", unit = "mm", type = "number", values = new List<object> { 16, 25, 32, 40 } },
|
||||
new ParameterDef { code = "stroke", nameZh = "行程", nameEn = "Stroke", unit = "mm", type = "number", values = new List<object> { 25, 50, 75, 100 } },
|
||||
new ParameterDef { code = "magnet", nameZh = "磁石", nameEn = "Magnet", unit = "", type = "enum", values = new List<object> { "", "S" } },
|
||||
new ParameterDef { code = "mount", nameZh = "安装方式", nameEn = "Mounting", unit = "", type = "enum", values = new List<object> { "FA", "LB", "CB" } }
|
||||
},
|
||||
rules = new List<Rule>
|
||||
{
|
||||
new Rule { If = new RuleCond { Param = "bore", Op = "eq", Value = 16 }, Then = new RuleCond { Param = "stroke", Op = "in", Value = new object[] { 25, 50 } } },
|
||||
new Rule { If = new RuleCond { Param = "magnet", Op = "eq", Value = "S" }, Then = new RuleCond { Param = "bore", Op = "in", Value = new object[] { 25, 32, 40 } } },
|
||||
new Rule { If = new RuleCond { Param = "mount", Op = "eq", Value = "CB" }, Then = new RuleCond { Param = "stroke", Op = "in", Value = new object[] { 25, 50, 75 } } }
|
||||
},
|
||||
naming = new Naming { stepNameTemplate = "{model}.step" },
|
||||
attachments = new List<Attachment>(),
|
||||
variants = new List<Variant>()
|
||||
};
|
||||
var buildRes = OpcBuilder.Build(tmpOpc, "欧霓博气动目录", "2026.06", "ACT", "气动执行元件", "Pneumatic Actuators",
|
||||
spec, Path.Combine(sdDir, "params.csv"), Path.Combine(sdDir, "step"),
|
||||
new List<BuildAttachmentSpec>
|
||||
{
|
||||
new BuildAttachmentSpec { kind = "datasheet", lang = "zh", source = Path.Combine(sdDir, "docs", "datasheet", "SC_datasheet_zh.pdf") },
|
||||
new BuildAttachmentSpec { kind = "dimDrawing", lang = "zh", source = Path.Combine(sdDir, "docs", "dim-drawing", "SC32x50S-LB.png"), model = "SC32x50S-LB" }
|
||||
});
|
||||
check("样例: Builder 打包成功 (变体 " + (buildRes.Ok ? "ok" : "fail") + ")", buildRes.Ok);
|
||||
foreach (var be in buildRes.Errors.Take(10)) log.AppendLine(" builder-err: " + be);
|
||||
if (buildRes.Ok)
|
||||
{
|
||||
using (var pkg2 = OpcPackage.Open(tmpOpc))
|
||||
{
|
||||
check("样例: Builder 产物可打开且 72 变体", pkg2.Catalog.series[0].variants.Count == 72);
|
||||
var issues2 = PackageValidator.Validate(pkg2);
|
||||
check("样例: Builder 产物校验通过", issues2.All(i => i.Severity != "ERROR"));
|
||||
var cfg2 = new Configurator(pkg2.Catalog.series[0]);
|
||||
var selB = new Dictionary<string, object> { { "bore", 40 }, { "stroke", 100 }, { "magnet", "" }, { "mount", "FA" } };
|
||||
var vb = cfg2.Resolve(selB);
|
||||
check("样例: Builder 产物选型命中 SC40x100-FA", vb != null && vb.modelCode == "SC40x100-FA");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
fail++;
|
||||
log.AppendLine("EXCEPTION: " + ex);
|
||||
}
|
||||
|
||||
log.AppendLine(string.Format("结果: {0} 通过, {1} 失败", pass, fail));
|
||||
var logPath = Path.Combine(Path.GetDirectoryName(opcPath) ?? ".", "selftest.log");
|
||||
File.WriteAllText(logPath, log.ToString(), Encoding.UTF8);
|
||||
Console.Out.WriteLine("selftest -> " + logPath);
|
||||
return fail == 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
1234
src/GenServer/GenServer.cs
Normal file
1234
src/GenServer/GenServer.cs
Normal file
File diff suppressed because it is too large
Load Diff
110
src/Viewer3D/StepMesh.cs
Normal file
110
src/Viewer3D/StepMesh.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
|
||||
namespace Ounibo.Catalog.Viewer3D
|
||||
{
|
||||
public class Cyl
|
||||
{
|
||||
public double Z0, Z1, R;
|
||||
public double X; // 圆心 X 偏移 (双轴活塞杆)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析本项目生成的极简 STEP (圆柱组合), 并离散为 WPF 网格。
|
||||
/// 注: 只支持自有生成器的格式; 真实 CAD 的通用 STEP 解析在 M2 由 Builder 侧处理。
|
||||
/// </summary>
|
||||
public static class StepMesh
|
||||
{
|
||||
public static List<Cyl> Parse(string step)
|
||||
{
|
||||
var rs = new List<double>();
|
||||
foreach (Match m in Regex.Matches(step, @"CYLINDRICAL_SURFACE\('',#\d+,([\d.]+)\);"))
|
||||
rs.Add(double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture));
|
||||
|
||||
// 圆心点 = 仅有的 y=0 点: (X, 0, Z) 每圆柱一对 (底+顶)
|
||||
var xs = new List<double>();
|
||||
var zs = new List<double>();
|
||||
foreach (Match m in Regex.Matches(step, @"CARTESIAN_POINT\('',\((-?[\d.]+),0,(-?[\d.]+)\)\);"))
|
||||
{
|
||||
xs.Add(double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture));
|
||||
zs.Add(double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var cy = new List<Cyl>();
|
||||
for (int i = 0; i < rs.Count && 2 * i + 1 < zs.Count; i++)
|
||||
cy.Add(new Cyl
|
||||
{
|
||||
Z0 = Math.Min(zs[2 * i], zs[2 * i + 1]),
|
||||
Z1 = Math.Max(zs[2 * i], zs[2 * i + 1]),
|
||||
R = rs[i],
|
||||
X = xs[2 * i]
|
||||
});
|
||||
return cy;
|
||||
}
|
||||
|
||||
/// <summary>圆柱 → 三角网格 (侧面 + 顶盖 + 底盖, 带逐顶点法线)。</summary>
|
||||
public static MeshGeometry3D BuildMesh(Cyl c, int segments = 32)
|
||||
{
|
||||
var mesh = new MeshGeometry3D();
|
||||
var pts = new List<Point3D>();
|
||||
var nrm = new List<Vector3D>();
|
||||
|
||||
// 下环 (z0) 与上环 (z1)
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
double a = 2 * Math.PI * i / segments;
|
||||
double x = Math.Cos(a) * c.R + c.X, y = Math.Sin(a) * c.R;
|
||||
pts.Add(new Point3D(x, y, c.Z0));
|
||||
var n0 = new Vector3D(x - c.X, y, 0); n0.Normalize(); nrm.Add(n0);
|
||||
}
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
double a = 2 * Math.PI * i / segments;
|
||||
double x = Math.Cos(a) * c.R + c.X, y = Math.Sin(a) * c.R;
|
||||
pts.Add(new Point3D(x, y, c.Z1));
|
||||
var n1 = new Vector3D(x - c.X, y, 0); n1.Normalize(); nrm.Add(n1);
|
||||
}
|
||||
|
||||
// 侧面
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
int a0 = i, a1 = (i + 1) % segments;
|
||||
int b0 = segments + i, b1 = segments + ((i + 1) % segments);
|
||||
mesh.TriangleIndices.Add(a0); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(a1);
|
||||
mesh.TriangleIndices.Add(a1); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(b1);
|
||||
}
|
||||
|
||||
// 顶盖与底盖 (中心点 + 扇形)
|
||||
pts.Add(new Point3D(c.X, 0, c.Z1)); nrm.Add(new Vector3D(0, 0, 1));
|
||||
int topC = pts.Count - 1;
|
||||
pts.Add(new Point3D(c.X, 0, c.Z0)); nrm.Add(new Vector3D(0, 0, -1));
|
||||
int botC = pts.Count - 1;
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
int b0 = segments + i, b1 = segments + ((i + 1) % segments);
|
||||
mesh.TriangleIndices.Add(topC); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(b1);
|
||||
mesh.TriangleIndices.Add(botC); mesh.TriangleIndices.Add(i + 1 == segments ? 0 : i + 1); mesh.TriangleIndices.Add(i);
|
||||
}
|
||||
|
||||
mesh.Positions = new Point3DCollection(pts);
|
||||
mesh.Normals = new Vector3DCollection(nrm);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/// <summary>计算包围信息用于相机取景。</summary>
|
||||
public static void Bounds(List<Cyl> cyls, out double cx, out double cy, out double cz, out double r, out double h)
|
||||
{
|
||||
cx = 0; cy = 0;
|
||||
double zMin = double.MaxValue, zMax = double.MinValue, rMax = 0;
|
||||
foreach (var c in cyls)
|
||||
{
|
||||
zMin = Math.Min(zMin, c.Z0); zMax = Math.Max(zMax, c.Z1); rMax = Math.Max(rMax, c.R);
|
||||
}
|
||||
cz = (zMin + zMax) / 2; h = zMax - zMin; r = rMax;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user