首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)

This commit is contained in:
wangruiguo
2026-09-03 17:55:45 +08:00
commit fafa86d3a6
241 changed files with 78656 additions and 0 deletions

122
src/App/AdminWindow.cs Normal file
View 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
View 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
View 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
View 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();
}
}
}

View 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

File diff suppressed because it is too large Load Diff

108
src/App/ViewerControl.cs Normal file
View 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);
}
}
}