2026-05-11 14:32:44 +08:00
|
|
|
|
using Prism.Commands;
|
|
|
|
|
|
using System.Collections.ObjectModel;
|
|
|
|
|
|
using System.IO;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
using System.Net;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
using System.Text.Json;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
using System.Windows;
|
|
|
|
|
|
using System.Windows.Threading;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
using YY.Admin.Core.Entity;
|
2026-05-09 18:25:34 +08:00
|
|
|
|
using YY.Admin.Core.Services;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
using YY.Admin.Infrastructure.Print;
|
2026-05-09 18:25:34 +08:00
|
|
|
|
using YY.Admin.Services.Service;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
using YY.Admin.Services.Service.Print;
|
2026-05-09 18:25:34 +08:00
|
|
|
|
|
|
|
|
|
|
namespace YY.Admin.ViewModels.RawMaterialEntry;
|
|
|
|
|
|
|
2026-05-11 14:32:44 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 「新增原料入场记录」独立页面:左侧表单逻辑继承编辑 VM,右侧展示当日入场简要列表并支持选中回填模板。
|
|
|
|
|
|
/// </summary>
|
2026-05-09 18:25:34 +08:00
|
|
|
|
public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel
|
|
|
|
|
|
{
|
2026-05-11 14:32:44 +08:00
|
|
|
|
private const int TodayListFetchSize = 5000;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
private const string RawMaterialEntryBizCode = "1900000000000000530";
|
|
|
|
|
|
private const string RawMaterialEntryTemplateCode = "MES_RAW_MATERIAL_ENTRY";
|
2026-05-11 14:32:44 +08:00
|
|
|
|
private readonly IRawMaterialCardService _rawMaterialCardService;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
private readonly IPrintDotService _printDotService;
|
|
|
|
|
|
private readonly IPrintBizTemplateBindService _printBizTemplateBindService;
|
|
|
|
|
|
private readonly IPrintTemplateService _printTemplateService;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
|
|
|
|
|
|
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<MesXslRawMaterialEntry> TodayEntries { get; } = new();
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
public IReadOnlyList<string> DateRangeOptions { get; } =
|
|
|
|
|
|
["今日", "过去24小时", "过去48小时", "过去72小时"];
|
|
|
|
|
|
|
|
|
|
|
|
private string _selectedDateRange = "今日";
|
|
|
|
|
|
public string SelectedDateRange
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _selectedDateRange;
|
|
|
|
|
|
set
|
|
|
|
|
|
{
|
|
|
|
|
|
if (SetProperty(ref _selectedDateRange, value))
|
|
|
|
|
|
_ = LoadTodayEntriesAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 14:32:44 +08:00
|
|
|
|
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;
|
|
|
|
|
|
/// <summary>右侧面板展开时的目标宽度(像素),由 GridSplitter 拖拽结束时回写。</summary>
|
|
|
|
|
|
public double ExpandedRightPanelWidth
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _expandedRightPanelWidth;
|
|
|
|
|
|
private set
|
|
|
|
|
|
{
|
|
|
|
|
|
var v = Math.Clamp(value, 200, 560);
|
|
|
|
|
|
if (SetProperty(ref _expandedRightPanelWidth, v))
|
|
|
|
|
|
SaveLayoutState();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 「生成原材料卡片」按钮可用条件:
|
|
|
|
|
|
/// 入场记录已保存(有 Id)且至少存在一行「未生成卡片 + 份数>0」的明细。
|
|
|
|
|
|
/// 已打印态下用户「继续拆码」新增了行,按钮自动重新可用;全部行都已生成卡片时不可用。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public bool CanGenerateCards =>
|
|
|
|
|
|
!string.IsNullOrWhiteSpace(Entry?.Id)
|
|
|
|
|
|
&& SplitCodeDetails.Any(d => !d.HasCard && (d.Portions ?? 0) > 0);
|
2026-05-11 14:32:44 +08:00
|
|
|
|
|
|
|
|
|
|
public DelegateCommand ToggleRightPanelCommand { get; }
|
|
|
|
|
|
public DelegateCommand RefreshTodayEntriesCommand { get; }
|
|
|
|
|
|
public DelegateCommand GenerateRawMaterialCardsCommand { get; }
|
2026-05-12 14:06:07 +08:00
|
|
|
|
/// <summary>「重新拆码」:清除已生成的卡片 + 清空明细,仅在编辑态可用。</summary>
|
|
|
|
|
|
public DelegateCommand ResplitCommand { get; }
|
2026-05-14 10:43:51 +08:00
|
|
|
|
public DelegateCommand SaveAndPrintCommand { get; }
|
|
|
|
|
|
public DelegateCommand RefreshPrintersCommand { get; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>PrintDot 桥接器返回的打印机列表(与打印模板页一致)。</summary>
|
|
|
|
|
|
public ObservableCollection<PrintDotPrinter> 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 _isPrintPreviewExpanded = true;
|
|
|
|
|
|
/// <summary>右侧下方「入场标签打印预览」折叠面板是否展开。</summary>
|
|
|
|
|
|
public bool IsPrintPreviewExpanded
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _isPrintPreviewExpanded;
|
|
|
|
|
|
set
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!SetProperty(ref _isPrintPreviewExpanded, value)) return;
|
|
|
|
|
|
if (value) _lastPreviewSnapshot = string.Empty;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private string _printPreviewStatus = string.Empty;
|
|
|
|
|
|
/// <summary>预览区状态提示(如离线、加载中、错误摘要)。</summary>
|
|
|
|
|
|
public string PrintPreviewStatus
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _printPreviewStatus;
|
|
|
|
|
|
private set => SetProperty(ref _printPreviewStatus, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>由 View 订阅,在 UI 线程将 HTML 交给 WebView2。</summary>
|
|
|
|
|
|
public event EventHandler<string>? PrintPreviewHtmlReady;
|
|
|
|
|
|
|
|
|
|
|
|
private bool _isActionBusy;
|
|
|
|
|
|
/// <summary>底部动作按钮(保存/保存并打印/生成卡片)是否处于执行中。</summary>
|
|
|
|
|
|
public bool IsActionBusy
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _isActionBusy;
|
|
|
|
|
|
private set
|
|
|
|
|
|
{
|
|
|
|
|
|
if (SetProperty(ref _isActionBusy, value))
|
|
|
|
|
|
{
|
|
|
|
|
|
RaisePropertyChanged(nameof(IsNotActionBusy));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public bool IsNotActionBusy => !IsActionBusy;
|
|
|
|
|
|
|
|
|
|
|
|
private string _actionBusyText = "处理中...";
|
|
|
|
|
|
/// <summary>动作执行中的遮罩提示文案。</summary>
|
|
|
|
|
|
public string ActionBusyText
|
|
|
|
|
|
{
|
|
|
|
|
|
get => _actionBusyText;
|
|
|
|
|
|
private set => SetProperty(ref _actionBusyText, value);
|
|
|
|
|
|
}
|
2026-05-11 14:32:44 +08:00
|
|
|
|
|
2026-05-09 18:25:34 +08:00
|
|
|
|
public RawMaterialEntryOperationViewModel(
|
|
|
|
|
|
IRawMaterialEntryService entryService,
|
|
|
|
|
|
IJeecgDictSyncService dictSyncService,
|
|
|
|
|
|
IMixerMaterialService mixerMaterialService,
|
2026-05-11 14:32:44 +08:00
|
|
|
|
IRawMaterialCardService rawMaterialCardService,
|
2026-05-14 10:43:51 +08:00
|
|
|
|
IPrintDotService printDotService,
|
|
|
|
|
|
IPrintBizTemplateBindService printBizTemplateBindService,
|
|
|
|
|
|
IPrintTemplateService printTemplateService,
|
2026-05-09 18:25:34 +08:00
|
|
|
|
IContainerExtension container,
|
|
|
|
|
|
IRegionManager regionManager)
|
|
|
|
|
|
: base(entryService, dictSyncService, mixerMaterialService, container, regionManager)
|
|
|
|
|
|
{
|
2026-05-11 14:32:44 +08:00
|
|
|
|
_rawMaterialCardService = rawMaterialCardService;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
_printDotService = printDotService;
|
|
|
|
|
|
_printBizTemplateBindService = printBizTemplateBindService;
|
|
|
|
|
|
_printTemplateService = printTemplateService;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
LoadLayoutState();
|
|
|
|
|
|
ToggleRightPanelCommand = new DelegateCommand(() => IsRightPanelExpanded = !IsRightPanelExpanded);
|
|
|
|
|
|
RefreshTodayEntriesCommand = new DelegateCommand(async () => await LoadTodayEntriesAsync());
|
|
|
|
|
|
GenerateRawMaterialCardsCommand = new DelegateCommand(async () => await GenerateRawMaterialCardsAsync());
|
2026-05-12 14:06:07 +08:00
|
|
|
|
ResplitCommand = new DelegateCommand(async () => await ResplitAsync(), () => CanResplit)
|
|
|
|
|
|
.ObservesProperty(() => Entry);
|
2026-05-14 10:43:51 +08:00
|
|
|
|
SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync());
|
|
|
|
|
|
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
|
2026-05-12 14:06:07 +08:00
|
|
|
|
// 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions,并同步刷新两个 Can*。
|
|
|
|
|
|
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
_ = RefreshPrintersAsync(verbose: false);
|
|
|
|
|
|
|
|
|
|
|
|
var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
|
|
|
|
|
|
_printPreviewTimer = new DispatcherTimer(
|
|
|
|
|
|
TimeSpan.FromMilliseconds(480),
|
|
|
|
|
|
DispatcherPriority.Background,
|
|
|
|
|
|
(_, _) => _ = OnPrintPreviewTickAsync(),
|
|
|
|
|
|
dispatcher);
|
|
|
|
|
|
_printPreviewTimer.Start();
|
2026-05-12 14:06:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 「重新拆码」按钮可用条件:入场记录已保存 + 至少有一行已生成卡片。
|
|
|
|
|
|
/// 全是新增未生成的行时,用户直接点「删除」即可,无需走「清空卡片」流程。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public bool CanResplit =>
|
|
|
|
|
|
!string.IsNullOrWhiteSpace(Entry?.Id)
|
|
|
|
|
|
&& SplitCodeDetails.Any(d => d.HasCard);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 集合变化时:对新增/移除的 item 维护 PropertyChanged 订阅,并刷新两个 Can* 属性。
|
|
|
|
|
|
/// HasCard / Portions 变化会让「生成原材料卡片」和「重新拆码」按钮可用性同步刷新。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public override void InitializeForAdd()
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
_lastPreviewSnapshot = string.Empty;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
_suppressTodaySelectionReaction = true;
|
|
|
|
|
|
_selectedTodayEntry = null;
|
|
|
|
|
|
RaisePropertyChanged(nameof(SelectedTodayEntry));
|
|
|
|
|
|
_suppressTodaySelectionReaction = false;
|
|
|
|
|
|
base.InitializeForAdd();
|
|
|
|
|
|
RaisePropertyChanged(nameof(CanGenerateCards));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 10:43:51 +08:00
|
|
|
|
/// <summary>保存后按业务打印绑定拉取模板与 printData,直接发送到 PrintDot 打印机(不弹预览窗)。</summary>
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (wasEdit)
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Success("编辑成功!");
|
|
|
|
|
|
else
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Success("新增成功!");
|
|
|
|
|
|
|
|
|
|
|
|
Result = true;
|
|
|
|
|
|
RaisePropertyChanged(nameof(CanGenerateCards));
|
|
|
|
|
|
await LoadTodayEntriesAsync();
|
|
|
|
|
|
InitializeForAdd();
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}");
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
|
EndActionBusy();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>通过 PrintDot 桥接器拉取打印机列表(与打印模板页一致)。</summary>
|
|
|
|
|
|
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" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 14:32:44 +08:00
|
|
|
|
/// <summary>页面首次加载时拉取「今日」列表(由 View Loaded 调用)。</summary>
|
|
|
|
|
|
public async Task LoadTodayEntriesOnFirstShowAsync() => await LoadTodayEntriesAsync();
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>用户在拖拽结束 GridSplitter 后提交实际列宽。</summary>
|
|
|
|
|
|
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);
|
2026-05-12 14:06:07 +08:00
|
|
|
|
DateTime? threshold = _selectedDateRange switch
|
|
|
|
|
|
{
|
|
|
|
|
|
"过去24小时" => DateTime.Now.AddHours(-24),
|
|
|
|
|
|
"过去48小时" => DateTime.Now.AddHours(-48),
|
|
|
|
|
|
"过去72小时" => DateTime.Now.AddHours(-72),
|
|
|
|
|
|
_ => null // "今日":按自然日过滤
|
|
|
|
|
|
};
|
2026-05-11 14:32:44 +08:00
|
|
|
|
var rows = result.Records
|
2026-05-12 14:06:07 +08:00
|
|
|
|
.Where(e => IsInRange(e, threshold))
|
2026-05-11 14:32:44 +08:00
|
|
|
|
.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
TodayEntries.Clear();
|
|
|
|
|
|
foreach (var r in rows)
|
|
|
|
|
|
TodayEntries.Add(r);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// 列表失败不阻断左侧新增(与物料加载策略一致)
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
IsLoading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
private static bool IsInRange(MesXslRawMaterialEntry e, DateTime? threshold)
|
2026-05-11 14:32:44 +08:00
|
|
|
|
{
|
2026-05-12 14:06:07 +08:00
|
|
|
|
if (threshold == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
var today = DateTime.Today;
|
|
|
|
|
|
return (e.EntryTime?.Date == today) || (e.CreateTime?.Date == today);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (e.EntryTime >= threshold) || (e.CreateTime >= threshold);
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 点击右侧今日记录:直接以「编辑模式」加载源记录到左侧表单(保留 Id/条码/批次号),
|
|
|
|
|
|
/// 不再走「新增模板」逻辑,避免重新生成条码、覆盖原数据。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private async Task ApplyTodayRowToFormAsync(MesXslRawMaterialEntry src)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 复用基类 InitializeForEdit:保留 Id/Barcode/BatchNo/状态等所有字段,标题自动切到「编辑原料入场记录」
|
|
|
|
|
|
base.InitializeForEdit(src);
|
|
|
|
|
|
RaisePropertyChanged(nameof(CanGenerateCards));
|
|
|
|
|
|
|
|
|
|
|
|
// 若物料列表此前未加载导致选中项未回填,则补一次拉取(与编辑弹窗逻辑一致)
|
|
|
|
|
|
if (_pendingMaterialId != null)
|
|
|
|
|
|
await LoadMaterialOptionsAsync();
|
2026-05-14 10:43:51 +08:00
|
|
|
|
_lastPreviewSnapshot = string.Empty;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 保存成功后:无论新增还是编辑,都立即刷新右侧「今日入场」列表。
|
|
|
|
|
|
/// 编辑成功额外把表单切回「新增态」,避免用户连续误改同一条;新增成功后由基类负责清空表单。
|
|
|
|
|
|
/// </summary>
|
2026-05-11 14:32:44 +08:00
|
|
|
|
protected override async Task SaveAsync()
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
if (IsActionBusy) return;
|
|
|
|
|
|
BeginActionBusy("保存中...");
|
2026-05-11 14:32:44 +08:00
|
|
|
|
var wasEdit = !IsAddMode;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
try
|
2026-05-12 14:06:07 +08:00
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
await base.SaveAsync();
|
|
|
|
|
|
RaisePropertyChanged(nameof(CanGenerateCards));
|
|
|
|
|
|
|
|
|
|
|
|
if (!Result || CloseAction != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-12 14:06:07 +08:00
|
|
|
|
|
2026-05-14 10:43:51 +08:00
|
|
|
|
await LoadTodayEntriesAsync();
|
2026-05-12 14:06:07 +08:00
|
|
|
|
|
2026-05-14 10:43:51 +08:00
|
|
|
|
if (wasEdit)
|
|
|
|
|
|
{
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Success("编辑成功!");
|
|
|
|
|
|
InitializeForAdd();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
2026-05-11 14:32:44 +08:00
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
EndActionBusy();
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 「重新拆码」:弹窗预提示「将清除 N 张原材料卡片」→ 确认 → 后端按 splitDetailId IN 批删
|
|
|
|
|
|
/// → 清空 SplitCodeDetails → Entry.PrintFlag=0 → EditAsync(Entry) 持久化新状态 → 刷新今日列表。
|
|
|
|
|
|
/// 流程对应「清空卡片重新生成」的业务诉求;用户可立即重新维护明细并再次「生成原材料卡片」。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 「生成原材料卡片」:仅处理 HasCard==false 的明细行(「继续拆码」流程的核心)。
|
|
|
|
|
|
/// 条码续号 = 已有卡片总数(Σ HasCard==true 行的 Portions)+ 1,避免与已生成卡片的条码冲突。
|
|
|
|
|
|
/// 成功生成的行置 HasCard=true,Entry.PrintFlag=1(整票级标记)。
|
|
|
|
|
|
/// </summary>
|
2026-05-11 14:32:44 +08:00
|
|
|
|
private async Task GenerateRawMaterialCardsAsync()
|
|
|
|
|
|
{
|
2026-05-14 10:43:51 +08:00
|
|
|
|
if (IsActionBusy) return;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id))
|
|
|
|
|
|
{
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Warning("请先保存入场记录再生成原材料卡片!");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-12 14:06:07 +08:00
|
|
|
|
|
|
|
|
|
|
// 只处理未生成卡片的行 + 份数>0;已生成行直接跳过,避免重复加卡和条码冲突
|
|
|
|
|
|
var pendingRows = SplitCodeDetails
|
|
|
|
|
|
.Where(d => !d.HasCard && (d.Portions ?? 0) > 0)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
if (pendingRows.Count == 0)
|
2026-05-11 14:32:44 +08:00
|
|
|
|
{
|
2026-05-12 14:06:07 +08:00
|
|
|
|
HandyControl.Controls.MessageBox.Warning("当前没有待生成卡片的拆码明细(已生成的行不会重复处理)。请先「新增明细」再生成。");
|
2026-05-11 14:32:44 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
// 库位必填仅校验本批待生成行;行号取在原集合的真实索引,避免编号偏移误导
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 10:43:51 +08:00
|
|
|
|
BeginActionBusy("生成中...");
|
2026-05-11 14:32:44 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
IsLoading = true;
|
2026-05-12 14:06:07 +08:00
|
|
|
|
// 续号起点:已有卡片数 = Σ HasCard==true 行的 Portions;新增卡片从其后续编
|
|
|
|
|
|
var alreadyGenerated = SplitCodeDetails
|
|
|
|
|
|
.Where(d => d.HasCard)
|
|
|
|
|
|
.Sum(d => d.Portions ?? 0);
|
|
|
|
|
|
var globalIndex = alreadyGenerated + 1;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
var baseBarcode = Entry.Barcode ?? "";
|
|
|
|
|
|
var failCount = 0;
|
2026-05-12 14:06:07 +08:00
|
|
|
|
var newCardCount = 0;
|
|
|
|
|
|
// 按行收集成功标记:只要该行所有份都加卡成功,行 HasCard=true;
|
|
|
|
|
|
// 中途任一份失败则保留 HasCard=false,下次再点「生成」时会重试该行
|
|
|
|
|
|
var rowSuccessMap = new Dictionary<RawMaterialSplitDetailItem, bool>();
|
2026-05-11 14:32:44 +08:00
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
foreach (var detail in pendingRows)
|
2026-05-11 14:32:44 +08:00
|
|
|
|
{
|
|
|
|
|
|
var portions = detail.Portions ?? 0;
|
2026-05-12 14:06:07 +08:00
|
|
|
|
var rowAllOk = true;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
for (var i = 0; i < portions; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var card = new MesXslRawMaterialCard
|
|
|
|
|
|
{
|
2026-05-12 14:06:07 +08:00
|
|
|
|
// 关联到当前拆码明细行 — 便于「重新拆码」按此 ID 批删
|
|
|
|
|
|
SplitDetailId = detail.Id,
|
2026-05-11 14:32:44 +08:00
|
|
|
|
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,
|
|
|
|
|
|
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);
|
2026-05-12 14:06:07 +08:00
|
|
|
|
if (ok) newCardCount++;
|
|
|
|
|
|
else { failCount++; rowAllOk = false; }
|
2026-05-11 14:32:44 +08:00
|
|
|
|
globalIndex++;
|
|
|
|
|
|
}
|
2026-05-12 14:06:07 +08:00
|
|
|
|
rowSuccessMap[detail] = rowAllOk;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 14:06:07 +08:00
|
|
|
|
// 把整行加卡都成功的标记 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));
|
2026-05-11 14:32:44 +08:00
|
|
|
|
|
|
|
|
|
|
if (failCount == 0)
|
2026-05-12 14:06:07 +08:00
|
|
|
|
HandyControl.Controls.MessageBox.Success($"已生成 {newCardCount} 张原材料卡片(累计 {alreadyGenerated + newCardCount} 张),打印状态已更新为「已打印」!");
|
2026-05-11 14:32:44 +08:00
|
|
|
|
else
|
2026-05-12 14:06:07 +08:00
|
|
|
|
HandyControl.Controls.MessageBox.Warning($"本次共尝试生成 {newCardCount + failCount} 张,成功 {newCardCount} 张,失败 {failCount} 张。失败的行未标记为「已打印」,可检查网络后再次点击「生成原材料卡片」重试。");
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
HandyControl.Controls.MessageBox.Error($"生成原材料卡片失败:{ex.Message}");
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
IsLoading = false;
|
2026-05-14 10:43:51 +08:00
|
|
|
|
EndActionBusy();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void BeginActionBusy(string text)
|
|
|
|
|
|
{
|
|
|
|
|
|
ActionBusyText = string.IsNullOrWhiteSpace(text) ? "处理中..." : text;
|
|
|
|
|
|
IsActionBusy = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void EndActionBusy()
|
|
|
|
|
|
{
|
|
|
|
|
|
IsActionBusy = false;
|
|
|
|
|
|
ActionBusyText = "处理中...";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>页面卸载时停止轮询,避免后台仍请求已释放的 WebView。</summary>
|
|
|
|
|
|
public void StopPrintPreviewTimer() => _printPreviewTimer.Stop();
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>视图重新附加时恢复轮询(与 <see cref="StopPrintPreviewTimer"/> 配对)。</summary>
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
|
|
|
|
|
html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj);
|
|
|
|
|
|
}
|
|
|
|
|
|
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 "<html><head><meta charset=\"utf-8\"/><style>"
|
|
|
|
|
|
+ "body{margin:0;background:#525659;display:flex;align-items:center;"
|
|
|
|
|
|
+ "justify-content:center;height:100vh;font-family:'Microsoft YaHei',Arial,sans-serif;}"
|
|
|
|
|
|
+ ".card{background:#fff;border-radius:8px;padding:28px 40px;text-align:center;"
|
|
|
|
|
|
+ "box-shadow:0 6px 24px rgba(0,0,0,.4);max-width:520px;}"
|
|
|
|
|
|
+ "</style></head><body><div class=\"card\">"
|
|
|
|
|
|
+ "<div style=\"font-size:36px;margin-bottom:10px\">⚠️</div>"
|
|
|
|
|
|
+ "<div style=\"font-size:13px;color:#e74c3c;word-break:break-all;\">预览:" + esc + "</div>"
|
|
|
|
|
|
+ "</div></body></html>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string BuildPrintPreviewPlaceholderHtml(string tip)
|
|
|
|
|
|
{
|
|
|
|
|
|
var esc = WebUtility.HtmlEncode(tip ?? string.Empty);
|
|
|
|
|
|
return "<html><head><meta charset=\"utf-8\"/><style>"
|
|
|
|
|
|
+ "body{margin:0;background:#525659;display:flex;align-items:center;justify-content:center;"
|
|
|
|
|
|
+ "height:100vh;font-family:'Microsoft YaHei',Arial,sans-serif;}"
|
|
|
|
|
|
+ ".card{background:#fff;border-radius:8px;padding:32px 48px;text-align:center;}"
|
|
|
|
|
|
+ "</style></head><body><div class=\"card\">"
|
|
|
|
|
|
+ "<div style=\"font-size:14px;color:#888;\">" + esc + "</div></div></body></html>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async Task<bool> 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<string>()?.Trim();
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(templateField)) continue;
|
|
|
|
|
|
var bizField = obj["bizField"]?.GetValue<string>()?.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<string>(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<string> 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<string>()?.Trim();
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
var bindField = obj["bindField"]?.GetValue<string>()?.Trim();
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!);
|
|
|
|
|
|
if (obj["columns"] is JsonArray cols)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var c in cols)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cBind = c?["bindField"]?.GetValue<string>()?.Trim();
|
|
|
|
|
|
var cField = c?["field"]?.GetValue<string>()?.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 本地实时预览与后端保持一致:DateTime/DateTimeOffset 统一输出 yyyy-MM-dd HH:mm:ss(不带时区后缀)。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static JsonNode NormalizePreviewNodeValue(JsonNode? node)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (node == null) return JsonValue.Create(string.Empty)!;
|
|
|
|
|
|
|
|
|
|
|
|
if (node is JsonValue v)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (v.TryGetValue<DateTime>(out var dt))
|
|
|
|
|
|
{
|
|
|
|
|
|
return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (v.TryGetValue<DateTimeOffset>(out var dto))
|
|
|
|
|
|
{
|
|
|
|
|
|
return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (v.TryGetValue<string>(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;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
2026-05-14 10:43:51 +08:00
|
|
|
|
cur[parts[^1]] = value;
|
2026-05-11 14:32:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void LoadLayoutState()
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!File.Exists(LayoutFilePath)) return;
|
|
|
|
|
|
var json = File.ReadAllText(LayoutFilePath);
|
|
|
|
|
|
var dto = JsonSerializer.Deserialize<AddPageLayoutDto>(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; }
|
2026-05-09 18:25:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|