using HandyControl.Controls; using HandyControl.Data; using Prism.Commands; using System.Collections.ObjectModel; using System.IO; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using System.Windows; using System.Windows.Threading; using YY.Admin.Core.Entity; using YY.Admin.Core.Services; using YY.Admin.Infrastructure.Print; using YY.Admin.Services.Service; using YY.Admin.Services.Service.Print; using YY.Admin.Views.RawMaterialEntry; namespace YY.Admin.ViewModels.RawMaterialEntry; /// /// 「新增原料入场记录」独立页面:左侧表单逻辑继承编辑 VM,右侧展示当日入场简要列表并支持选中回填模板。 /// public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel { private const int TodayListFetchSize = 5000; private const string RawMaterialEntryBizCode = "1900000000000000530"; private const string RawMaterialEntryTemplateCode = "MES_RAW_MATERIAL_ENTRY"; private const string RawMaterialCardTemplateCode = "MES_RAW_MATERIAL_CARD"; private readonly IRawMaterialCardService _rawMaterialCardService; private readonly IPrintDotService _printDotService; private readonly IPrintBizTemplateBindService _printBizTemplateBindService; private readonly IPrintTemplateService _printTemplateService; private static readonly JsonSerializerOptions LayoutJsonOpts = new() { PropertyNameCaseInsensitive = true, WriteIndented = true }; private static string LayoutFilePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YY.Admin", "raw-material-entry-add-layout.json"); private bool _suppressTodaySelectionReaction; public ObservableCollection TodayEntries { get; } = new(); public IReadOnlyList DateRangeOptions { get; } = ["今日", "过去24小时", "过去48小时", "过去72小时"]; private string _selectedDateRange = "今日"; public string SelectedDateRange { get => _selectedDateRange; set { if (SetProperty(ref _selectedDateRange, value)) _ = LoadTodayEntriesAsync(); } } private MesXslRawMaterialEntry? _selectedTodayEntry; public MesXslRawMaterialEntry? SelectedTodayEntry { get => _selectedTodayEntry; set { if (!SetProperty(ref _selectedTodayEntry, value)) return; if (_suppressTodaySelectionReaction || value == null) return; _ = ApplyTodayRowToFormAsync(value); } } private bool _isRightPanelExpanded = true; public bool IsRightPanelExpanded { get => _isRightPanelExpanded; set { if (!SetProperty(ref _isRightPanelExpanded, value)) return; SaveLayoutState(); } } private double _expandedRightPanelWidth = 280; /// 右侧面板展开时的目标宽度(像素),由 GridSplitter 拖拽结束时回写。 public double ExpandedRightPanelWidth { get => _expandedRightPanelWidth; private set { var v = Math.Clamp(value, 200, 560); if (SetProperty(ref _expandedRightPanelWidth, v)) SaveLayoutState(); } } /// /// 「生成原材料卡片」按钮可用条件: /// 入场记录已保存(有 Id)且至少存在一行「未生成卡片 + 份数>0」的明细。 /// 已打印态下用户「继续拆码」新增了行,按钮自动重新可用;全部行都已生成卡片时不可用。 /// public bool CanGenerateCards => !string.IsNullOrWhiteSpace(Entry?.Id) && SplitCodeDetails.Any(d => !d.HasCard && (d.Portions ?? 0) > 0); public DelegateCommand ToggleRightPanelCommand { get; } public DelegateCommand RefreshTodayEntriesCommand { get; } public DelegateCommand GenerateRawMaterialCardsCommand { get; } /// 「重新拆码」:清除已生成的卡片 + 清空明细,仅在编辑态可用。 public DelegateCommand ResplitCommand { get; } public DelegateCommand SaveAndPrintCommand { get; } public DelegateCommand RefreshPrintersCommand { get; } public DelegateCommand ZoomOutPrintPreviewCommand { get; } public DelegateCommand ZoomInPrintPreviewCommand { get; } public DelegateCommand ResetPrintPreviewZoomCommand { get; } /// PrintDot 桥接器返回的打印机列表(与打印模板页一致)。 public ObservableCollection Printers { get; } = new(); private bool _suppressPrinterSave; private PrintDotPrinter? _selectedPrinter; public PrintDotPrinter? SelectedPrinter { get => _selectedPrinter; set { if (!SetProperty(ref _selectedPrinter, value)) return; if (_suppressPrinterSave) return; var s = PrintDotSettings.Load(); s.SelectedPrinter = value?.Name ?? string.Empty; s.Save(); } } private string _printerStatus = string.Empty; public string PrinterStatus { get => _printerStatus; set => SetProperty(ref _printerStatus, value); } private static readonly JsonSerializerOptions PreviewSnapshotJsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly DispatcherTimer _printPreviewTimer; private string _lastPreviewSnapshot = string.Empty; private int _previewRequestGeneration; private bool _printPreviewBusy; private bool _previewTemplateLoaded; private string _previewTemplateJson = string.Empty; private string _previewTemplateName = "原料入场记录"; private string _previewTemplateCode = RawMaterialEntryTemplateCode; private string _previewFieldMappingJson = "[]"; private bool _rawMaterialCardTemplateLoaded; private string _rawMaterialCardTemplateJson = string.Empty; private string _rawMaterialCardTemplateName = "原材料卡片"; private string _rawMaterialCardTemplateCode = RawMaterialCardTemplateCode; private string _rawMaterialCardFieldMappingJson = "[]"; private bool _isPrintPreviewExpanded = true; /// 右侧下方「入场标签打印预览」折叠面板是否展开。 public bool IsPrintPreviewExpanded { get => _isPrintPreviewExpanded; set { if (!SetProperty(ref _isPrintPreviewExpanded, value)) return; if (value) _lastPreviewSnapshot = string.Empty; RaisePropertyChanged(nameof(IsPrintPreviewWebAreaVisible)); } } /// 右侧下方预览区是否显示 WebView2 宿主(HandyControl 内嵌弹窗期间因 Airspace 临时隐藏)。 public bool IsPrintPreviewWebAreaVisible => !SuspendEmbeddedPrintPreviewAirspace && IsPrintPreviewExpanded; protected override void OnSuspendEmbeddedPrintPreviewAirspaceChanged() { RaisePropertyChanged(nameof(IsPrintPreviewWebAreaVisible)); } private string _printPreviewStatus = string.Empty; /// 预览区状态提示(如离线、加载中、错误摘要)。 public string PrintPreviewStatus { get => _printPreviewStatus; private set => SetProperty(ref _printPreviewStatus, value); } private const double PrintPreviewMinZoom = 0.5d; private const double PrintPreviewMaxZoom = 2.0d; private const double PrintPreviewDefaultZoom = 0.7d; private const double PrintPreviewZoomStep = 0.1d; private double _printPreviewZoomFactor = PrintPreviewDefaultZoom; /// 打印预览缩放倍率(WebView2 ZoomFactor)。 public double PrintPreviewZoomFactor { get => _printPreviewZoomFactor; set { var clamped = Math.Round(Math.Clamp(value, PrintPreviewMinZoom, PrintPreviewMaxZoom), 2); if (!SetProperty(ref _printPreviewZoomFactor, clamped)) return; RaisePropertyChanged(nameof(PrintPreviewZoomText)); } } /// 打印预览缩放显示文本(百分比)。 public string PrintPreviewZoomText => $"{Math.Round(PrintPreviewZoomFactor * 100):0}%"; /// 由 View 订阅,在 UI 线程将 HTML 交给 WebView2。 public event EventHandler? PrintPreviewHtmlReady; private bool _isActionBusy; /// 底部动作按钮(保存/保存并打印/生成卡片)是否处于执行中。 public bool IsActionBusy { get => _isActionBusy; private set { if (SetProperty(ref _isActionBusy, value)) { RaisePropertyChanged(nameof(IsNotActionBusy)); } } } public bool IsNotActionBusy => !IsActionBusy; private string _actionBusyText = "处理中..."; /// 动作执行中的遮罩提示文案。 public string ActionBusyText { get => _actionBusyText; private set => SetProperty(ref _actionBusyText, value); } public RawMaterialEntryOperationViewModel( IRawMaterialEntryService entryService, IJeecgDictSyncService dictSyncService, IMixerMaterialService mixerMaterialService, IMixerMaterialTareStrategyService tareStrategyService, ISupplierService supplierService, IRawMaterialCardService rawMaterialCardService, IPrintDotService printDotService, IPrintBizTemplateBindService printBizTemplateBindService, IPrintTemplateService printTemplateService, IContainerExtension container, IRegionManager regionManager) : base(entryService, dictSyncService, mixerMaterialService, tareStrategyService, supplierService, container, regionManager) { _rawMaterialCardService = rawMaterialCardService; _printDotService = printDotService; _printBizTemplateBindService = printBizTemplateBindService; _printTemplateService = printTemplateService; LoadLayoutState(); ToggleRightPanelCommand = new DelegateCommand(() => IsRightPanelExpanded = !IsRightPanelExpanded); RefreshTodayEntriesCommand = new DelegateCommand(async () => await LoadTodayEntriesAsync()); GenerateRawMaterialCardsCommand = new DelegateCommand(async () => await GenerateRawMaterialCardsAsync()); ResplitCommand = new DelegateCommand(async () => await ResplitAsync(), () => CanResplit) .ObservesProperty(() => Entry); SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync()); RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true)); ZoomOutPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor -= PrintPreviewZoomStep); ZoomInPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor += PrintPreviewZoomStep); ResetPrintPreviewZoomCommand = new DelegateCommand(() => PrintPreviewZoomFactor = PrintPreviewDefaultZoom); // 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions,并同步刷新两个 Can*。 SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags; _ = RefreshPrintersAsync(verbose: false); var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; _printPreviewTimer = new DispatcherTimer( TimeSpan.FromMilliseconds(480), DispatcherPriority.Background, (_, _) => _ = OnPrintPreviewTickAsync(), dispatcher); _printPreviewTimer.Start(); } /// /// 「重新拆码」按钮可用条件:入场记录已保存 + 至少有一行已生成卡片。 /// 全是新增未生成的行时,用户直接点「删除」即可,无需走「清空卡片」流程。 /// public bool CanResplit => !string.IsNullOrWhiteSpace(Entry?.Id) && SplitCodeDetails.Any(d => d.HasCard); /// /// 集合变化时:对新增/移除的 item 维护 PropertyChanged 订阅,并刷新两个 Can* 属性。 /// HasCard / Portions 变化会让「生成原材料卡片」和「重新拆码」按钮可用性同步刷新。 /// private void OnSplitCodeDetailsCollectionChangedForCanFlags( object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) { foreach (RawMaterialSplitDetailItem o in e.OldItems) o.PropertyChanged -= OnSplitDetailHasCardChanged; } if (e.NewItems != null) { foreach (RawMaterialSplitDetailItem n in e.NewItems) n.PropertyChanged += OnSplitDetailHasCardChanged; } RaisePropertyChanged(nameof(CanGenerateCards)); RaisePropertyChanged(nameof(CanResplit)); ResplitCommand.RaiseCanExecuteChanged(); } private void OnSplitDetailHasCardChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName is nameof(RawMaterialSplitDetailItem.HasCard) or nameof(RawMaterialSplitDetailItem.Portions)) { RaisePropertyChanged(nameof(CanGenerateCards)); RaisePropertyChanged(nameof(CanResplit)); ResplitCommand.RaiseCanExecuteChanged(); } } public override void InitializeForAdd() { _lastPreviewSnapshot = string.Empty; _suppressTodaySelectionReaction = true; _selectedTodayEntry = null; RaisePropertyChanged(nameof(SelectedTodayEntry)); _suppressTodaySelectionReaction = false; base.InitializeForAdd(); RaisePropertyChanged(nameof(CanGenerateCards)); } /// 保存后按业务打印绑定拉取模板与 printData,直接发送到 PrintDot 打印机(不弹预览窗)。 private async Task SaveAndPrintAsync() { if (IsActionBusy) return; if (Entry == null) return; var wasEdit = !IsAddMode; var barcode = Entry.Barcode; BeginActionBusy("保存并打印中..."); try { IsLoading = true; if (!await PersistEntryCoreAsync()) return; string? entryId = Entry.Id; if (string.IsNullOrWhiteSpace(entryId) && !string.IsNullOrWhiteSpace(barcode)) { var page = await EntryService.PageAsync(1, 50, barcode: barcode); entryId = page.Records .FirstOrDefault(e => string.Equals(e.Barcode, barcode, StringComparison.OrdinalIgnoreCase))?.Id ?? page.Records .OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue) .FirstOrDefault()?.Id; } if (string.IsNullOrWhiteSpace(entryId)) { HandyControl.Controls.MessageBox.Warning("保存成功,但未能解析记录主键,无法打印。请从右侧列表选中该条后再试。"); } else { var (templateJson, printDataJson, err) = await EntryService.PrepareNativePrintAsync(entryId); if (!string.IsNullOrWhiteSpace(err)) { HandyControl.Controls.MessageBox.Error($"保存成功,但打印准备失败:{err}"); } else { var selectedPrinterName = SelectedPrinter?.Name?.Trim(); if (string.IsNullOrWhiteSpace(selectedPrinterName)) { HandyControl.Controls.MessageBox.Warning("保存成功,但未选择打印机。请先选择打印机后再试。"); } else { JsonObject? dataObj = JsonNode.Parse(printDataJson) as JsonObject; dataObj ??= new JsonObject(); var html = NativePrintRenderService.RenderToHtml(templateJson, dataObj); var tpl = BuildPrintTemplateForEntry(templateJson); var pdfBase64 = await HtmlToPdfRenderer.RenderAsync( html, tpl.PaperWidthMm ?? 210d, tpl.PaperHeightMm ?? 297d); var jobName = string.IsNullOrWhiteSpace(barcode) ? "原料入场记录" : $"原料入场记录-{barcode}"; await _printDotService.PrintAsync(selectedPrinterName, pdfBase64, jobName, 1); } } } Growl.Success(new GrowlInfo { Message = "保存成功", ShowDateTime = false, WaitTime = 1 }); Result = true; RaisePropertyChanged(nameof(CanGenerateCards)); await LoadTodayEntriesAsync(); InitializeForAdd(); } catch (Exception ex) { HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}"); } finally { IsLoading = false; EndActionBusy(); } } /// 通过 PrintDot 桥接器拉取打印机列表(与打印模板页一致)。 private async Task RefreshPrintersAsync(bool verbose) { if (verbose) PrinterStatus = "刷新打印机中..."; var savedName = PrintDotSettings.Load().SelectedPrinter; try { var list = await _printDotService.GetPrintersAsync(); Application.Current.Dispatcher.Invoke(() => { Printers.Clear(); foreach (var p in list) Printers.Add(p); var match = list.FirstOrDefault(p => p.Name == savedName) ?? list.FirstOrDefault(p => p.IsDefault) ?? (list.Count > 0 ? list[0] : null); _suppressPrinterSave = true; SelectedPrinter = match; _suppressPrinterSave = false; PrinterStatus = list.Count > 0 ? $"共 {list.Count} 台打印机" : "未检测到打印机"; }); } catch (Exception ex) { Application.Current.Dispatcher.Invoke(() => { Printers.Clear(); _suppressPrinterSave = true; SelectedPrinter = null; _suppressPrinterSave = false; PrinterStatus = verbose ? $"PrintDot 未连接:{ex.Message}" : "PrintDot 未连接"; }); } } private static PrintTemplate BuildPrintTemplateForEntry(string? templateJson) { try { if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}") return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" }; var root = JsonDocument.Parse(templateJson).RootElement; double w = 210, h = 297; if (root.TryGetProperty("page", out var page)) { if (page.TryGetProperty("width", out var wEl)) w = wEl.GetDouble(); if (page.TryGetProperty("height", out var hEl)) h = hEl.GetDouble(); } return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY", PaperWidthMm = w, PaperHeightMm = h, PaperOrientation = w > h ? "横向" : "纵向", }; } catch { return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" }; } } /// 页面首次加载时拉取「今日」列表(由 View Loaded 调用)。 public async Task LoadTodayEntriesOnFirstShowAsync() => await LoadTodayEntriesAsync(); /// 用户在拖拽结束 GridSplitter 后提交实际列宽。 public void CommitRightPanelWidthFromView(double actualWidth) { if (!IsRightPanelExpanded || actualWidth < 1) return; ExpandedRightPanelWidth = actualWidth; } private async Task LoadTodayEntriesAsync() { try { IsLoading = true; var result = await EntryService.PageAsync(1, TodayListFetchSize); DateTime? threshold = _selectedDateRange switch { "过去24小时" => DateTime.Now.AddHours(-24), "过去48小时" => DateTime.Now.AddHours(-48), "过去72小时" => DateTime.Now.AddHours(-72), _ => null // "今日":按自然日过滤 }; var rows = result.Records .Where(e => IsInRange(e, threshold)) .OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue) .ToList(); TodayEntries.Clear(); foreach (var r in rows) TodayEntries.Add(r); } catch { // 列表失败不阻断左侧新增(与物料加载策略一致) } finally { IsLoading = false; } } private static bool IsInRange(MesXslRawMaterialEntry e, DateTime? threshold) { if (threshold == null) { var today = DateTime.Today; return (e.EntryTime?.Date == today) || (e.CreateTime?.Date == today); } return (e.EntryTime >= threshold) || (e.CreateTime >= threshold); } /// /// 点击右侧今日记录:直接以「编辑模式」加载源记录到左侧表单(保留 Id/条码/批次号), /// 不再走「新增模板」逻辑,避免重新生成条码、覆盖原数据。 /// private async Task ApplyTodayRowToFormAsync(MesXslRawMaterialEntry src) { // 复用基类 InitializeForEdit:保留 Id/Barcode/BatchNo/状态等所有字段,标题自动切到「编辑原料入场记录」 base.InitializeForEdit(src); RaisePropertyChanged(nameof(CanGenerateCards)); // 若物料列表此前未加载导致选中项未回填,则补一次拉取(与编辑弹窗逻辑一致) if (_pendingMaterialId != null) await LoadMaterialOptionsAsync(); _lastPreviewSnapshot = string.Empty; } /// /// 保存成功后:无论新增还是编辑,都立即刷新右侧「今日入场」列表。 /// 编辑成功额外把表单切回「新增态」,避免用户连续误改同一条;新增成功后由基类负责清空表单。 /// protected override async Task SaveAsync() { if (IsActionBusy) return; BeginActionBusy("保存中..."); var wasEdit = !IsAddMode; try { await base.SaveAsync(); RaisePropertyChanged(nameof(CanGenerateCards)); if (!Result || CloseAction != null) { return; } await LoadTodayEntriesAsync(); if (wasEdit) { Growl.Success(new GrowlInfo { Message = "保存成功", ShowDateTime = false, WaitTime = 1 }); InitializeForAdd(); } } finally { EndActionBusy(); } } /// /// 「重新拆码」:弹窗预提示「将清除 N 张原材料卡片」→ 确认 → 后端按 splitDetailId IN 批删 /// → 清空 SplitCodeDetails → Entry.PrintFlag=0 → EditAsync(Entry) 持久化新状态 → 刷新今日列表。 /// 流程对应「清空卡片重新生成」的业务诉求;用户可立即重新维护明细并再次「生成原材料卡片」。 /// private async Task ResplitAsync() { if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id)) { HandyControl.Controls.MessageBox.Warning("请先选中一条已保存的入场记录后再「重新拆码」。"); return; } var detailIds = SplitCodeDetails .Select(d => d.Id) .Where(id => !string.IsNullOrWhiteSpace(id)) .Distinct() .ToList(); try { IsLoading = true; // dryRun 预查:统计当前明细行已生成的卡片数量 var cardCount = detailIds.Count > 0 ? await _rawMaterialCardService.DeleteBySplitDetailIdsAsync(detailIds, dryRun: true) : 0; if (cardCount < 0) { HandyControl.Controls.MessageBox.Error("无法连接服务器,重新拆码已取消。"); return; } var confirm = HandyControl.Controls.MessageBox.Show( $"当前入场记录已生成 {cardCount} 条原材料卡片,是否清空卡片重新生成?", "重新拆码", System.Windows.MessageBoxButton.OKCancel, System.Windows.MessageBoxImage.Question); if (confirm != System.Windows.MessageBoxResult.OK) { return; } // 真正执行批删(即使 cardCount=0 也调用一次,结果幂等) if (cardCount > 0) { var deleted = await _rawMaterialCardService.DeleteBySplitDetailIdsAsync(detailIds, dryRun: false); if (deleted < 0) { HandyControl.Controls.MessageBox.Error("清除已生成的原材料卡片失败,重新拆码已中止。"); return; } } // 清空明细 + 重置打印标记,并把变更持久化到后端入场记录 SplitCodeDetails.Clear(); Entry.PrintFlag = "0"; ApplySplitDetailsToEntry(); // 同步把 PortionDetailIds 等清空到 Entry 上 var ok = await EntryService.EditAsync(Entry); if (!ok) { HandyControl.Controls.MessageBox.Error("保存重新拆码后的入场记录失败,请刷新后重试。"); return; } RaisePropertyChanged(nameof(Entry)); RaisePropertyChanged(nameof(IsPrinted)); RaisePropertyChanged(nameof(IsTotalWeightEditable)); AddSplitDetailCommand.RaiseCanExecuteChanged(); RaisePropertyChanged(nameof(CanGenerateCards)); RaisePropertyChanged(nameof(CanResplit)); HandyControl.Controls.MessageBox.Success($"已清除 {cardCount} 条原材料卡片,请重新维护拆码明细。"); await LoadTodayEntriesAsync(); } catch (Exception ex) { HandyControl.Controls.MessageBox.Error($"重新拆码失败:{ex.Message}"); } finally { IsLoading = false; } } /// /// 「生成原材料卡片」:仅处理 HasCard==false 的明细行(「继续拆码」流程的核心)。 /// 条码续号 = 已有卡片总数(Σ HasCard==true 行的 Portions)+ 1,避免与已生成卡片的条码冲突。 /// 成功生成的行置 HasCard=true,Entry.PrintFlag=1(整票级标记)。 /// private async Task GenerateRawMaterialCardsAsync() { if (IsActionBusy) return; if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id)) { HandyControl.Controls.MessageBox.Warning("请先保存入场记录再生成原材料卡片!"); return; } // 只处理未生成卡片的行 + 份数>0;已生成行直接跳过,避免重复加卡和条码冲突 var pendingRows = SplitCodeDetails .Where(d => !d.HasCard && (d.Portions ?? 0) > 0) .ToList(); if (pendingRows.Count == 0) { HandyControl.Controls.MessageBox.Warning("当前没有待生成卡片的拆码明细(已生成的行不会重复处理)。请先「新增明细」再生成。"); return; } if (!await EnsureEntryBarcodeAndBatchNoAsync()) { return; } // 库位必填仅校验本批待生成行;行号取在原集合的真实索引,避免编号偏移误导 foreach (var row in pendingRows) { if (string.IsNullOrWhiteSpace(row.WarehouseLocation)) { var realIndex = SplitCodeDetails.IndexOf(row); HandyControl.Controls.MessageBox.Warning($"拆码明细第 {realIndex + 1} 行的「库位」未填写,请点击该行库位选择库区后再生成原材料卡片。"); return; } } // 总重核对:拆码明细合计 vs 基础资料总重 { var splitTotal = SplitCodeDetails.Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0)); var basicTotal = Entry.TotalWeight ?? 0d; if (Math.Abs(splitTotal - basicTotal) > 0.01d) { string hint; if (splitTotal > basicTotal) hint = $"本次打印拆码总重 {splitTotal:0.##},超出基础资料总重 {basicTotal:0.##},超出部分是否需要核对?"; else hint = $"本次打印拆码总重 {splitTotal:0.##},少于基础资料总重 {basicTotal:0.##},剩余部分将无法继续拆码,是否继续?"; var confirm = System.Windows.MessageBox.Show( hint, "总重核对", System.Windows.MessageBoxButton.OKCancel, System.Windows.MessageBoxImage.Warning); if (confirm != System.Windows.MessageBoxResult.OK) return; } } // 续号起点:已有卡片数 = Σ HasCard==true 行的 Portions;新增卡片从其后续编 var alreadyGenerated = SplitCodeDetails .Where(d => d.HasCard) .Sum(d => d.Portions ?? 0); var plannedCards = BuildPlannedRawMaterialCards(Entry, pendingRows, alreadyGenerated + 1); if (plannedCards.Count == 0) { HandyControl.Controls.MessageBox.Warning("未生成有效的原材料卡片预览数据,请检查拆码明细后重试。"); return; } if (!await EnsureRawMaterialCardTemplateLoadedAsync()) { HandyControl.Controls.MessageBox.Warning("未找到原材料卡片打印模板,请先同步“业务打印绑定/打印模板”后再试。"); return; } var confirmWindow = new RawMaterialCardGenerateConfirmWindow( plannedCards, _rawMaterialCardTemplateName, _rawMaterialCardTemplateCode, _printDotService, row => { var matched = plannedCards.FirstOrDefault(p => string.Equals(p.DetailId, row.DetailId, StringComparison.OrdinalIgnoreCase) && string.Equals(p.Card.Barcode, row.Card.Barcode, StringComparison.OrdinalIgnoreCase)); var target = matched ?? plannedCards[Math.Clamp(row.Index - 1, 0, plannedCards.Count - 1)]; return BuildRawMaterialCardPreviewHtml(target); }) { Owner = Application.Current.MainWindow, WindowStartupLocation = WindowStartupLocation.CenterOwner }; // WebView2 Airspace:模态子窗体仍可能与本窗体内 HWND 叠层异常,弹窗期间收起嵌入预览 var confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() => Task.FromResult(confirmWindow.ShowDialog() == true)); if (!confirmed) return; var selectedPrinterName = confirmWindow.SelectedPrinterName; if (string.IsNullOrWhiteSpace(selectedPrinterName)) { HandyControl.Controls.MessageBox.Warning("未选择打印机,已取消打印。"); return; } BeginActionBusy("生成中..."); try { IsLoading = true; var globalIndex = alreadyGenerated + 1; var baseBarcode = Entry.Barcode ?? ""; var failCount = 0; var newCardCount = 0; var generatedCards = new List(); // 按行收集成功标记:只要该行所有份都加卡成功,行 HasCard=true; // 中途任一份失败则保留 HasCard=false,下次再点「生成」时会重试该行 var rowSuccessMap = new Dictionary(); foreach (var detail in pendingRows) { var portions = detail.Portions ?? 0; var rowAllOk = true; for (var i = 0; i < portions; i++) { var card = new MesXslRawMaterialCard { // 关联到当前拆码明细行 — 便于「重新拆码」按此 ID 批删 SplitDetailId = detail.Id, Barcode = baseBarcode + globalIndex.ToString("D3"), BatchNo = Entry.BatchNo, EntryDate = Entry.EntryTime?.Date ?? DateTime.Today, MaterialId = Entry.MaterialId, MaterialName = Entry.MaterialName, SupplierId = Entry.SupplierId, SupplierName = Entry.SupplierName, ManufacturerMaterialName = Entry.ManufacturerMaterialName, ShelfLife = Entry.ShelfLife, TotalWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null, PackagingTare = detail.PackagingTare.HasValue ? (decimal?)detail.PackagingTare.Value : null, PalletWeight = detail.PalletWeight.HasValue ? (decimal?)detail.PalletWeight.Value : null, RemainingWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null, RemainingQuantity = detail.PortionPackages, WarehouseArea = detail.WarehouseLocation, UnloadOperator = Entry.UnloadOperator, Status = "1", TestResult = "0", PriorityPickup = "0", TenantId = Entry.TenantId }; var ok = await _rawMaterialCardService.AddAsync(card); if (ok) { newCardCount++; generatedCards.Add(card); } else { failCount++; rowAllOk = false; } globalIndex++; } rowSuccessMap[detail] = rowAllOk; } // 把整行加卡都成功的标记 HasCard=true(触发该行 UI 自动锁定 + 删除按钮隐藏 + 打印列显示「已打印」) foreach (var kv in rowSuccessMap) { if (kv.Value) kv.Key.HasCard = true; } // 整票级 PrintFlag:只要本批至少有一张卡片成功即置 1(与原行为兼容)。 // 关键:必须先 ApplySplitDetailsToEntry(),把刚被设为 true 的 HasCard 序列化到 PortionCardFlags, // 否则下次重新打开时该行会回退为 false(PortionCardFlags 旧值未更新),从而被「继续拆码」流程误纳入待生成。 if (newCardCount > 0) { Entry.PrintFlag = "1"; ApplySplitDetailsToEntry(); await EntryService.EditAsync(Entry); RaisePropertyChanged(nameof(Entry)); RaisePropertyChanged(nameof(IsPrinted)); RaisePropertyChanged(nameof(IsTotalWeightEditable)); AddSplitDetailCommand.RaiseCanExecuteChanged(); } RaisePropertyChanged(nameof(CanGenerateCards)); RaisePropertyChanged(nameof(CanResplit)); string? printError = null; if (newCardCount > 0 && generatedCards.Count > 0) { printError = await PrintGeneratedRawMaterialCardsAsync(selectedPrinterName, generatedCards); if (!string.IsNullOrWhiteSpace(printError)) { HandyControl.Controls.MessageBox.Warning($"卡片已生成 {newCardCount} 张,但打印存在异常:{printError}"); } } if (failCount == 0) { if (string.IsNullOrWhiteSpace(printError)) HandyControl.Controls.MessageBox.Success($"已生成并打印 {newCardCount} 张原材料卡片(累计 {alreadyGenerated + newCardCount} 张),打印状态已更新为「已打印」!"); else HandyControl.Controls.MessageBox.Success($"已生成 {newCardCount} 张原材料卡片(累计 {alreadyGenerated + newCardCount} 张),打印状态已更新;请检查打印机后补打标签。"); } else HandyControl.Controls.MessageBox.Warning($"本次共尝试生成 {newCardCount + failCount} 张,成功 {newCardCount} 张,失败 {failCount} 张。失败的行未标记为「已打印」,可检查网络后再次点击「生成原材料卡片」重试。"); } catch (Exception ex) { HandyControl.Controls.MessageBox.Error($"生成原材料卡片失败:{ex.Message}"); } finally { IsLoading = false; EndActionBusy(); } } /// /// 生成原材料卡片前确保入场记录已有条码/批次号。 /// 规则:条码优先沿用现有值;为空时调用入场服务生成(在线优先,离线本地兜底); /// 批次号为空则回填为条码。 /// private async Task EnsureEntryBarcodeAndBatchNoAsync() { if (Entry == null) return false; if (string.IsNullOrWhiteSpace(Entry.Barcode)) { if (string.IsNullOrWhiteSpace(Entry.MaterialCode)) { HandyControl.Controls.MessageBox.Warning("当前记录缺少物料编码,无法生成原材料卡片条码。"); return false; } var code = await EntryService.GenerateBarcodeAsync(Entry.MaterialCode); if (string.IsNullOrWhiteSpace(code)) { HandyControl.Controls.MessageBox.Warning("未能生成入场条码,请检查网络或稍后重试。"); return false; } Entry.Barcode = code.Trim(); } if (string.IsNullOrWhiteSpace(Entry.BatchNo)) { Entry.BatchNo = Entry.Barcode; } RaisePropertyChanged(nameof(Entry)); return true; } private List BuildPlannedRawMaterialCards( MesXslRawMaterialEntry entry, IReadOnlyList pendingRows, int startIndex) { var list = new List(); var cursor = startIndex; var baseBarcode = entry.Barcode ?? string.Empty; foreach (var detail in pendingRows) { var portions = detail.Portions ?? 0; for (var i = 0; i < portions; i++) { var card = BuildRawMaterialCardFromDetail(entry, detail, baseBarcode + cursor.ToString("D3")); list.Add(new RawMaterialCardGeneratePlanItem { Card = card, DetailId = detail.Id, SourceRowNo = SplitCodeDetails.IndexOf(detail) + 1 }); cursor++; } } return list; } private static MesXslRawMaterialCard BuildRawMaterialCardFromDetail( MesXslRawMaterialEntry entry, RawMaterialSplitDetailItem detail, string barcode) { return new MesXslRawMaterialCard { SplitDetailId = detail.Id, Barcode = barcode, BatchNo = entry.BatchNo, EntryDate = entry.EntryTime?.Date ?? DateTime.Today, MaterialId = entry.MaterialId, MaterialName = entry.MaterialName, SupplierId = entry.SupplierId, SupplierName = entry.SupplierName, ManufacturerMaterialName = entry.ManufacturerMaterialName, ShelfLife = entry.ShelfLife, TotalWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null, PackagingTare = detail.PackagingTare.HasValue ? (decimal?)detail.PackagingTare.Value : null, PalletWeight = detail.PalletWeight.HasValue ? (decimal?)detail.PalletWeight.Value : null, RemainingWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null, RemainingQuantity = detail.PortionPackages, WarehouseArea = detail.WarehouseLocation, UnloadOperator = entry.UnloadOperator, Status = "1", TestResult = "0", PriorityPickup = "0", TenantId = entry.TenantId }; } private async Task EnsureRawMaterialCardTemplateLoadedAsync() { if (_rawMaterialCardTemplateLoaded && !string.IsNullOrWhiteSpace(_rawMaterialCardTemplateJson)) { return true; } var bindList = _printBizTemplateBindService.GetCached(); if (bindList.Count == 0) { try { bindList = await _printBizTemplateBindService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } } var bind = bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialCardTemplateCode, StringComparison.OrdinalIgnoreCase)) ?? bindList.FirstOrDefault(x => (x.BizName ?? string.Empty).Contains("原材料卡片", StringComparison.OrdinalIgnoreCase)); if (bind == null) return false; _rawMaterialCardFieldMappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!; _rawMaterialCardTemplateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialCardTemplateCode : bind.TemplateCode!; var templates = _printTemplateService.GetCached(); if (templates.Count == 0) { try { templates = await _printTemplateService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } } var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase)) ?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, _rawMaterialCardTemplateCode, StringComparison.OrdinalIgnoreCase)); if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson)) return false; _rawMaterialCardTemplateJson = tpl.TemplateJson!; _rawMaterialCardTemplateName = string.IsNullOrWhiteSpace(tpl.TemplateName) ? "原材料卡片" : tpl.TemplateName!; _rawMaterialCardTemplateLoaded = true; return true; } private string BuildRawMaterialCardPreviewHtml(RawMaterialCardGeneratePlanItem planItem) { try { var data = BuildRawMaterialCardPrintData(planItem.Card); return NativePrintRenderService.RenderToHtml(_rawMaterialCardTemplateJson, data, enableScreenAutoFit: false); } catch (Exception ex) { return BuildPrintPreviewErrorHtml($"模板预览失败:{ex.Message}"); } } private JsonObject BuildRawMaterialCardPrintData(MesXslRawMaterialCard card) { JsonObject printData = new(); JsonNode? bizRoot = JsonSerializer.SerializeToNode(card, PreviewSnapshotJsonOpts); try { var mappingNode = JsonNode.Parse(string.IsNullOrWhiteSpace(_rawMaterialCardFieldMappingJson) ? "[]" : _rawMaterialCardFieldMappingJson) as JsonArray; if (mappingNode != null) { foreach (var rule in mappingNode) { if (rule is not JsonObject obj) continue; var templateField = obj["templateField"]?.GetValue()?.Trim(); if (string.IsNullOrWhiteSpace(templateField)) continue; var bizField = obj["bizField"]?.GetValue()?.Trim(); JsonNode? val = string.IsNullOrWhiteSpace(bizField) ? JsonValue.Create(string.Empty) : ResolvePath(bizRoot, bizField!); var normalized = NormalizePreviewNodeValue(val); SetPath(printData, templateField!, normalized); } } } catch { // 映射失败时降级为模板字段补空,不中断后续流程 } try { var root = JsonNode.Parse(_rawMaterialCardTemplateJson); var fields = new HashSet(StringComparer.OrdinalIgnoreCase); CollectTemplateBindFields(root, fields); foreach (var key in fields) { if (!HasPath(printData, key)) { SetPath(printData, key, JsonValue.Create(string.Empty)); } } } catch { // 忽略模板结构异常 } return printData; } private async Task PrintGeneratedRawMaterialCardsAsync(string printerName, IReadOnlyList cards) { if (cards.Count == 0) return null; if (string.IsNullOrWhiteSpace(printerName)) return "未选择打印机"; if (!await EnsureRawMaterialCardTemplateLoadedAsync()) return "未找到原材料卡片打印模板"; var tpl = BuildPrintTemplateForRawMaterialCard(_rawMaterialCardTemplateJson); try { var idx = 0; foreach (var card in cards) { idx++; BeginActionBusy($"打印中({idx}/{cards.Count})..."); var data = BuildRawMaterialCardPrintData(card); var html = NativePrintRenderService.RenderToHtml(_rawMaterialCardTemplateJson, data); var pdfBase64 = await HtmlToPdfRenderer.RenderAsync( html, tpl.PaperWidthMm ?? 210d, tpl.PaperHeightMm ?? 297d); var jobName = string.IsNullOrWhiteSpace(card.Barcode) ? "原材料卡片" : $"原材料卡片-{card.Barcode}"; await _printDotService.PrintAsync(printerName, pdfBase64, jobName, 1); } return null; } catch (Exception ex) { return ex.Message; } } private static PrintTemplate BuildPrintTemplateForRawMaterialCard(string? templateJson) { try { if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}") return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = RawMaterialCardTemplateCode }; var root = JsonDocument.Parse(templateJson).RootElement; double w = 210, h = 297; if (root.TryGetProperty("page", out var page)) { if (page.TryGetProperty("width", out var wEl)) w = wEl.GetDouble(); if (page.TryGetProperty("height", out var hEl)) h = hEl.GetDouble(); } return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = RawMaterialCardTemplateCode, PaperWidthMm = w, PaperHeightMm = h, PaperOrientation = w > h ? "横向" : "纵向", }; } catch { return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = RawMaterialCardTemplateCode }; } } private void BeginActionBusy(string text) { ActionBusyText = string.IsNullOrWhiteSpace(text) ? "处理中..." : text; IsActionBusy = true; } private void EndActionBusy() { IsActionBusy = false; ActionBusyText = "处理中..."; } /// 页面卸载时停止轮询,避免后台仍请求已释放的 WebView。 public void StopPrintPreviewTimer() => _printPreviewTimer.Stop(); /// 视图重新附加时恢复轮询(与 配对)。 public void StartPrintPreviewTimer() { if (!_printPreviewTimer.IsEnabled) _printPreviewTimer.Start(); } private async Task OnPrintPreviewTickAsync() { if (!IsPrintPreviewExpanded || Entry == null || _printPreviewBusy) return; try { ApplySplitDetailsToEntry(); var snap = JsonSerializer.Serialize(Entry, PreviewSnapshotJsonOpts); if (string.Equals(snap, _lastPreviewSnapshot, StringComparison.Ordinal)) return; _lastPreviewSnapshot = snap; _printPreviewBusy = true; PrintPreviewStatus = "更新预览中…"; var myGen = ++_previewRequestGeneration; if (!await EnsureLocalPreviewTemplateLoadedAsync().ConfigureAwait(false)) { PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("未找到本地打印模板缓存,请先联网完成一次“业务打印绑定/打印模板”同步。")); PrintPreviewStatus = "未找到本地模板缓存"; return; } if (string.IsNullOrWhiteSpace(_previewTemplateJson) || _previewTemplateJson == "{}") { PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("尚未配置业务打印绑定或模板内容为空")); PrintPreviewStatus = "未配置模板"; return; } var dataObj = BuildLocalPreviewData(Entry); if (myGen != _previewRequestGeneration) return; string html; try { // 实时预览关闭模板内部 fitPage 自适应,避免与 WebView2 外层缩放叠加后出现“越放大越小”。 html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj, enableScreenAutoFit: false); } catch (Exception ex) { html = BuildPrintPreviewErrorHtml(ex.Message); PrintPreviewStatus = "渲染失败"; PostPrintPreviewHtml(html); return; } PostPrintPreviewHtml(html); PrintPreviewStatus = string.Empty; } catch (Exception ex) { PostPrintPreviewHtml(BuildPrintPreviewErrorHtml(ex.Message)); PrintPreviewStatus = "预览异常"; } finally { _printPreviewBusy = false; } } private void PostPrintPreviewHtml(string html) { var app = Application.Current; if (app?.Dispatcher == null) { PrintPreviewHtmlReady?.Invoke(this, html); return; } if (app.Dispatcher.CheckAccess()) PrintPreviewHtmlReady?.Invoke(this, html); else app.Dispatcher.Invoke(() => PrintPreviewHtmlReady?.Invoke(this, html)); } private static string BuildPrintPreviewErrorHtml(string message) { var esc = WebUtility.HtmlEncode(message ?? string.Empty); return "
" + "
⚠️
" + "
预览:" + esc + "
" + "
"; } private static string BuildPrintPreviewPlaceholderHtml(string tip) { var esc = WebUtility.HtmlEncode(tip ?? string.Empty); return "
" + "
" + esc + "
"; } private async Task EnsureLocalPreviewTemplateLoadedAsync() { if (_previewTemplateLoaded && !string.IsNullOrWhiteSpace(_previewTemplateJson)) { return true; } // 离线优先用本地缓存;若当前在线再尝试刷新缓存 var bindList = _printBizTemplateBindService.GetCached(); if (bindList.Count == 0) { try { bindList = await _printBizTemplateBindService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } } var bind = bindList.FirstOrDefault(x => string.Equals(x.BizCode, RawMaterialEntryBizCode, StringComparison.OrdinalIgnoreCase)) ?? bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialEntryTemplateCode, StringComparison.OrdinalIgnoreCase)) ?? bindList.FirstOrDefault(x => (x.BizName ?? string.Empty).Contains("原料入场记录", StringComparison.OrdinalIgnoreCase) || (x.BizName ?? string.Empty).Contains("原材料流转卡片", StringComparison.OrdinalIgnoreCase)); if (bind == null) return false; _previewFieldMappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!; _previewTemplateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialEntryTemplateCode : bind.TemplateCode!; var templates = _printTemplateService.GetCached(); if (templates.Count == 0) { try { templates = await _printTemplateService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } } var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase)) ?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, _previewTemplateCode, StringComparison.OrdinalIgnoreCase)); if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson)) return false; _previewTemplateJson = tpl.TemplateJson!; _previewTemplateName = string.IsNullOrWhiteSpace(tpl.TemplateName) ? "原料入场记录" : tpl.TemplateName!; _previewTemplateLoaded = true; return true; } private JsonObject BuildLocalPreviewData(MesXslRawMaterialEntry entry) { JsonObject printData = new(); JsonNode? bizRoot = JsonSerializer.SerializeToNode(entry, PreviewSnapshotJsonOpts); try { var mappingNode = JsonNode.Parse(string.IsNullOrWhiteSpace(_previewFieldMappingJson) ? "[]" : _previewFieldMappingJson) as JsonArray; if (mappingNode != null) { foreach (var rule in mappingNode) { try { if (rule is not JsonObject obj) continue; var templateField = obj["templateField"]?.GetValue()?.Trim(); if (string.IsNullOrWhiteSpace(templateField)) continue; var bizField = obj["bizField"]?.GetValue()?.Trim(); JsonNode? val = string.IsNullOrWhiteSpace(bizField) ? JsonValue.Create(string.Empty) : ResolvePath(bizRoot, bizField!); // JsonNode 不能同时挂在两个父节点,必须深拷贝后再写入 printData var normalized = NormalizePreviewNodeValue(val); SetPath(printData, templateField!, normalized); } catch { // 单条映射异常不应影响其余字段,继续后续规则 } } } } catch { // 忽略 mapping JSON 异常,继续尝试按模板字段补空 } try { var root = JsonNode.Parse(_previewTemplateJson); var fields = new HashSet(StringComparer.OrdinalIgnoreCase); CollectTemplateBindFields(root, fields); foreach (var key in fields) { if (!HasPath(printData, key)) { SetPath(printData, key, JsonValue.Create(string.Empty)); } } } catch { // 模板异常时忽略补齐 } return printData; } private static void CollectTemplateBindFields(JsonNode? node, HashSet fields) { if (node == null) return; if (node is JsonObject obj) { if (obj["dataBinding"] is JsonObject db && db["params"] is JsonArray paramsArr) { foreach (var p in paramsArr) { var key = p?["key"]?.GetValue()?.Trim(); if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!); } } var bindField = obj["bindField"]?.GetValue()?.Trim(); if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!); if (obj["columns"] is JsonArray cols) { foreach (var c in cols) { var cBind = c?["bindField"]?.GetValue()?.Trim(); var cField = c?["field"]?.GetValue()?.Trim(); if (!string.IsNullOrWhiteSpace(cBind)) fields.Add(cBind!); else if (!string.IsNullOrWhiteSpace(cField)) fields.Add(cField!); } } foreach (var kv in obj) CollectTemplateBindFields(kv.Value, fields); return; } if (node is JsonArray arr) { foreach (var item in arr) CollectTemplateBindFields(item, fields); } } private static bool HasPath(JsonObject root, string path) { var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); JsonNode? cur = root; for (int i = 0; i < parts.Length; i++) { if (cur is not JsonObject obj) return false; if (!obj.TryGetPropertyValue(parts[i], out cur)) return false; if (i == parts.Length - 1) return true; } return false; } private static JsonNode? ResolvePath(JsonNode? root, string path) { if (root == null || string.IsNullOrWhiteSpace(path)) return null; var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); JsonNode? cur = root; foreach (var p in parts) { if (cur == null) return null; if (cur is JsonArray arr) { if (int.TryParse(p, out var idx)) { cur = idx >= 0 && idx < arr.Count ? arr[idx] : null; } else { cur = arr.Count > 0 ? arr[0]?[p] : null; } } else { cur = cur[p]; } } return cur; } /// /// 本地实时预览与后端保持一致:DateTime/DateTimeOffset 统一输出 yyyy-MM-dd HH:mm:ss(不带时区后缀)。 /// private static JsonNode NormalizePreviewNodeValue(JsonNode? node) { if (node == null) return JsonValue.Create(string.Empty)!; if (node is JsonValue v) { if (v.TryGetValue(out var dt)) { return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!; } if (v.TryGetValue(out var dto)) { return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!; } if (v.TryGetValue(out var s) && !string.IsNullOrWhiteSpace(s)) { // 仅处理明显的 ISO 时间串,避免误伤普通文本 if ((s.Contains('T') || s.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || s.Contains('+')) && DateTimeOffset.TryParse(s, out var parsed)) { return JsonValue.Create(parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!; } } } return node.DeepClone(); } private static void SetPath(JsonObject target, string path, JsonNode value) { var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return; JsonObject cur = target; for (int i = 0; i < parts.Length - 1; i++) { if (cur[parts[i]] is not JsonObject child) { child = new JsonObject(); cur[parts[i]] = child; } cur = child; } cur[parts[^1]] = value; } private void LoadLayoutState() { try { if (!File.Exists(LayoutFilePath)) return; var json = File.ReadAllText(LayoutFilePath); var dto = JsonSerializer.Deserialize(json, LayoutJsonOpts); if (dto == null) return; if (dto.ExpandedWidth is > 0 and < 2000) _expandedRightPanelWidth = Math.Clamp(dto.ExpandedWidth.Value, 200, 560); if (dto.IsExpanded.HasValue) _isRightPanelExpanded = dto.IsExpanded.Value; RaisePropertyChanged(nameof(ExpandedRightPanelWidth)); RaisePropertyChanged(nameof(IsRightPanelExpanded)); } catch { /* 布局文件损坏时忽略 */ } } private void SaveLayoutState() { try { var dir = Path.GetDirectoryName(LayoutFilePath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var dto = new AddPageLayoutDto { ExpandedWidth = _expandedRightPanelWidth, IsExpanded = _isRightPanelExpanded }; File.WriteAllText(LayoutFilePath, JsonSerializer.Serialize(dto, LayoutJsonOpts)); } catch { /* 写本地失败时不影响业务 */ } } private sealed class AddPageLayoutDto { public double? ExpandedWidth { get; set; } public bool? IsExpanded { get; set; } } public sealed class RawMaterialCardGeneratePlanItem { public required MesXslRawMaterialCard Card { get; init; } public required string DetailId { get; init; } public int SourceRowNo { get; init; } } }