using AllBase.Helper; using AllBase.Model.DBModel; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using OrderSupportTicket.Services; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Data; using System.Windows.Media; using System.Windows.Threading; using WPFCommon.Models; using WxOrder.Framework.Commons; using WxOrder.Framework.Commons.Dialogs; namespace OrderSupportTicket.ViewModels; /// /// 工单页总 ViewModel,负责工单查询、留言操作、新建工单和 SSE 刷新。 /// public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IDisposable { /// /// 新建工单弹窗中传入 的参数名。 /// public const string OrderParameterKey = "order"; private readonly TicketService _ticketService; private readonly WorkOrderCacheService _cacheService = new WorkOrderCacheService(); private readonly Dispatcher _dispatcher; private readonly AsyncDelegateCommand _selectTicketCommand; private readonly AsyncDelegateCommand _createTicketCommand; private readonly AsyncDelegateCommand _sendMessageCommand; private readonly AsyncDelegateCommand _loadEarlierMessagesCommand; private readonly AsyncDelegateCommand _retryRealtimeCommand; private readonly HashSet _seenEventIds = new HashSet(StringComparer.Ordinal); private readonly Dictionary _lastReadSequences = new Dictionary(StringComparer.Ordinal); private Dictionary _savedWorkOrders = new Dictionary(StringComparer.Ordinal); private bool _hasSavedCache; private TicketClientOptions _options; private bool _configured; private bool _initialized; private bool _disposed; private string _pendingOpenKey; private WorkOrderSummaryViewModel _selectedTicket; private TicketOrderOption _selectedOrder; private ImageSource _selectedOrderImage; private int _selectedOrderImageVersion; private string _chatText; private string _draftText; private string _ticketSearchText = string.Empty; private bool _isDraft; private bool _isBusy; private bool _isLoading; private bool _isLoadingMessages; private bool _hasMoreMessages; private string _nextBefore; private string _noticeText; private bool _isNoticeError; private bool _hasNewData; private string _newDataText; private bool _isWindowActive; private TicketRealtimeState _sseState; private string _sseStatusText; private int _messageScrollVersion; private string _pendingCreateRequestId; private string _pendingCreateFingerprint; private string _pendingMessageId; private string _pendingMessageFingerprint; private int _selectionVersion; public OrderSupportTicketViewModel( TicketService ticketService, TicketClientOptions options = null) { _ticketService = ticketService ?? throw new ArgumentNullException(nameof(ticketService)); _dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; Tickets = new ObservableCollection(); Messages = new ObservableCollection(); TicketView = CollectionViewSource.GetDefaultView(Tickets); TicketView.Filter = FilterTicket; _selectTicketCommand = new AsyncDelegateCommand( SelectTicketAsync, ticket => ticket != null && !IsBusy); _createTicketCommand = new AsyncDelegateCommand( CreateTicketAsync, CanCreateTicket); _sendMessageCommand = new AsyncDelegateCommand( SendMessageAsync, CanSendMessage); _loadEarlierMessagesCommand = new AsyncDelegateCommand( LoadEarlierMessagesAsync, () => !IsBusy && SelectedTicket != null && HasMoreMessages); _retryRealtimeCommand = new AsyncDelegateCommand( RestartRealtimeAsync, () => !IsBusy && _configured); _ticketService.NotificationReceived += OnNotificationReceived; _ticketService.StateChanged += OnRealtimeStateChanged; SseState = _ticketService.RealtimeState; SseStatusText = GetRealtimeStatusText(SseState); if (options != null) Configure(options); } /// /// 当前账号和 APPID 下的历史工单。 /// public ObservableCollection Tickets { get; } /// /// 经过搜索条件过滤后的历史工单。 /// public ICollectionView TicketView { get; } /// /// 当前选中工单的公开留言。 /// public ObservableCollection Messages { get; } /// /// 选择并加载历史工单的命令。 /// public ICommand SelectTicketCommand => _selectTicketCommand; /// /// 提交新工单的命令。 /// public ICommand CreateTicketCommand => _createTicketCommand; /// /// 提交客户留言的命令。 /// public ICommand SendMessageCommand => _sendMessageCommand; /// /// 加载当前工单更早留言的命令。 /// public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand; /// /// 手动重新连接 SSE 的命令。 /// public ICommand RetryRealtimeCommand => _retryRealtimeCommand; /// /// 当前正在查看的历史工单。 /// public WorkOrderSummaryViewModel SelectedTicket { get => _selectedTicket; private set { if (SetValue(ref _selectedTicket, value)) { RaisePropertyChanged(nameof(IsChatVisible)); RaisePropertyChanged(nameof(IsEmptyVisible)); RaisePropertyChanged(nameof(ComposerPlaceholder)); RaiseSendCanExecuteChanged(); RaiseLoadEarlierCanExecuteChanged(); } } } /// /// 新建工单时选中的关联订单。 /// public TicketOrderOption SelectedOrder { get => _selectedOrder; set { if (SetValue(ref _selectedOrder, value)) { RaisePropertyChanged(nameof(SelectedOrderDescription)); RaisePropertyChanged(nameof(SelectedOrderText)); RaiseCreateCanExecuteChanged(); _ = LoadSelectedOrderImageAsync(value); } } } /// /// 当前工单输入框中的留言文本。 /// public string ChatText { get => _chatText; set { if (SetValue(ref _chatText, value ?? string.Empty)) { RaisePropertyChanged(nameof(ChatCharacterCountText)); RaiseSendCanExecuteChanged(); } } } /// /// 新建工单输入框中的问题描述。 /// public string DraftText { get => _draftText; set { if (SetValue(ref _draftText, value ?? string.Empty)) { RaisePropertyChanged(nameof(DraftCharacterCountText)); RaiseCreateCanExecuteChanged(); } } } /// /// 历史工单搜索关键词,可匹配订单号、店铺、状态或最近动态。 /// public string TicketSearchText { get => _ticketSearchText; set { if (SetValue(ref _ticketSearchText, value ?? string.Empty)) { if (IsDraft) CancelDraft(); TicketView?.Refresh(); } } } /// /// 当前是否处于新建工单草稿状态。 /// public bool IsDraft { get => _isDraft; private set { if (SetValue(ref _isDraft, value)) { RaisePropertyChanged(nameof(IsChatVisible)); RaisePropertyChanged(nameof(IsEmptyVisible)); RaisePropertyChanged(nameof(IsDraftVisible)); RaiseCreateCanExecuteChanged(); } } } /// /// 是否正在执行创建、留言或刷新操作。 /// public bool IsBusy { get => _isBusy; private set { if (SetValue(ref _isBusy, value)) { RaisePropertyChanged(nameof(IsNotBusy)); RaiseSendCanExecuteChanged(); RaiseCreateCanExecuteChanged(); RaiseLoadEarlierCanExecuteChanged(); _retryRealtimeCommand.RaiseCanExecuteChanged(); _selectTicketCommand.RaiseCanExecuteChanged(); if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey)) _ = SelectPendingTicketAsync(); } } } /// /// 是否正在加载工单摘要列表。 /// public bool IsLoading { get => _isLoading; private set => SetValue(ref _isLoading, value); } /// /// 是否正在加载当前工单留言。 /// public bool IsLoadingMessages { get => _isLoadingMessages; private set => SetValue(ref _isLoadingMessages, value); } /// /// 当前留言板是否还有更早留言。 /// public bool HasMoreMessages { get => _hasMoreMessages; private set { if (SetValue(ref _hasMoreMessages, value)) RaiseLoadEarlierCanExecuteChanged(); } } /// /// 当前操作提示或错误信息。 /// public string NoticeText { get => _noticeText; private set => SetValue(ref _noticeText, value); } /// /// 当前提示是否为错误类型。 /// public bool IsNoticeError { get => _isNoticeError; private set => SetValue(ref _isNoticeError, value); } /// /// 当前页面是否检测到服务器有新的工单消息。 /// public bool HasNewData { get => _hasNewData; private set => SetValue(ref _hasNewData, value); } /// /// 新数据提示文本。 /// public string NewDataText { get => _newDataText; private set => SetValue(ref _newDataText, value); } /// /// 工单页所在窗口当前是否处于激活且可见状态。 /// 未激活时只刷新数据,不把当前工单标记为已读。 /// public bool IsWindowActive { get => _isWindowActive; set { if (!SetValue(ref _isWindowActive, value)) return; if (value && _initialized && !IsDraft && SelectedTicket != null) _ = MarkSelectedTicketReadAsync(); } } /// /// SSE 当前连接状态。 /// public TicketRealtimeState SseState { get => _sseState; private set => SetValue(ref _sseState, value); } /// /// SSE 当前状态的中文显示文本。 /// public string SseStatusText { get => _sseStatusText; private set => SetValue(ref _sseStatusText, value); } /// /// 触发消息列表滚动到底部的版本号。 /// public int MessageScrollVersion { get => _messageScrollVersion; private set => SetValue(ref _messageScrollVersion, value); } /// /// 是否显示当前工单的聊天面板。 /// public bool IsChatVisible => !IsDraft && SelectedTicket != null; /// /// 是否显示新建工单面板。 /// public bool IsDraftVisible => IsDraft; /// /// 是否显示未选择工单时的空状态。 /// public bool IsEmptyVisible => !IsDraft && SelectedTicket == null; /// /// 当前是否没有执行中的操作。 /// public bool IsNotBusy => !IsBusy; /// /// 留言输入框字数统计文本。 /// public string ChatCharacterCountText => (ChatText ?? string.Empty).Length + " / 4000"; /// /// 新建工单输入框字数统计文本。 /// public string DraftCharacterCountText => (DraftText ?? string.Empty).Length + " / 2000"; /// /// 当前工单输入框提示文本。 /// public string ComposerPlaceholder => SelectedTicket != null && SelectedTicket.IsCompleted ? "工单已完结,继续留言将重新开启" : "输入留言"; /// /// 当前关联订单的商品、金额和下单时间说明。 /// public string SelectedOrderDescription => SelectedOrder?.ProductDescription ?? "未提供关联订单"; /// /// 当前关联订单的订单号和商品名摘要。 /// public string SelectedOrderText => SelectedOrder?.DisplayText ?? "未提供关联订单"; /// /// 当前关联订单的商品缩略图,加载完成前为空。 /// public ImageSource SelectedOrderImage { get => _selectedOrderImage; private set => SetValue(ref _selectedOrderImage, value); } /// /// 弹窗标题。 /// public string Title => "客户工单"; /// /// 由 DialogService 绑定,页面本身不主动请求关闭。 /// #pragma warning disable CS0067 public event Action RequestClose; #pragma warning restore CS0067 public bool CanCloseDialog() { return true; } public void OnDialogOpened(IDialogParameters parameters) { if (parameters == null) return; if (parameters.TryGetValue("options", out var options) && options != null) { Configure(options); } var order = GetOrderFromParameters(parameters); if (order != null) OpenNewTicket(order); } /// /// 从弹窗参数中取出需要新建工单的订单。优先使用订单对象, /// 同时兼容按字段传入订单号、APPID 等信息的调用方式。 /// private static TicketOrderOption GetOrderFromParameters( IDialogParameters parameters) { foreach (var key in new[] { OrderParameterKey, "ticketOrder", "TicketOrderOption" }) { if (parameters.TryGetValue(key, out var order) && order != null) { return order; } } if (parameters.TryGetValue("options", out var options) && options?.Orders != null) { var optionOrder = options.Orders.FirstOrDefault(o => o != null); if (optionOrder != null) return optionOrder; } foreach (var key in parameters.Keys ?? Enumerable.Empty()) { if (!parameters.TryGetValue(key, out var value)) continue; if (value is TicketOrderOption directOrder) return directOrder; if (value is TicketClientOptions option) { var optionOrder = option.Orders?.FirstOrDefault(o => o != null); if (optionOrder != null) return optionOrder; } } var appId = GetParameterText(parameters, "appId", "AppId"); var orderId = GetParameterText( parameters, "wechatOrderId", "orderId", "OrderId"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(orderId)) return null; decimal.TryParse( GetParameterText(parameters, "amount", "Amount"), NumberStyles.Number, CultureInfo.InvariantCulture, out var amount); DateTime.TryParse( GetParameterText(parameters, "orderedAt", "OrderedAt"), CultureInfo.CurrentCulture, DateTimeStyles.None, out var orderedAt); return new TicketOrderOption { AppId = appId.Trim(), OrderId = orderId.Trim(), GoodsName = GetParameterText(parameters, "goodsName", "GoodsName"), ImageUrl = GetParameterText(parameters, "imageUrl", "ImageUrl"), Amount = amount, OrderedAt = orderedAt }; } private static string GetParameterText( IDialogParameters parameters, params string[] keys) { foreach (var key in keys) { if (parameters.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) { return value; } } return null; } /// /// 后台下载关联订单的商品缩略图,避免阻塞界面;切换订单后丢弃过期结果。 /// private async Task LoadSelectedOrderImageAsync(TicketOrderOption order) { var version = ++_selectedOrderImageVersion; SelectedOrderImage = null; var imageUrl = order?.ImageUrl?.Trim(); if (string.IsNullOrEmpty(imageUrl)) return; var image = await ProductImageLoader.LoadAsync(imageUrl); if (image == null || version != _selectedOrderImageVersion || _disposed) return; try { await _dispatcher.InvokeAsync(() => { if (version == _selectedOrderImageVersion && !_disposed) SelectedOrderImage = image; }); } catch { // 页面关闭过程中忽略图片回填失败。 } } public void OnDialogClosed() { // 工单页面由主窗口持有并复用,关闭弹窗时保留页面状态。 } /// /// 切换回历史工单页面。 /// public void ShowHistory() { CancelDraft(); } /// /// 取消尚未提交的新工单草稿。 /// public void CancelNewTicketDraft() { CancelDraft(); } public void Configure(TicketClientOptions options) { if (_disposed) throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel)); if (_ticketService.OwnsRealtime) { _ = _ticketService.StopRealtimeAsync(); SseState = TicketRealtimeState.Disconnected; } else { SseState = _ticketService.RealtimeState; } SseStatusText = GetRealtimeStatusText(SseState); _configured = false; _initialized = false; _pendingOpenKey = null; _options = CloneOptions(options); HasNewData = false; NewDataText = string.Empty; IsDraft = false; SelectedTicket = null; SelectedOrder = null; DraftText = string.Empty; Tickets.Clear(); Messages.Clear(); if (_options.AppIds == null || _options.AppIds.Count == 0) { _options.AppIds = (_options.Orders ?? Enumerable.Empty()) .Where(o => o != null) .Select(o => o.AppId) .Where(a => !string.IsNullOrWhiteSpace(a)) .Select(a => a.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); } LoadSavedOrderTimes(); ApplyOptionsValidation(); } private void ApplyOptionsValidation() { try { _ticketService.ValidateOptions(_options); if (_options.AppIds == null || _options.AppIds.Count == 0) throw new ArgumentException("appIds 至少需要一个 APPID。"); _configured = true; NoticeText = string.Empty; IsNoticeError = false; } catch (Exception ex) { _configured = false; SetNotice( "工单页配置不完整:" + ex.Message + " 请通过 TicketClientOptions 注入拍单账号和 APPID。", true); } RaiseCreateCanExecuteChanged(); RaiseLoadEarlierCanExecuteChanged(); _retryRealtimeCommand.RaiseCanExecuteChanged(); } public async Task InitializeAsync() { if (_disposed) return; if (!_configured) { SetNotice( "未配置拍单账号或 APPID,工单页无法连接服务器。", true); return; } try { var started = false; // 共享连接由宿主统一维护,页面首次打开时不能因为 _initialized=false 重启连接。 var shouldStartRealtime = _ticketService.RealtimeState == TicketRealtimeState.Disconnected || (_ticketService.OwnsRealtime && !_initialized); if (shouldStartRealtime) { _ticketService.StartRealtime(_options); started = true; } if (!_initialized || started) { await RefreshAllWorkOrdersAsync(loadSelectedMessages: true); _initialized = true; } await SelectPendingTicketAsync(); } catch (Exception ex) { SetNotice(DescribeException(ex), true); } } public async Task DeactivateAsync() { SaveWorkOrderList(); if (_ticketService.OwnsRealtime) await _ticketService.StopRealtimeAsync(); } public void OpenNewTicket(TicketOrderOption order) { if (order == null) return; RememberOrderInfo(order); CancelDraft(); TicketSearchText = string.Empty; SelectedOrder = order; EnsureOrderAppId(order.AppId); OpenNewDraft(); } /// /// 订单列表异步加载完成后,将商品名称、图片和价格补充到对应工单。 /// public void ApplyOrderInfo(TicketOrderOption order) { if (order == null || string.IsNullOrWhiteSpace(order.OrderId)) return; RememberOrderInfo(order); var ticket = Tickets.FirstOrDefault(t => string.Equals(t.WechatOrderId, order.OrderId, StringComparison.Ordinal) && (string.IsNullOrWhiteSpace(order.AppId) || string.Equals(t.AppId, order.AppId, StringComparison.Ordinal))); if (ticket == null) return; ticket.ApplyProductInfo(order.GoodsName, order.ImageUrl, order.Amount); TicketView.Refresh(); SaveWorkOrderList(); } private void EnsureOrderAppId(string appId) { if (_options == null || string.IsNullOrWhiteSpace(appId)) return; if (_options.AppIds == null) _options.AppIds = new List(); if (!_options.AppIds.Any(id => string.Equals(id, appId, StringComparison.Ordinal))) { _options.AppIds.Add(appId); } if (!_configured) ApplyOptionsValidation(); } /// /// 打开指定工单;页面尚未初始化时会在首次加载完成后自动定位。 /// public void OpenTicket(string appId, string wechatOrderId) { if (_disposed || !_configured) return; if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId)) return; _pendingOpenKey = appId + "|" + wechatOrderId; if (_initialized) _ = SelectPendingTicketAsync(); } /// /// 按订单号查找历史工单。订单号在工单中作为唯一业务键使用。 /// public WorkOrderSummaryViewModel FindTicketByOrderId(string orderId) { if (string.IsNullOrWhiteSpace(orderId)) return null; var target = orderId.Trim(); return Tickets.FirstOrDefault(ticket => string.Equals(ticket.WechatOrderId, target, StringComparison.Ordinal)); } public void Dispose() { if (_disposed) return; SaveWorkOrderList(); _disposed = true; _ticketService.NotificationReceived -= OnNotificationReceived; _ticketService.StateChanged -= OnRealtimeStateChanged; _ticketService.Dispose(); } private void OpenNewDraft() { if (SelectedOrder == null) { SetNotice("未提供关联订单,无法新建工单。", true); return; } IsDraft = true; SelectedTicket = null; Messages.Clear(); HasMoreMessages = false; _nextBefore = null; DraftText = string.Empty; _pendingCreateFingerprint = null; _pendingCreateRequestId = null; ClearNotice(); } /// /// 取消未提交的新工单。点击其他工单或开始搜索时会调用。 /// private void CancelDraft() { if (!IsDraft) return; IsDraft = false; SelectedOrder = null; DraftText = string.Empty; _pendingCreateFingerprint = null; _pendingCreateRequestId = null; ClearNotice(); } private async Task SelectPendingTicketAsync() { var key = _pendingOpenKey; if (string.IsNullOrWhiteSpace(key) || IsBusy) return; try { _pendingOpenKey = null; var ticket = FindTicket(key); if (ticket == null) { var parts = key.Split(new[] { '|' }, 2); if (parts.Length != 2) return; await RefreshSingleWorkOrderAsync(parts[0], parts[1]); ticket = FindTicket(key); } if (ticket != null) { IsDraft = false; await SelectTicketAsync(ticket); } } catch (Exception ex) { SetNotice("打开指定工单失败:" + DescribeException(ex), true); } } private async Task SelectTicketAsync(WorkOrderSummaryViewModel ticket) { if (ticket == null || IsBusy) return; TicketSearchText = string.Empty; CancelDraft(); ApplyKnownProductInfo(ticket); SelectedTicket = ticket; await LoadMessagesAsync(ticket, replace: true); } private async Task CreateTicketAsync() { if (!CanCreateTicket()) return; var description = (DraftText ?? string.Empty).Trim(); var fingerprint = SelectedOrder.OrderKey + "|" + description; if (!string.Equals( fingerprint, _pendingCreateFingerprint, StringComparison.Ordinal)) { _pendingCreateFingerprint = fingerprint; _pendingCreateRequestId = NewOperationId("create"); } IsBusy = true; ClearNotice(); try { var result = await _ticketService.CreateWorkOrderAsync( _options, SelectedOrder, description, null); var operation = (result.Operation ?? string.Empty).ToLowerInvariant(); var isExisting = operation == "existing"; var targetKey = result.AppId + "|" + result.WechatOrderId; if (isExisting) { ChatText = description; SetNotice( "该订单已有未完结工单,服务端未重复追加描述。已打开原工单,请点击“发送”提交本次补充。", false); } else if (operation == "reopened") { SetNotice("原工单已重新开启,本次问题已加入留言板。", false); } else { SetNotice("工单已提交,等待客服回复。", false); } _pendingCreateFingerprint = null; _pendingCreateRequestId = null; DraftText = string.Empty; IsDraft = false; await RefreshAllWorkOrdersAsync( loadSelectedMessages: false, preferredKey: targetKey); var target = Tickets.FirstOrDefault(t => string.Equals(t.Key, targetKey, StringComparison.Ordinal)); if (target != null) { SelectedTicket = target; await LoadMessagesAsync(target, replace: true); } } catch (Exception ex) { SetNotice(DescribeException(ex), true); } finally { IsBusy = false; } } private async Task SendMessageAsync() { if (!CanSendMessage()) return; var ticket = SelectedTicket; var text = (ChatText ?? string.Empty).Trim(); var fingerprint = ticket.Key + "|" + text; if (!string.Equals( fingerprint, _pendingMessageFingerprint, StringComparison.Ordinal)) { _pendingMessageFingerprint = fingerprint; _pendingMessageId = NewOperationId("send"); } IsBusy = true; ClearNotice(); try { var result = await _ticketService.SendMessageAsync( _options, ToSummaryDto(ticket), text, _pendingMessageId); if (result.Message != null) AppendMessage(result.Message); ticket.ApplySendResult(result.Status, result.Version); MoveTicketToTop(ticket); ChatText = string.Empty; _pendingMessageFingerprint = null; _pendingMessageId = null; SetNotice( result.Reopened ? "留言已发送,已完结工单已重新开启。" : "留言已发送。", false); await MarkReadBestEffortAsync(ticket); SaveWorkOrderList(); MessageScrollVersion++; } catch (TicketApiException ex) when (ex.StatusCode == 409) { SetNotice("工单状态或数据版本发生冲突,正在刷新最新内容。", true); _pendingMessageFingerprint = null; _pendingMessageId = null; await RefreshAllWorkOrdersAsync( loadSelectedMessages: false, preferredKey: ticket.Key); var refreshed = Tickets.FirstOrDefault(t => string.Equals(t.Key, ticket.Key, StringComparison.Ordinal)); if (refreshed != null) { SelectedTicket = refreshed; await LoadMessagesAsync(refreshed, replace: true); } } catch (Exception ex) { SetNotice(DescribeException(ex), true); } finally { IsBusy = false; } } private async Task LoadEarlierMessagesAsync() { var ticket = SelectedTicket; if (ticket == null || !HasMoreMessages || string.IsNullOrWhiteSpace(_nextBefore)) return; IsBusy = true; try { var response = await _ticketService.GetMessagesAsync( _options, ticket.AppId, ticket.WechatOrderId, _nextBefore, 50); MergeMessages(response.Items ?? new List()); HasMoreMessages = response.HasMore; _nextBefore = response.NextBefore; } catch (Exception ex) { SetNotice(DescribeException(ex), true); } finally { IsBusy = false; } } private async Task RestartRealtimeAsync() { if (!_configured) return; IsBusy = true; try { await _ticketService.RestartRealtimeAsync(_options); await RefreshAllWorkOrdersAsync(loadSelectedMessages: false); if (SelectedTicket != null && !IsDraft) await LoadMessagesAsync(SelectedTicket, replace: true); ClearNotice(); } catch (Exception ex) { SetNotice(DescribeException(ex), true); } finally { IsBusy = false; } } private async Task LoadMessagesAsync( WorkOrderSummaryViewModel ticket, bool replace) { var version = ++_selectionVersion; IsLoadingMessages = true; try { var response = await _ticketService.GetMessagesAsync( _options, ticket.AppId, ticket.WechatOrderId, null, 50); if (version != _selectionVersion || SelectedTicket == null || !string.Equals(ticket.Key, SelectedTicket.Key, StringComparison.Ordinal)) { return; } if (replace) Messages.Clear(); MergeMessages(response.Items ?? new List()); HasMoreMessages = response.HasMore; _nextBefore = response.NextBefore; await MarkReadBestEffortAsync(ticket); MessageScrollVersion++; } catch (Exception ex) { if (replace) Messages.Clear(); HasMoreMessages = false; _nextBefore = null; SetNotice(DescribeException(ex), true); } finally { IsLoadingMessages = false; } } private async Task RefreshAllWorkOrdersAsync( bool loadSelectedMessages, string preferredKey = null) { IsLoading = true; try { var incoming = new List(); string cursor = null; var page = 0; do { var response = await _ticketService.GetWorkOrdersAsync( _options, limit: 100, cursor: cursor); incoming.AddRange(response.Items ?? new List()); cursor = response.NextCursor; page++; if (!response.HasMore || string.IsNullOrWhiteSpace(cursor)) break; } while (page < 20); var currentKey = SelectedTicket?.Key; var existing = Tickets.ToDictionary(t => t.Key, StringComparer.Ordinal); var merged = new List(); foreach (var dto in incoming) { if (dto == null || string.IsNullOrWhiteSpace(dto.AppId) || string.IsNullOrWhiteSpace(dto.WechatOrderId)) { continue; } var key = dto.AppId + "|" + dto.WechatOrderId; var hasNewMessage = HasNewMessage(dto); if (existing.TryGetValue(key, out var item)) { if (!item.TryApply(dto)) continue; } else { item = new WorkOrderSummaryViewModel(dto); } if (hasNewMessage) item.MarkHasNewMessage(); ApplyKnownProductInfo(item); if (merged.All(t => !string.Equals(t.Key, item.Key, StringComparison.Ordinal))) merged.Add(item); } Tickets.Clear(); foreach (var item in merged.OrderByDescending(t => t.UpdatedAt)) Tickets.Add(item); var target = FindTicket(preferredKey) ?? FindTicket(currentKey) ?? Tickets.FirstOrDefault(); if (!IsDraft) SelectedTicket = target; SaveWorkOrderList(); if (loadSelectedMessages && SelectedTicket != null && !IsDraft) await LoadMessagesAsync(SelectedTicket, replace: true); } catch (Exception ex) { SetNotice(DescribeException(ex), true); } finally { IsLoading = false; } } private async Task RefreshSingleWorkOrderAsync(string appId, string wechatOrderId) { if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId)) return; var options = CloneOptions(_options); options.AppIds = new List { appId }; var response = await _ticketService.GetWorkOrdersAsync( options, wechatOrderId, limit: 1); var dto = response.Items?.FirstOrDefault(); if (dto == null) return; var key = dto.AppId + "|" + dto.WechatOrderId; var hasNewMessage = HasNewMessage(dto); var existing = FindTicket(key); if (existing == null) { existing = new WorkOrderSummaryViewModel(dto); Tickets.Add(existing); } else if (!existing.TryApply(dto)) { return; } if (hasNewMessage) existing.MarkHasNewMessage(); ApplyKnownProductInfo(existing); SaveWorkOrderList(); ReorderTickets(); if (SelectedTicket != null && string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal) && !IsDraft) { await LoadMessagesAsync(SelectedTicket, replace: true); } } private void MergeMessages(IEnumerable items) { var byId = Messages .GroupBy(m => m.MessageId) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); foreach (var dto in items) { if (dto == null) continue; var id = dto.MessageId; if (string.IsNullOrWhiteSpace(id)) id = "sequence:" + dto.SequenceNo; if (!byId.ContainsKey(id)) byId[id] = new ChatMessageViewModel(dto); } var ordered = byId.Values .OrderBy(m => m.SequenceNo) .ThenBy(m => m.CreatedAt) .ToList(); Messages.Clear(); foreach (var message in ordered) Messages.Add(message); } private void AppendMessage(TicketMessageDto dto) { if (dto == null) return; var id = string.IsNullOrWhiteSpace(dto.MessageId) ? "sequence:" + dto.SequenceNo : dto.MessageId; if (Messages.Any(m => string.Equals(m.MessageId, id, StringComparison.Ordinal))) return; var incoming = new ChatMessageViewModel(dto); var index = 0; while (index < Messages.Count && Messages[index].SequenceNo <= incoming.SequenceNo) { index++; } Messages.Insert(index, incoming); } private async Task MarkReadBestEffortAsync(WorkOrderSummaryViewModel ticket) { if (!_isWindowActive || ticket == null) return; var maxSequence = Messages.Count == 0 ? 0 : Messages.Max(m => m.SequenceNo); if (maxSequence <= 0) return; if (_lastReadSequences.TryGetValue(ticket.Key, out var lastRead) && maxSequence <= lastRead) { MarkTicketRead(ticket); return; } try { await _ticketService.MarkReadAsync( _options, ticket.AppId, ticket.WechatOrderId, maxSequence); if (!_isWindowActive) return; _lastReadSequences[ticket.Key] = maxSequence; MarkTicketRead(ticket); SaveWorkOrderList(); } catch { // 已读失败不影响留言展示;下次打开工单会重新尝试。 } } private async Task MarkSelectedTicketReadAsync() { var ticket = SelectedTicket; if (ticket == null || IsDraft) return; await MarkReadBestEffortAsync(ticket); } private static void MarkTicketRead(WorkOrderSummaryViewModel ticket) { ticket.MarkRead(); TicketRealtimeMonitor.Current.MarkRead( ticket.AppId, ticket.WechatOrderId); } private void OnNotificationReceived( object sender, TicketRealtimeNotificationEventArgs args) { var notification = args?.Notification; if (notification == null) return; _dispatcher.BeginInvoke(new Action(() => { _ = HandleNotificationAsync(notification); })); } private async Task HandleNotificationAsync(TicketRealtimeNotification notification) { if (_disposed || !_configured) return; if (!string.IsNullOrWhiteSpace(notification.EventId)) { if (!_seenEventIds.Add(notification.EventId)) return; if (_seenEventIds.Count > 5000) _seenEventIds.Clear(); } try { if (notification.IsResyncRequired) { await RefreshAllWorkOrdersAsync( loadSelectedMessages: SelectedTicket != null && !IsDraft); return; } if (string.IsNullOrWhiteSpace(notification.AppId) || string.IsNullOrWhiteSpace(notification.WechatOrderId)) { return; } await RefreshSingleWorkOrderAsync( notification.AppId, notification.WechatOrderId); } catch (Exception ex) { SetNotice("实时通知已收到,但刷新工单失败:" + DescribeException(ex), true); } } private void OnRealtimeStateChanged( object sender, TicketRealtimeStateChangedEventArgs args) { _dispatcher.BeginInvoke(new Action(() => { if (_disposed || args == null) return; SseState = args.State; SseStatusText = GetRealtimeStatusText(args.State); })); } private static string GetRealtimeStatusText(TicketRealtimeState state) { return state switch { TicketRealtimeState.Connected => "实时推送:已连接", TicketRealtimeState.Connecting => "实时推送:连接中…", TicketRealtimeState.Reconnecting => "实时推送:重连中…", _ => "实时推送:已断开" }; } private bool CanCreateTicket() { return !IsBusy && IsDraft && SelectedOrder != null && !string.IsNullOrWhiteSpace(DraftText) && DraftText.Trim().Length <= 2000; } private bool FilterTicket(object item) { if (!(item is WorkOrderSummaryViewModel ticket)) return false; var keyword = (TicketSearchText ?? string.Empty).Trim(); if (keyword.Length == 0) return true; return ContainsIgnoreCase(ticket.WechatOrderId, keyword) || ContainsIgnoreCase(ticket.ShopName, keyword) || ContainsIgnoreCase(ticket.ProductName, keyword) || ContainsIgnoreCase(ticket.StatusText, keyword) || ContainsIgnoreCase(ticket.PreviewText, keyword); } private static bool ContainsIgnoreCase(string source, string keyword) { return !string.IsNullOrWhiteSpace(source) && source.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0; } private bool CanSendMessage() { return !IsBusy && SelectedTicket != null && !string.IsNullOrWhiteSpace(ChatText) && ChatText.Trim().Length <= 4000; } private void RaiseSendCanExecuteChanged() { _sendMessageCommand.RaiseCanExecuteChanged(); } private void RaiseCreateCanExecuteChanged() { _createTicketCommand.RaiseCanExecuteChanged(); } private void RaiseLoadEarlierCanExecuteChanged() { _loadEarlierMessagesCommand.RaiseCanExecuteChanged(); } private void SetNotice(string text, bool isError) { NoticeText = text ?? string.Empty; IsNoticeError = isError; } private void ClearNotice() { NoticeText = string.Empty; IsNoticeError = false; } /// /// 记录订单商品信息,后续刷新工单列表时用于补齐列表和对话中的商品展示。 /// private void RememberOrderInfo(TicketOrderOption order) { if (order == null || string.IsNullOrWhiteSpace(order.OrderId) || _options == null) { return; } if (_options.Orders == null) _options.Orders = new List(); var existing = _options.Orders.FirstOrDefault(o => o != null && string.Equals(o.OrderId, order.OrderId, StringComparison.Ordinal) && (string.IsNullOrWhiteSpace(o.AppId) || string.IsNullOrWhiteSpace(order.AppId) || string.Equals(o.AppId, order.AppId, StringComparison.Ordinal))); if (existing == null) { _options.Orders.Add(order); return; } if (!string.IsNullOrWhiteSpace(order.GoodsName)) existing.GoodsName = order.GoodsName; if (!string.IsNullOrWhiteSpace(order.ImageUrl)) existing.ImageUrl = order.ImageUrl; if (order.Amount > 0) existing.Amount = order.Amount; if (order.OrderedAt != default) existing.OrderedAt = order.OrderedAt; } /// /// 依次使用本地缓存、已注入订单和订单列表快照补齐工单商品信息。 /// private void ApplyKnownProductInfo(WorkOrderSummaryViewModel ticket) { if (ticket == null) return; if (_savedWorkOrders.TryGetValue(ticket.WechatOrderId, out var saved) && saved != null) { ticket.ApplyProductInfo( saved.ProductName, saved.ProductImageUrl, saved.Amount); } var option = _options?.Orders?.FirstOrDefault(o => o != null && string.Equals(o.OrderId, ticket.WechatOrderId, StringComparison.Ordinal) && (string.IsNullOrWhiteSpace(o.AppId) || string.Equals(o.AppId, ticket.AppId, StringComparison.Ordinal))); if (option != null) { ticket.ApplyProductInfo( option.GoodsName, option.ImageUrl, option.Amount); } if (!string.IsNullOrWhiteSpace(ticket.ProductName) && !string.IsNullOrWhiteSpace(ticket.ProductImageUrl) && ticket.ProductAmount.HasValue) { return; } var localOrder = FindLocalOrder(ticket.WechatOrderId); if (localOrder == null) return; var localOption = TicketOrderOption.FromWechatOrder(localOrder, ticket.AppId); if (localOption == null) return; ticket.ApplyProductInfo( localOption.GoodsName, localOption.ImageUrl, localOption.Amount); RememberOrderInfo(localOption); } /// /// 从已加载的订单列表快照中查找商品信息,订单列表仍在加载时返回 null。 /// private static oms_wei_order_info FindLocalOrder(string orderId) { if (string.IsNullOrWhiteSpace(orderId)) return null; try { var orders = GlobalConfig.lstOrder; if (orders == null || orders.Count == 0) return null; var snapshot = new oms_wei_order_info[orders.Count]; orders.CopyTo(snapshot, 0); return snapshot.FirstOrDefault(o => o != null && string.Equals(o.order_id, orderId, StringComparison.Ordinal)); } catch { return null; } } private WorkOrderSummaryViewModel FindTicket(string key) { if (string.IsNullOrWhiteSpace(key)) return null; return Tickets.FirstOrDefault(t => string.Equals(t.Key, key, StringComparison.Ordinal)); } private void MoveTicketToTop(WorkOrderSummaryViewModel ticket) { var index = Tickets.IndexOf(ticket); if (index > 0) Tickets.Move(index, 0); } private void ReorderTickets() { var ordered = Tickets.OrderByDescending(t => t.UpdatedAt).ToList(); for (var target = 0; target < ordered.Count; target++) { var current = Tickets.IndexOf(ordered[target]); if (current != target) Tickets.Move(current, target); } } private static WorkOrderSummaryDto ToSummaryDto(WorkOrderSummaryViewModel ticket) { return new WorkOrderSummaryDto { AppId = ticket.AppId, WechatOrderId = ticket.WechatOrderId, Status = ticket.Status, Version = ticket.Version }; } private void LoadSavedOrderTimes() { _savedWorkOrders.Clear(); _hasSavedCache = false; if (string.IsNullOrWhiteSpace(_options?.UserName)) return; var cache = _cacheService.Load(_options.UserName); if (cache?.Items == null) return; foreach (var saved in cache.Items) { if (saved == null || string.IsNullOrWhiteSpace(saved.OrderId)) { continue; } _savedWorkOrders[saved.OrderId] = saved; } _hasSavedCache = true; } private void UpdateSavedWorkOrders() { _savedWorkOrders = Tickets .GroupBy(t => t.WechatOrderId, StringComparer.Ordinal) .ToDictionary( group => group.Key, group => ToCacheItem(group.First()), StringComparer.Ordinal); var newCount = Tickets.Count(ticket => ticket.HasNewMessage); HasNewData = newCount > 0; NewDataText = newCount > 0 ? $"有 {newCount} 个工单有新数据" : string.Empty; } private void SaveWorkOrderList() { if (string.IsNullOrWhiteSpace(_options?.UserName)) return; if (!_initialized && Tickets.Count == 0) return; UpdateSavedWorkOrders(); var cache = new WorkOrderListCache { UserName = _options.UserName, Items = _savedWorkOrders.Values .OrderByDescending(item => item.LastMessageAt) .ToList() }; try { _cacheService.Save(cache); _hasSavedCache = true; } catch { // 本地缓存写入失败不应影响工单接口和页面操作。 } } private bool HasNewMessage(WorkOrderSummaryDto dto) { if (dto == null || string.IsNullOrWhiteSpace(dto.WechatOrderId)) return false; if (!_savedWorkOrders.TryGetValue(dto.WechatOrderId, out var saved)) return _hasSavedCache; return WorkOrderCacheService.ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt; } private static WorkOrderListCacheItem ToCacheItem( WorkOrderSummaryViewModel ticket) { return new WorkOrderListCacheItem { OrderId = ticket.WechatOrderId, LastMessageAt = ticket.LastMessageAt, ProductName = ticket.ProductName, ProductImageUrl = ticket.ProductImageUrl, Amount = ticket.ProductAmount }; } private static string NewOperationId(string prefix) { return prefix + "-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "-" + Guid.NewGuid().ToString("N"); } private static TicketClientOptions CloneOptions(TicketClientOptions options) { return options?.Clone() ?? new TicketClientOptions(); } private static string DescribeException(Exception ex) { if (ex is TicketApiException apiException) { var hint = apiException.StatusCode switch { 400 => "请求格式错误。", 404 => "该 APPID 和订单下尚无工单。", 409 => "数据版本或幂等编号冲突。", 413 => "请求内容超过大小限制。", 422 => "参数或订单归属校验失败。", 429 => "请求过于频繁,请稍后重试。", 503 => "工单服务暂未开启或数据库繁忙。", _ => null }; return string.IsNullOrWhiteSpace(hint) ? apiException.Message : hint + " " + apiException.Message; } if (ex is TimeoutException) return ex.Message; if (ex is ArgumentException) return ex.Message; return "操作失败:" + ex.Message; } }