1789 lines
54 KiB
C#
1789 lines
54 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 工单页总 ViewModel,负责工单查询、留言操作、新建工单和 SSE 刷新。
|
|
/// </summary>
|
|
public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IDisposable
|
|
{
|
|
/// <summary>
|
|
/// 新建工单弹窗中传入 <see cref="TicketOrderOption"/> 的参数名。
|
|
/// </summary>
|
|
public const string OrderParameterKey = "order";
|
|
|
|
private readonly TicketService _ticketService;
|
|
private readonly WorkOrderCacheService _cacheService = new WorkOrderCacheService();
|
|
private readonly Dispatcher _dispatcher;
|
|
private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand;
|
|
private readonly AsyncDelegateCommand _createTicketCommand;
|
|
private readonly AsyncDelegateCommand _sendMessageCommand;
|
|
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
|
|
private readonly AsyncDelegateCommand _retryRealtimeCommand;
|
|
private readonly HashSet<string> _seenEventIds =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, int> _lastReadSequences =
|
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
|
private Dictionary<string, WorkOrderListCacheItem> _savedWorkOrders =
|
|
new Dictionary<string, WorkOrderListCacheItem>(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<WorkOrderSummaryViewModel>();
|
|
Messages = new ObservableCollection<ChatMessageViewModel>();
|
|
TicketView = CollectionViewSource.GetDefaultView(Tickets);
|
|
TicketView.Filter = FilterTicket;
|
|
_selectTicketCommand = new AsyncDelegateCommand<WorkOrderSummaryViewModel>(
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前账号和 APPID 下的历史工单。
|
|
/// </summary>
|
|
public ObservableCollection<WorkOrderSummaryViewModel> Tickets { get; }
|
|
|
|
/// <summary>
|
|
/// 经过搜索条件过滤后的历史工单。
|
|
/// </summary>
|
|
public ICollectionView TicketView { get; }
|
|
|
|
/// <summary>
|
|
/// 当前选中工单的公开留言。
|
|
/// </summary>
|
|
public ObservableCollection<ChatMessageViewModel> Messages { get; }
|
|
|
|
/// <summary>
|
|
/// 选择并加载历史工单的命令。
|
|
/// </summary>
|
|
public ICommand SelectTicketCommand => _selectTicketCommand;
|
|
|
|
/// <summary>
|
|
/// 提交新工单的命令。
|
|
/// </summary>
|
|
public ICommand CreateTicketCommand => _createTicketCommand;
|
|
|
|
/// <summary>
|
|
/// 提交客户留言的命令。
|
|
/// </summary>
|
|
public ICommand SendMessageCommand => _sendMessageCommand;
|
|
|
|
/// <summary>
|
|
/// 加载当前工单更早留言的命令。
|
|
/// </summary>
|
|
public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand;
|
|
|
|
/// <summary>
|
|
/// 手动重新连接 SSE 的命令。
|
|
/// </summary>
|
|
public ICommand RetryRealtimeCommand => _retryRealtimeCommand;
|
|
|
|
/// <summary>
|
|
/// 当前正在查看的历史工单。
|
|
/// </summary>
|
|
public WorkOrderSummaryViewModel SelectedTicket
|
|
{
|
|
get => _selectedTicket;
|
|
private set
|
|
{
|
|
if (SetValue(ref _selectedTicket, value))
|
|
{
|
|
RaisePropertyChanged(nameof(IsChatVisible));
|
|
RaisePropertyChanged(nameof(IsEmptyVisible));
|
|
RaisePropertyChanged(nameof(ComposerPlaceholder));
|
|
RaiseSendCanExecuteChanged();
|
|
RaiseLoadEarlierCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 新建工单时选中的关联订单。
|
|
/// </summary>
|
|
public TicketOrderOption SelectedOrder
|
|
{
|
|
get => _selectedOrder;
|
|
set
|
|
{
|
|
if (SetValue(ref _selectedOrder, value))
|
|
{
|
|
RaisePropertyChanged(nameof(SelectedOrderDescription));
|
|
RaisePropertyChanged(nameof(SelectedOrderText));
|
|
RaiseCreateCanExecuteChanged();
|
|
_ = LoadSelectedOrderImageAsync(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前工单输入框中的留言文本。
|
|
/// </summary>
|
|
public string ChatText
|
|
{
|
|
get => _chatText;
|
|
set
|
|
{
|
|
if (SetValue(ref _chatText, value ?? string.Empty))
|
|
{
|
|
RaisePropertyChanged(nameof(ChatCharacterCountText));
|
|
RaiseSendCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 新建工单输入框中的问题描述。
|
|
/// </summary>
|
|
public string DraftText
|
|
{
|
|
get => _draftText;
|
|
set
|
|
{
|
|
if (SetValue(ref _draftText, value ?? string.Empty))
|
|
{
|
|
RaisePropertyChanged(nameof(DraftCharacterCountText));
|
|
RaiseCreateCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 历史工单搜索关键词,可匹配订单号、店铺、状态或最近动态。
|
|
/// </summary>
|
|
public string TicketSearchText
|
|
{
|
|
get => _ticketSearchText;
|
|
set
|
|
{
|
|
if (SetValue(ref _ticketSearchText, value ?? string.Empty))
|
|
{
|
|
if (IsDraft)
|
|
CancelDraft();
|
|
TicketView?.Refresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前是否处于新建工单草稿状态。
|
|
/// </summary>
|
|
public bool IsDraft
|
|
{
|
|
get => _isDraft;
|
|
private set
|
|
{
|
|
if (SetValue(ref _isDraft, value))
|
|
{
|
|
RaisePropertyChanged(nameof(IsChatVisible));
|
|
RaisePropertyChanged(nameof(IsEmptyVisible));
|
|
RaisePropertyChanged(nameof(IsDraftVisible));
|
|
RaiseCreateCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 是否正在执行创建、留言或刷新操作。
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 是否正在加载工单摘要列表。
|
|
/// </summary>
|
|
public bool IsLoading
|
|
{
|
|
get => _isLoading;
|
|
private set => SetValue(ref _isLoading, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 是否正在加载当前工单留言。
|
|
/// </summary>
|
|
public bool IsLoadingMessages
|
|
{
|
|
get => _isLoadingMessages;
|
|
private set => SetValue(ref _isLoadingMessages, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前留言板是否还有更早留言。
|
|
/// </summary>
|
|
public bool HasMoreMessages
|
|
{
|
|
get => _hasMoreMessages;
|
|
private set
|
|
{
|
|
if (SetValue(ref _hasMoreMessages, value))
|
|
RaiseLoadEarlierCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前操作提示或错误信息。
|
|
/// </summary>
|
|
public string NoticeText
|
|
{
|
|
get => _noticeText;
|
|
private set => SetValue(ref _noticeText, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前提示是否为错误类型。
|
|
/// </summary>
|
|
public bool IsNoticeError
|
|
{
|
|
get => _isNoticeError;
|
|
private set => SetValue(ref _isNoticeError, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前页面是否检测到服务器有新的工单消息。
|
|
/// </summary>
|
|
public bool HasNewData
|
|
{
|
|
get => _hasNewData;
|
|
private set => SetValue(ref _hasNewData, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 新数据提示文本。
|
|
/// </summary>
|
|
public string NewDataText
|
|
{
|
|
get => _newDataText;
|
|
private set => SetValue(ref _newDataText, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 工单页所在窗口当前是否处于激活且可见状态。
|
|
/// 未激活时只刷新数据,不把当前工单标记为已读。
|
|
/// </summary>
|
|
public bool IsWindowActive
|
|
{
|
|
get => _isWindowActive;
|
|
set
|
|
{
|
|
if (!SetValue(ref _isWindowActive, value))
|
|
return;
|
|
if (value && _initialized && !IsDraft && SelectedTicket != null)
|
|
_ = MarkSelectedTicketReadAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// SSE 当前连接状态。
|
|
/// </summary>
|
|
public TicketRealtimeState SseState
|
|
{
|
|
get => _sseState;
|
|
private set => SetValue(ref _sseState, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// SSE 当前状态的中文显示文本。
|
|
/// </summary>
|
|
public string SseStatusText
|
|
{
|
|
get => _sseStatusText;
|
|
private set => SetValue(ref _sseStatusText, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 触发消息列表滚动到底部的版本号。
|
|
/// </summary>
|
|
public int MessageScrollVersion
|
|
{
|
|
get => _messageScrollVersion;
|
|
private set => SetValue(ref _messageScrollVersion, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 是否显示当前工单的聊天面板。
|
|
/// </summary>
|
|
public bool IsChatVisible => !IsDraft && SelectedTicket != null;
|
|
|
|
/// <summary>
|
|
/// 是否显示新建工单面板。
|
|
/// </summary>
|
|
public bool IsDraftVisible => IsDraft;
|
|
|
|
/// <summary>
|
|
/// 是否显示未选择工单时的空状态。
|
|
/// </summary>
|
|
public bool IsEmptyVisible => !IsDraft && SelectedTicket == null;
|
|
|
|
/// <summary>
|
|
/// 当前是否没有执行中的操作。
|
|
/// </summary>
|
|
public bool IsNotBusy => !IsBusy;
|
|
|
|
/// <summary>
|
|
/// 留言输入框字数统计文本。
|
|
/// </summary>
|
|
public string ChatCharacterCountText => (ChatText ?? string.Empty).Length + " / 4000";
|
|
|
|
/// <summary>
|
|
/// 新建工单输入框字数统计文本。
|
|
/// </summary>
|
|
public string DraftCharacterCountText => (DraftText ?? string.Empty).Length + " / 2000";
|
|
|
|
/// <summary>
|
|
/// 当前工单输入框提示文本。
|
|
/// </summary>
|
|
public string ComposerPlaceholder =>
|
|
SelectedTicket != null && SelectedTicket.IsCompleted
|
|
? "工单已完结,继续留言将重新开启"
|
|
: "输入留言";
|
|
|
|
/// <summary>
|
|
/// 当前关联订单的商品、金额和下单时间说明。
|
|
/// </summary>
|
|
public string SelectedOrderDescription =>
|
|
SelectedOrder?.ProductDescription ?? "未提供关联订单";
|
|
|
|
/// <summary>
|
|
/// 当前关联订单的订单号和商品名摘要。
|
|
/// </summary>
|
|
public string SelectedOrderText =>
|
|
SelectedOrder?.DisplayText ?? "未提供关联订单";
|
|
|
|
/// <summary>
|
|
/// 当前关联订单的商品缩略图,加载完成前为空。
|
|
/// </summary>
|
|
public ImageSource SelectedOrderImage
|
|
{
|
|
get => _selectedOrderImage;
|
|
private set => SetValue(ref _selectedOrderImage, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 弹窗标题。
|
|
/// </summary>
|
|
public string Title => "客户工单";
|
|
|
|
/// <summary>
|
|
/// 由 DialogService 绑定,页面本身不主动请求关闭。
|
|
/// </summary>
|
|
#pragma warning disable CS0067
|
|
public event Action<IDialogResult> RequestClose;
|
|
#pragma warning restore CS0067
|
|
|
|
public bool CanCloseDialog()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public void OnDialogOpened(IDialogParameters parameters)
|
|
{
|
|
if (parameters == null)
|
|
return;
|
|
|
|
if (parameters.TryGetValue<TicketClientOptions>("options", out var options)
|
|
&& options != null)
|
|
{
|
|
Configure(options);
|
|
}
|
|
|
|
var order = GetOrderFromParameters(parameters);
|
|
if (order != null)
|
|
OpenNewTicket(order);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 从弹窗参数中取出需要新建工单的订单。优先使用订单对象,
|
|
/// 同时兼容按字段传入订单号、APPID 等信息的调用方式。
|
|
/// </summary>
|
|
private static TicketOrderOption GetOrderFromParameters(
|
|
IDialogParameters parameters)
|
|
{
|
|
foreach (var key in new[]
|
|
{
|
|
OrderParameterKey,
|
|
"ticketOrder",
|
|
"TicketOrderOption"
|
|
})
|
|
{
|
|
if (parameters.TryGetValue<TicketOrderOption>(key, out var order)
|
|
&& order != null)
|
|
{
|
|
return order;
|
|
}
|
|
}
|
|
|
|
if (parameters.TryGetValue<TicketClientOptions>("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<string>())
|
|
{
|
|
if (!parameters.TryGetValue<object>(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<string>(key, out var value)
|
|
&& !string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 后台下载关联订单的商品缩略图,避免阻塞界面;切换订单后丢弃过期结果。
|
|
/// </summary>
|
|
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()
|
|
{
|
|
// 工单页面由主窗口持有并复用,关闭弹窗时保留页面状态。
|
|
}
|
|
|
|
/// <summary>
|
|
/// 切换回历史工单页面。
|
|
/// </summary>
|
|
public void ShowHistory()
|
|
{
|
|
CancelDraft();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 取消尚未提交的新工单草稿。
|
|
/// </summary>
|
|
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<TicketOrderOption>())
|
|
.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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 订单列表异步加载完成后,将商品名称、图片和价格补充到对应工单。
|
|
/// </summary>
|
|
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<string>();
|
|
|
|
if (!_options.AppIds.Any(id =>
|
|
string.Equals(id, appId, StringComparison.Ordinal)))
|
|
{
|
|
_options.AppIds.Add(appId);
|
|
}
|
|
|
|
if (!_configured)
|
|
ApplyOptionsValidation();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 打开指定工单;页面尚未初始化时会在首次加载完成后自动定位。
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 按订单号查找历史工单。订单号在工单中作为唯一业务键使用。
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 取消未提交的新工单。点击其他工单或开始搜索时会调用。
|
|
/// </summary>
|
|
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<TicketMessageDto>());
|
|
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<TicketMessageDto>());
|
|
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<WorkOrderSummaryDto>();
|
|
string cursor = null;
|
|
var page = 0;
|
|
do
|
|
{
|
|
var response = await _ticketService.GetWorkOrdersAsync(
|
|
_options,
|
|
limit: 100,
|
|
cursor: cursor);
|
|
incoming.AddRange(response.Items ?? new List<WorkOrderSummaryDto>());
|
|
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<WorkOrderSummaryViewModel>();
|
|
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<string> { 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<TicketMessageDto> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 记录订单商品信息,后续刷新工单列表时用于补齐列表和对话中的商品展示。
|
|
/// </summary>
|
|
private void RememberOrderInfo(TicketOrderOption order)
|
|
{
|
|
if (order == null
|
|
|| string.IsNullOrWhiteSpace(order.OrderId)
|
|
|| _options == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_options.Orders == null)
|
|
_options.Orders = new List<TicketOrderOption>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 依次使用本地缓存、已注入订单和订单列表快照补齐工单商品信息。
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 从已加载的订单列表快照中查找商品信息,订单列表仍在加载时返回 null。
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|