feat: add match material functionality to BOM server and UI

- Implemented MatchMaterialHandler to handle material matching requests.
- Added new DTOs for match material requests and responses.
- Updated BomHttpServer to register the new match material API endpoint.
- Enhanced the UI with a new button to trigger material matching.
- Added modal to display matching results and confirm updates to material codes.
- Integrated API calls to fetch matching material codes from the PLM system.
This commit is contained in:
Ling 2026-04-09 17:06:24 +08:00
parent b2ca9a46dd
commit f13d646f7d
19 changed files with 997 additions and 267 deletions

View File

@ -140,7 +140,7 @@ namespace Laservall.Solidworks
private void Selections_NewSelection(IXDocument doc, Xarial.XCad.IXSelObject selObject)
{
m_TaskPane.Control.OnSelectChange(doc, selObject);
m_TaskPane.Control.ViewModel.OnSelectionChanged(doc, selObject);
}
public override void OnDisconnect()

View File

@ -0,0 +1,53 @@
using Newtonsoft.Json;
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Laservall.Solidworks.Common
{
public static class HttpClientHelper
{
private static readonly HttpClient _client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
public static void SetBaseAddress(string baseUrl)
{
if (!string.IsNullOrEmpty(baseUrl))
{
_client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
}
}
public static async Task<TResponse> PostJsonAsync<TRequest, TResponse>(
string url, TRequest body, CancellationToken ct = default(CancellationToken))
{
var json = JsonConvert.SerializeObject(body);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<TResponse>(responseText);
}
public static async Task<T> GetJsonAsync<T>(string url, CancellationToken ct = default(CancellationToken))
{
var response = await _client.GetAsync(url, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonConvert.DeserializeObject<T>(responseText);
}
public static async Task PostJsonAsync<TRequest>(
string url, TRequest body, CancellationToken ct = default(CancellationToken))
{
var json = JsonConvert.SerializeObject(body);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _client.PostAsync(url, content, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
}
}

View File

@ -8,12 +8,37 @@ using System.Windows;
namespace Laservall.Solidworks.Common
{
public static class ComboBoxDataRegistry
{
private static readonly Dictionary<string, List<string>> _data =
new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
private static readonly List<string> _defaultItems = new List<string>();
public static void Register(string propertyName, List<string> items)
{
_data[propertyName] = items ?? new List<string>();
}
public static List<string> GetItems(string propertyName)
{
List<string> items;
if (_data.TryGetValue(propertyName, out items))
{
return items;
}
return _defaultItems;
}
}
public class ComboxPropertyEditor : PropertyEditorBase
{
public override FrameworkElement CreateElement(PropertyItem propertyItem)
{
var comboBox = new ComboBox();
comboBox.ItemsSource = new List<string> { "A", "B", "C" };
comboBox.IsEditable = true;
var items = ComboBoxDataRegistry.GetItems(propertyItem.DisplayName);
comboBox.ItemsSource = items;
comboBox.SelectedItem = propertyItem.Value?.ToString();
comboBox.SelectionChanged += (s, e) =>
{
@ -33,7 +58,9 @@ namespace Laservall.Solidworks.Common
public override FrameworkElement CreateElement(PropertyItem propertyItem)
{
var comboBox = new ComboBox();
comboBox.ItemsSource = new List<string> { "A", "B", "C" };
comboBox.IsEditable = true;
var items = ComboBoxDataRegistry.GetItems(propertyItem.DisplayName);
comboBox.ItemsSource = items;
comboBox.SelectedItem = propertyItem.Value?.ToString();
comboBox.SelectionChanged += (s, e) =>
{
@ -47,4 +74,20 @@ namespace Laservall.Solidworks.Common
return ComboBox.SelectedItemProperty;
}
}
public class ReadOnlyTextPropertyEditor : PropertyEditorBase
{
public override FrameworkElement CreateElement(PropertyItem propertyItem)
{
return new System.Windows.Controls.TextBlock
{
VerticalAlignment = VerticalAlignment.Center
};
}
public override DependencyProperty GetDependencyProperty()
{
return System.Windows.Controls.TextBlock.TextProperty;
}
}
}

39
Common/RelayCommand.cs Normal file
View File

@ -0,0 +1,39 @@
using System;
using System.Windows.Input;
namespace Laservall.Solidworks.Common
{
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Func<object, bool> _canExecute;
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public RelayCommand(Action execute, Func<bool> canExecute = null)
: this(_ => execute(), canExecute != null ? (Func<object, bool>)(_ => canExecute()) : null)
{
}
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object parameter)
{
_execute(parameter);
}
public void RaiseCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
}

23
Common/ViewModelBase.cs Normal file
View File

@ -0,0 +1,23 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Laservall.Solidworks.Common
{
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool Set<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}

View File

@ -1,4 +1,4 @@
using Laservall.Solidworks.Model;
using Laservall.Solidworks.Model;
using SolidWorks.Interop.sldworks;
using System;
using System.Collections.Generic;
@ -6,104 +6,237 @@ using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Shapes;
using Xarial.XCad.Documents;
using Xarial.XCad.SolidWorks.Documents;
namespace Laservall.Solidworks.Extension
{
public static class SWDocReader
{
public static void ReadDocProperties(IModelDoc2 doc,PartPropModel partPropModel)
// 特殊属性名称需要通过SolidWorks内置API读取而非自定义属性
private static readonly HashSet<string> SpecialReadProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
if (doc != null)
"材质", "MATERIAL",
"重量", "MASS"
};
public static void ReadDocProperties(IModelDoc2 doc, PartPropModel partPropModel)
{
if (doc == null) return;
Configuration activeConfig = doc.ConfigurationManager?.ActiveConfiguration;
CustomPropertyManager configPropMgr = activeConfig?.CustomPropertyManager;
CustomPropertyManager filePropMgr = null;
try
{
partPropModel.GetType().GetProperties().ToList().ForEach(p =>
filePropMgr = doc.Extension?.get_CustomPropertyManager("");
}
catch { }
partPropModel.GetType().GetProperties().ToList().ForEach(p =>
{
p.CustomAttributes.ToList().ForEach(attr =>
{
// Get DisplayName attribute
p.CustomAttributes.ToList().ForEach(attr =>
if (attr.AttributeType == typeof(System.ComponentModel.DisplayNameAttribute))
{
if (attr.AttributeType == typeof(System.ComponentModel.DisplayNameAttribute))
var displayName = attr.ConstructorArguments[0].Value.ToString();
string val = ReadPropertyValue(doc, configPropMgr, filePropMgr, displayName);
if (val != null)
{
var displayName = attr.ConstructorArguments[0].Value.ToString();
// Get custom property value by display name
var val2 = GetDocProperty(doc, displayName);
if (val2 != null)
switch (p.PropertyType.Name)
{
// 判断属性类型
switch (p.PropertyType.Name)
{
case "Double":
if (double.TryParse(val2, out double d))
{
p.SetValue(partPropModel, d);
}
break;
case "Int32":
if (int.TryParse(val2, out int i))
{
p.SetValue(partPropModel, i);
}
break;
case "Boolean":
if (bool.TryParse(val2, out bool b))
{
p.SetValue(partPropModel, b);
}
break;
default:
p.SetValue(partPropModel, val2);
break;
}
case "Double":
if (double.TryParse(val, out double d))
{
p.SetValue(partPropModel, d);
}
break;
case "Int32":
if (int.TryParse(val, out int i))
{
p.SetValue(partPropModel, i);
}
break;
case "Boolean":
// 支持 "true"/"false" 和 "1"/"0" 和 "是"/"否"
if (bool.TryParse(val, out bool b))
{
p.SetValue(partPropModel, b);
}
else if (val == "1" || string.Equals(val, "是", StringComparison.OrdinalIgnoreCase))
{
p.SetValue(partPropModel, true);
}
else if (val == "0" || string.Equals(val, "否", StringComparison.OrdinalIgnoreCase))
{
p.SetValue(partPropModel, false);
}
break;
default:
p.SetValue(partPropModel, val);
break;
}
}
});
//var val = partComponent.Component.CustomPropertyManager[p.Name];
//if (val != null)
//{
// p.SetValue(partPropModel, val);
//}
}
});
});
}
/// <summary>
/// 读取属性值:先检查特殊属性,再尝试配置级,最后回退到文件级
/// </summary>
private static string ReadPropertyValue(
IModelDoc2 doc,
CustomPropertyManager configPropMgr,
CustomPropertyManager filePropMgr,
string propertyName)
{
// 1. 特殊属性内置属性通过SolidWorks API直接获取
if (SpecialReadProperties.Contains(propertyName))
{
string specialVal = ReadSpecialProperty(doc, propertyName);
if (!string.IsNullOrEmpty(specialVal))
{
return specialVal;
}
// 特殊属性读取失败时,仍尝试从自定义属性读取(用户可能手动设置了)
}
// 2. 配置级自定义属性(优先)
if (configPropMgr != null)
{
string configVal = GetPropertyFromManager(configPropMgr, propertyName);
if (configVal != null)
{
return configVal;
}
}
// 3. 文件级自定义属性(回退)
if (filePropMgr != null)
{
string fileVal = GetPropertyFromManager(filePropMgr, propertyName);
if (fileVal != null)
{
return fileVal;
}
}
return null;
}
/// <summary>
/// 从指定的CustomPropertyManager读取属性值
/// </summary>
private static string GetPropertyFromManager(CustomPropertyManager propMgr, string propertyName)
{
try
{
int status = propMgr.Get6(propertyName, false, out string val, out string valout, out bool wasResolved, out bool _);
// status: swCustomInfoGetResult_e
// 0 = swCustomInfoGetResult_NotPresent — 属性不存在
if (status == 0)
{
return null;
}
if (wasResolved && !string.IsNullOrEmpty(valout))
{
return valout;
}
if (!string.IsNullOrEmpty(val))
{
return val;
}
return null;
}
catch
{
return null;
}
}
/// <summary>
/// 获取当前激活配置的自定义属性
/// 读取特殊内置属性(材质、重量等)
/// </summary>
/// <param name="model"></param>
/// <param name="propertyName"></param>
/// <returns></returns>
public static string GetDocProperty(IModelDoc2 model , string propertyName)
private static string ReadSpecialProperty(IModelDoc2 doc, string propertyName)
{
try
{
// 材质 — 仅Part文件有材质属性
if (string.Equals(propertyName, "材质", StringComparison.OrdinalIgnoreCase)
|| string.Equals(propertyName, "MATERIAL", StringComparison.OrdinalIgnoreCase))
{
PartDoc partDoc = doc as PartDoc;
if (partDoc != null)
{
Configuration config = doc.ConfigurationManager?.ActiveConfiguration;
if (config != null)
{
string materialName = partDoc.GetMaterialPropertyName2(config.Name, out string _);
if (!string.IsNullOrEmpty(materialName))
{
return materialName;
}
}
}
return null;
}
// 重量(质量) — Part和Assembly都支持
if (string.Equals(propertyName, "重量", StringComparison.OrdinalIgnoreCase)
|| string.Equals(propertyName, "MASS", StringComparison.OrdinalIgnoreCase))
{
ModelDocExtension docExt = doc.Extension;
if (docExt != null)
{
int status = 0;
// 返回值: [CenterOfMassX, CenterOfMassY, CenterOfMassZ, Volume, Area, Mass, ...]
double[] massProps = docExt.GetMassProperties(1, ref status) as double[];
if (status == 0 && massProps != null && massProps.Length > 5)
{
double mass = massProps[5]; // 质量,单位: kg
return Math.Round(mass, 4).ToString();
}
}
return null;
}
}
catch (Exception ex)
{
Debug.WriteLine($"ReadSpecialProperty({propertyName}) 异常: {ex.Message}");
}
return null;
}
/// <summary>
/// 获取单个属性值(保留向后兼容)
/// 优先级:特殊属性 > 配置级 > 文件级
/// </summary>
public static string GetDocProperty(IModelDoc2 model, string propertyName)
{
if (model == null)
{
return "未打开任何文档";
}
else
Configuration activeConfig = model.ConfigurationManager?.ActiveConfiguration;
CustomPropertyManager configPropMgr = activeConfig?.CustomPropertyManager;
CustomPropertyManager filePropMgr = null;
try
{
ModelDocExtension swModelDocExt = default(ModelDocExtension);
CustomPropertyManager swCustProp = default(CustomPropertyManager);
string val = "";
string valout = "";
int status;
//swModel = (ModelDoc2)swApp.ActiveDoc;
swModelDocExt = model.Extension;
// Get the custom property data
swCustProp = swModelDocExt.get_CustomPropertyManager("");
//status = swCustProp.Get4(propertyName, false, out val, out valout);
status = swCustProp.Get6(propertyName, false, out val,out valout,out bool wasResolved,out bool _);
if (wasResolved)
{
return valout;
}
else
{
return val;
}
filePropMgr = model.Extension?.get_CustomPropertyManager("");
}
catch { }
return ReadPropertyValue(model, configPropMgr, filePropMgr, propertyName) ?? "";
}
}
}

View File

@ -1,62 +1,117 @@
using SolidWorks.Interop.sldworks;
using Laservall.Solidworks.Common;
using SolidWorks.Interop.sldworks;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xarial.XCad.Data;
namespace Laservall.Solidworks.Extension
{
public static class SWDocWriter
{
// 根据文档与属性模型,写入自定义属性
private static readonly HashSet<string> SkipWriteProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"材质", "MATERIAL",
"重量", "MASS"
};
public static void WriteDocProperties(IModelDoc2 doc, Model.PartPropModel partPropModel)
{
if (doc != null)
if (doc == null) return;
Configuration activeConfig = doc.ConfigurationManager?.ActiveConfiguration;
CustomPropertyManager configPropMgr = activeConfig?.CustomPropertyManager;
CustomPropertyManager filePropMgr = null;
try
{
partPropModel.GetType().GetProperties().ToList().ForEach(p =>
{
// Get DisplayName attribute
p.CustomAttributes.ToList().ForEach(attr =>
{
if (attr.AttributeType == typeof(System.ComponentModel.DisplayNameAttribute))
{
var displayName = attr.ConstructorArguments[0].Value.ToString();
var val = p.GetValue(partPropModel);
if (val != null)
{
// 设置自定义属性值
SetDocProperty(doc, displayName, val.ToString());
}
}
});
});
filePropMgr = doc.Extension?.get_CustomPropertyManager("");
}
catch { }
string[] configNames = null;
try
{
configNames = configPropMgr?.GetNames() as string[];
}
catch { }
partPropModel.GetType().GetProperties().ToList().ForEach(p =>
{
p.CustomAttributes.ToList().ForEach(attr =>
{
if (attr.AttributeType == typeof(System.ComponentModel.DisplayNameAttribute))
{
var displayName = attr.ConstructorArguments[0].Value.ToString();
// ReadOnly属性使用ReadOnlyTextPropertyEditor的不写入
bool isReadOnly = p.CustomAttributes.Any(a =>
a.AttributeType == typeof(System.ComponentModel.EditorAttribute) &&
a.ConstructorArguments.Any(c =>
c.Value?.ToString()?.Contains(nameof(ReadOnlyTextPropertyEditor)) == true));
if (isReadOnly) return;
// 内置特殊属性不通过自定义属性写入
if (SkipWriteProperties.Contains(displayName)) return;
var val = p.GetValue(partPropModel);
string strVal = val?.ToString() ?? "";
SetPropertyValue(configPropMgr, filePropMgr, configNames, displayName, strVal);
}
});
});
}
/// <summary>
///
/// 写入属性值:属性存在于配置级则写配置级,否则写文件级
/// 与SolidWorksBomDataProvider.SaveChanges()保持一致的策略
/// </summary>
/// <param name="model"></param>
/// <param name="propertyName"></param>
/// <returns></returns>
public static void SetDocProperty(IModelDoc2 model, string propertyName,string val)
private static void SetPropertyValue(
CustomPropertyManager configPropMgr,
CustomPropertyManager filePropMgr,
string[] configNames,
string propertyName,
string val)
{
if (model == null)
{
bool writtenToConfig = false;
if (configPropMgr != null && configNames != null && configNames.Contains(propertyName))
{
try
{
configPropMgr.Set2(propertyName, val);
writtenToConfig = true;
}
catch (Exception ex)
{
Debug.WriteLine($"SetPropertyValue config({propertyName}) failed: {ex.Message}");
}
}
else
{
//swModel = (ModelDoc2)swApp.ActiveDoc;
ModelDocExtension swModelDocExt = model.Extension;
// Get the custom property data
CustomPropertyManager swCustProp = swModelDocExt.get_CustomPropertyManager("");
swCustProp.Set2(propertyName, val);
if (!writtenToConfig && filePropMgr != null)
{
try
{
filePropMgr.Set2(propertyName, val);
}
catch (Exception ex)
{
Debug.WriteLine($"SetPropertyValue file({propertyName}) failed: {ex.Message}");
}
}
else if (!writtenToConfig && configPropMgr != null)
{
try
{
configPropMgr.Set2(propertyName, val);
}
catch (Exception ex)
{
Debug.WriteLine($"SetPropertyValue config-fallback({propertyName}) failed: {ex.Message}");
}
}
}
}

View File

@ -90,6 +90,9 @@
<Compile Include="Common\PLMConfig.cs" />
<Compile Include="Common\PropertyEditor.cs" />
<Compile Include="Common\SM4Helper.cs" />
<Compile Include="Common\ViewModelBase.cs" />
<Compile Include="Common\RelayCommand.cs" />
<Compile Include="Common\HttpClientHelper.cs" />
<Compile Include="Extension\SWDocReader.cs" />
<Compile Include="Extension\SWDocWriter.cs" />
<Compile Include="Extension\SWUtils.cs" />
@ -104,6 +107,7 @@
<Compile Include="Pane\PartPropPaneUserControl.xaml.cs">
<DependentUpon>PartPropPaneUserControl.xaml</DependentUpon>
</Compile>
<Compile Include="Pane\PartPropPaneViewModel.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Windows\ColumnConfigWindow.xaml.cs">
<DependentUpon>ColumnConfigWindow.xaml</DependentUpon>
@ -127,6 +131,7 @@
<Compile Include="Server\Handlers\SaveHandler.cs" />
<Compile Include="Server\Handlers\ExportHandler.cs" />
<Compile Include="Server\Handlers\SettingsHandler.cs" />
<Compile Include="Server\Handlers\MatchMaterialHandler.cs" />
<Compile Include="Server\StaThreadMarshaller.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -9,6 +9,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using ReadOnlyTextPropertyEditor = Laservall.Solidworks.Common.ReadOnlyTextPropertyEditor;
namespace Laservall.Solidworks.Model
{

View File

@ -21,17 +21,16 @@
<RowDefinition Height="20"/>
</Grid.RowDefinitions>
<hc:ButtonGroup Grid.Row="0" VerticalAlignment="Center" Margin="10,5">
<Button FontSize="14" Content="保存" Background="#81C784" Click="Button_Click" />
<Button FontSize="14" Content="重置" Background="#E57373" />
<Button FontSize="14" Content="保存" Background="#81C784" Command="{Binding SaveCommand}" />
<Button FontSize="14" Content="重置" Background="#E57373" Command="{Binding ResetCommand}" />
</hc:ButtonGroup>
<hc:PropertyGrid Background="Transparent"
FontSize="14"
Grid.Row="1"
SelectedObject="{Binding PartPropModel}"/>
<TextBlock Grid.Row="2"
x:Name="VersionText"
SelectedObject="{Binding Model}"/>
<TextBlock Grid.Row="2"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Text="1.0.0.1"/>
Text="{Binding VersionText}"/>
</Grid>
</UserControl>

View File

@ -1,88 +1,20 @@
using Laservall.Solidworks.Extension;
using Laservall.Solidworks.Model;
using SolidWorks.Interop.sldworks;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using Xarial.XCad;
using Xarial.XCad.Documents;
using Xarial.XCad.Features;
using Xarial.XCad.SolidWorks.Documents;
using System.Windows.Controls;
using Xarial.XCad.SolidWorks.UI;
namespace Laservall.Solidworks.Pane
{
/// <summary>
/// PartPropPaneUserControl.xaml 的交互逻辑
/// </summary>
public partial class PartPropPaneUserControl : UserControl
{
public ISwTaskPane<PartPropPaneUserControl> pane;
public IModelDoc2 selectedDoc;
public IXDocument selectedIXDoc;
public PartPropModel PartPropModel { get; set; } = new PartPropModel();
public PartPropPaneViewModel ViewModel { get; }
public PartPropPaneUserControl()
{
InitializeComponent();
DataContext = this;
VersionText.Text = $"Ver.{Assembly.GetExecutingAssembly().GetName().Version}";
}
public void OnSelectChange(IXDocument doc, IXSelObject xSelObject)
{
if (xSelObject.OwnerDocument is IXDocument)
{
if(xSelObject is ISwPartComponent partComponent)
{
selectedDoc = (IModelDoc2)partComponent.Component.GetModelDoc();
//selectedIXDoc = partComponent.ReferencedDocument;
}
else if(xSelObject is IXFeature feature)
{
if(feature.Component is ISwComponent swPartComponent)
{
selectedDoc = swPartComponent.Component.GetModelDoc() as IModelDoc2;
}
else if(feature.OwnerDocument != null)
{
selectedDoc = (feature.OwnerDocument as ISwDocument).Model;
}
}
if (selectedDoc != null)
{
SWDocReader.ReadDocProperties(selectedDoc, PartPropModel);
}
}
if(xSelObject.OwnerDocument is ISwPart)
{
//xSelObject.OwnerDocument.Properties
selectedDoc = null;
}
Debug.WriteLine($"OnSelectChange: {xSelObject.GetType()}");
}
private void Button_Click(object sender, RoutedEventArgs e)
{
if(selectedDoc != null)
{
SWDocWriter.WriteDocProperties(selectedDoc, PartPropModel);
}
ViewModel = new PartPropPaneViewModel();
DataContext = ViewModel;
}
}
}

View File

@ -0,0 +1,121 @@
using Laservall.Solidworks.Common;
using Laservall.Solidworks.Extension;
using Laservall.Solidworks.Model;
using SolidWorks.Interop.sldworks;
using System.Diagnostics;
using System.Reflection;
using System.Windows.Input;
using Xarial.XCad;
using Xarial.XCad.Documents;
using Xarial.XCad.Features;
using Xarial.XCad.SolidWorks.Documents;
namespace Laservall.Solidworks.Pane
{
public class PartPropPaneViewModel : ViewModelBase
{
private IModelDoc2 _selectedDoc;
private PartPropModel _model = new PartPropModel();
public PartPropModel Model
{
get => _model;
set => Set(ref _model, value);
}
private string _statusText;
public string StatusText
{
get => _statusText;
set => Set(ref _statusText, value);
}
private string _versionText;
public string VersionText
{
get => _versionText;
set => Set(ref _versionText, value);
}
public ICommand SaveCommand { get; }
public ICommand ResetCommand { get; }
public PartPropPaneViewModel()
{
VersionText = $"Ver.{Assembly.GetExecutingAssembly().GetName().Version}";
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
ResetCommand = new RelayCommand(ExecuteReset, CanReset);
}
public void OnSelectionChanged(IXDocument doc, IXSelObject selObject)
{
IModelDoc2 resolvedDoc = null;
if (selObject.OwnerDocument is IXDocument)
{
if (selObject is ISwPartComponent partComponent)
{
resolvedDoc = (IModelDoc2)partComponent.Component.GetModelDoc();
}
else if (selObject is IXFeature feature)
{
if (feature.Component is ISwComponent swPartComponent)
{
resolvedDoc = swPartComponent.Component.GetModelDoc() as IModelDoc2;
}
else if (feature.OwnerDocument != null)
{
resolvedDoc = (feature.OwnerDocument as ISwDocument).Model;
}
}
}
if (selObject.OwnerDocument is ISwPart)
{
resolvedDoc = null;
}
_selectedDoc = resolvedDoc;
if (_selectedDoc != null)
{
SWDocReader.ReadDocProperties(_selectedDoc, Model);
StatusText = $"已加载: {_selectedDoc.GetTitle()}";
}
else
{
StatusText = "";
}
((RelayCommand)SaveCommand).RaiseCanExecuteChanged();
((RelayCommand)ResetCommand).RaiseCanExecuteChanged();
Debug.WriteLine($"OnSelectionChanged: {selObject.GetType()}");
}
private bool CanSave()
{
return _selectedDoc != null;
}
private void ExecuteSave()
{
if (_selectedDoc == null) return;
SWDocWriter.WriteDocProperties(_selectedDoc, Model);
StatusText = "属性已保存";
}
private bool CanReset()
{
return _selectedDoc != null;
}
private void ExecuteReset()
{
if (_selectedDoc == null) return;
SWDocReader.ReadDocProperties(_selectedDoc, Model);
StatusText = "属性已重置";
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -21,6 +21,7 @@ namespace Laservall.Solidworks.Server
private readonly SaveHandler _saveHandler;
private readonly ExportHandler _exportHandler;
private readonly SettingsHandler _settingsHandler;
private readonly MatchMaterialHandler _matchMaterialHandler;
private int _port;
private bool _disposed;
@ -41,6 +42,7 @@ namespace Laservall.Solidworks.Server
_saveHandler = new SaveHandler(dataProvider);
_exportHandler = new ExportHandler(() => _bomDataHandler.LastLoadResult ?? _bomStreamHandler.LastLoadResult, dataProvider);
_settingsHandler = new SettingsHandler(dataProvider);
_matchMaterialHandler = new MatchMaterialHandler();
RegisterRoutes();
}
@ -138,6 +140,11 @@ namespace Laservall.Solidworks.Server
await _settingsHandler.HandlePost(ctx, ct);
});
_router.Post("/api/bom/match-material", async (ctx, ct) =>
{
await _matchMaterialHandler.HandleMatch(ctx, ct);
});
_router.Post("/api/shutdown", async (ctx, ct) =>
{
ctx.Response.StatusCode = 200;

View File

@ -69,4 +69,46 @@ namespace Laservall.Solidworks.Server.Dto
public int Total { get; set; }
public string CurrentName { get; set; }
}
internal sealed class MatchMaterialRequest
{
public List<MatchMaterialItemDto> Items { get; set; }
}
internal sealed class MatchMaterialItemDto
{
public string DrawingNo { get; set; }
public string DocPath { get; set; }
}
internal sealed class MatchMaterialResponse
{
public bool Success { get; set; }
public List<MatchMaterialResultDto> Results { get; set; }
public string Error { get; set; }
}
internal sealed class MatchMaterialResultDto
{
public string DrawingNo { get; set; }
public string DocPath { get; set; }
public string PartCode { get; set; }
public string PartName { get; set; }
public bool Matched { get; set; }
}
internal sealed class PlmFindPartsCodeRequest
{
public string model { get; set; }
}
internal sealed class PlmFindPartsCodeResponse
{
public string part_id { get; set; }
public string part_code { get; set; }
public string part_name { get; set; }
public string model { get; set; }
public string remark { get; set; }
public string brand { get; set; }
}
}

View File

@ -0,0 +1,123 @@
using Laservall.Solidworks.Server.Dto;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Laservall.Solidworks.Server.Handlers
{
internal sealed class MatchMaterialHandler
{
private const string PlmApiUrl = "http://10.0.0.155:9991/api/PLM/FindPartsCode";
private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
public async Task HandleMatch(HttpListenerContext context, CancellationToken ct)
{
string body;
using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8))
{
body = await reader.ReadToEndAsync();
}
var request = JsonConvert.DeserializeObject<MatchMaterialRequest>(body);
if (request?.Items == null || request.Items.Count == 0)
{
await WriteJson(context.Response, new MatchMaterialResponse
{
Success = true,
Results = new List<MatchMaterialResultDto>()
}, ct);
return;
}
try
{
var plmRequestBody = request.Items
.Select(i => new PlmFindPartsCodeRequest { model = i.DrawingNo })
.ToList();
var plmJson = JsonConvert.SerializeObject(plmRequestBody);
var httpContent = new StringContent(plmJson, Encoding.UTF8, "application/json-patch+json");
var httpResponse = await _httpClient.PostAsync(PlmApiUrl, httpContent, ct);
var responseText = await httpResponse.Content.ReadAsStringAsync();
if (!httpResponse.IsSuccessStatusCode)
{
await WriteJson(context.Response, new MatchMaterialResponse
{
Success = false,
Results = new List<MatchMaterialResultDto>(),
Error = $"PLM API 返回 {(int)httpResponse.StatusCode}: {responseText}"
}, ct);
return;
}
var plmResults = JsonConvert.DeserializeObject<List<PlmFindPartsCodeResponse>>(responseText)
?? new List<PlmFindPartsCodeResponse>();
var plmLookup = plmResults
.Where(r => !string.IsNullOrEmpty(r.part_code))
.ToDictionary(r => r.model ?? "", r => r, StringComparer.OrdinalIgnoreCase);
var results = new List<MatchMaterialResultDto>();
foreach (var item in request.Items)
{
PlmFindPartsCodeResponse plmMatch;
if (plmLookup.TryGetValue(item.DrawingNo ?? "", out plmMatch))
{
results.Add(new MatchMaterialResultDto
{
DrawingNo = item.DrawingNo,
DocPath = item.DocPath,
PartCode = plmMatch.part_code,
PartName = plmMatch.part_name,
Matched = true
});
}
else
{
results.Add(new MatchMaterialResultDto
{
DrawingNo = item.DrawingNo,
DocPath = item.DocPath,
PartCode = "",
PartName = "",
Matched = false
});
}
}
await WriteJson(context.Response, new MatchMaterialResponse
{
Success = true,
Results = results
}, ct);
}
catch (Exception ex)
{
await WriteJson(context.Response, new MatchMaterialResponse
{
Success = false,
Results = new List<MatchMaterialResultDto>(),
Error = $"匹配失败: {ex.Message}"
}, ct);
}
}
private static async Task WriteJson(HttpListenerResponse response, object data, CancellationToken ct)
{
response.ContentType = "application/json; charset=utf-8";
string json = JsonConvert.SerializeObject(data);
byte[] bytes = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes, 0, bytes.Length, ct);
response.Close();
}
}
}

View File

@ -17,7 +17,7 @@ const AppContent: React.FC = () => {
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
const [configVisible, setConfigVisible] = useState(false);
const { treeData, settings, loading, reload, updateItemsLocally } = useBomData();
const { items, treeData, settings, loading, reload, updateItemsLocally } = useBomData();
const { dirtyCells, setDirty, clearAll, isDirty, getDirtyChanges, dirtyCount } = useDirtyTracking();
const {
columns,
@ -55,6 +55,7 @@ const AppContent: React.FC = () => {
onOpenConfig={() => setConfigVisible(true)}
getDirtyChanges={getDirtyChanges}
onSaveSuccess={handleSaveSuccess}
items={items}
/>
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{viewMode === 'hierarchical' ? (

View File

@ -56,4 +56,27 @@ export interface ProgressEventDto {
Processed: number;
Total: number;
CurrentName: string;
}
export interface MatchMaterialItemDto {
DrawingNo: string;
DocPath: string;
}
export interface MatchMaterialRequest {
Items: MatchMaterialItemDto[];
}
export interface MatchMaterialResultDto {
DrawingNo: string;
DocPath: string;
PartCode: string;
PartName: string;
Matched: boolean;
}
export interface MatchMaterialResponse {
Success: boolean;
Results: MatchMaterialResultDto[];
Error: string | null;
}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Space, Button, Segmented, Badge, message } from 'antd';
import { Space, Button, Segmented, Badge, message, Modal, Table, Tag } from 'antd';
import { apiPost, getExportUrl } from '../api/client';
import type { SaveChangeDto } from '../api/types';
import type { SaveChangeDto, BomItemDto, MatchMaterialRequest, MatchMaterialResponse, MatchMaterialResultDto } from '../api/types';
interface ToolBarProps {
dirtyCount: number;
@ -11,6 +11,7 @@ interface ToolBarProps {
onOpenConfig: () => void;
getDirtyChanges: () => SaveChangeDto[];
onSaveSuccess: (changes: SaveChangeDto[]) => void;
items: BomItemDto[];
}
export const ToolBar: React.FC<ToolBarProps> = ({
@ -21,8 +22,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({
onOpenConfig,
getDirtyChanges,
onSaveSuccess,
items,
}) => {
const [saving, setSaving] = useState(false);
const [matching, setMatching] = useState(false);
const [matchResults, setMatchResults] = useState<MatchMaterialResultDto[]>([]);
const [matchModalVisible, setMatchModalVisible] = useState(false);
const [matchSaving, setMatchSaving] = useState(false);
const handleSave = async () => {
if (dirtyCount === 0) return;
@ -47,29 +53,153 @@ export const ToolBar: React.FC<ToolBarProps> = ({
window.open(getExportUrl(viewMode === 'flat'), '_blank');
};
const handleMatchMaterial = async () => {
const emptyItems = items.filter(
(item) => !item.IsAssembly && !item.Props?.['物料编码']
);
if (emptyItems.length === 0) {
message.info('所有零件的物料编码均已填写');
return;
}
setMatching(true);
try {
const request: MatchMaterialRequest = {
Items: emptyItems.map((item) => ({
DrawingNo: item.DrawingNo,
DocPath: item.DocPath,
})),
};
const res = await apiPost<MatchMaterialResponse>('/api/bom/match-material', request);
if (!res.Success) {
message.error(`匹配失败: ${res.Error}`);
return;
}
const matched = res.Results.filter((r) => r.Matched);
if (matched.length === 0) {
message.info('未找到匹配的物料编码');
return;
}
setMatchResults(matched);
setMatchModalVisible(true);
} catch (e: any) {
message.error(`匹配失败: ${e.message}`);
} finally {
setMatching(false);
}
};
const handleConfirmMatch = async () => {
setMatchSaving(true);
try {
const changes: SaveChangeDto[] = matchResults.map((r) => ({
DocPath: r.DocPath,
Key: '物料编码',
Value: r.PartCode,
}));
const res = await apiPost<any>('/api/bom/save', { Changes: changes });
if (res.Success) {
message.success(`写入成功,共更新 ${res.Results?.length ?? changes.length} 项物料编码`);
onSaveSuccess(changes);
setMatchModalVisible(false);
setMatchResults([]);
} else {
message.error(`写入失败: ${res.Error}`);
}
} catch (e: any) {
message.error(`写入失败: ${e.message}`);
} finally {
setMatchSaving(false);
}
};
const matchColumns = [
{
title: '图号',
dataIndex: 'DrawingNo',
key: 'DrawingNo',
width: 260,
},
{
title: '物料编码',
dataIndex: 'PartCode',
key: 'PartCode',
width: 180,
},
{
title: 'PLM零件名称',
dataIndex: 'PartName',
key: 'PartName',
width: 180,
},
{
title: '状态',
key: 'status',
width: 80,
render: (_: unknown, record: MatchMaterialResultDto) =>
record.Matched ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
},
];
return (
<Space style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<Space>
<Badge count={dirtyCount}>
<Button type="primary" onClick={handleSave} disabled={dirtyCount === 0} loading={saving}>
<>
<Space style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<Space>
<Badge count={dirtyCount}>
<Button type="primary" onClick={handleSave} disabled={dirtyCount === 0} loading={saving}>
</Button>
</Badge>
<Button onClick={handleExport}></Button>
<Button onClick={onRefresh}></Button>
<Button onClick={onOpenConfig}></Button>
<Button onClick={handleMatchMaterial} loading={matching}>
</Button>
</Badge>
<Button onClick={handleExport}></Button>
<Button onClick={onRefresh}></Button>
<Button onClick={onOpenConfig}></Button>
</Space>
<Space>
<Segmented
options={[
{ label: '层级视图', value: 'hierarchical' },
{ label: '扁平视图', value: 'flat' },
]}
value={viewMode}
onChange={(val) => onViewModeChange(val as 'hierarchical' | 'flat')}
/>
<div style={{ marginLeft: 16, color: '#52c41a' }}> </div>
</Space>
</Space>
<Space>
<Segmented
options={[
{ label: '层级视图', value: 'hierarchical' },
{ label: '扁平视图', value: 'flat' },
]}
value={viewMode}
onChange={(val) => onViewModeChange(val as 'hierarchical' | 'flat')}
<Modal
title={`匹配物料编号 (${matchResults.length} 项匹配)`}
open={matchModalVisible}
onOk={handleConfirmMatch}
onCancel={() => {
setMatchModalVisible(false);
setMatchResults([]);
}}
okText="确认写入"
cancelText="取消"
confirmLoading={matchSaving}
width={760}
>
<p style={{ marginBottom: 12, color: '#666' }}>
SolidWorks文件属性
</p>
<Table
dataSource={matchResults}
columns={matchColumns}
rowKey="DocPath"
size="small"
pagination={false}
scroll={{ y: 400 }}
/>
<div style={{ marginLeft: 16, color: '#52c41a' }}> </div>
</Space>
</Space>
</Modal>
</>
);
};
};