1389 lines
42 KiB
C#
1389 lines
42 KiB
C#
using NaiBa.Util.Helper;
|
|
using OrderSupportTicket.Models;
|
|
using OrderSupportTicket.Models.Api;
|
|
using OrderSupportTicket.Services;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Windows.Input;
|
|
using System.Windows.Threading;
|
|
using WPFCommon.Models;
|
|
using WxOrder.Framework.Commons;
|
|
|
|
namespace OrderSupportTicket.ViewModels;
|
|
|
|
/// <summary>
|
|
/// 工单页总 ViewModel,负责工单查询、留言操作、订单筛选和 SSE 刷新。
|
|
/// </summary>
|
|
public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
|
|
{
|
|
private const string SavedListConfigName = "OrderSupportTicket";
|
|
private readonly ITicketApiService _api;
|
|
private readonly ITicketRealtimeService _realtime;
|
|
private readonly Dispatcher _dispatcher;
|
|
private readonly DelegateCommand _newTicketCommand;
|
|
private readonly DelegateCommand _clearOrderFilterCommand;
|
|
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 WorkOrderSummaryViewModel _selectedTicket;
|
|
private TicketOrderOption _selectedOrder;
|
|
private string _chatText;
|
|
private string _draftText;
|
|
private string _orderFilterText = 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 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(
|
|
ITicketApiService api,
|
|
ITicketRealtimeService realtime,
|
|
TicketClientOptions options = null)
|
|
{
|
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
|
_realtime = realtime ?? throw new ArgumentNullException(nameof(realtime));
|
|
_dispatcher = System.Windows.Application.Current?.Dispatcher
|
|
?? Dispatcher.CurrentDispatcher;
|
|
|
|
Tickets = new ObservableCollection<WorkOrderSummaryViewModel>();
|
|
Messages = new ObservableCollection<ChatMessageViewModel>();
|
|
AvailableOrders = new ObservableCollection<TicketOrderOption>();
|
|
FilteredOrders = new ObservableCollection<TicketOrderOption>();
|
|
|
|
_newTicketCommand = new DelegateCommand(OpenNewDraft, () => !IsBusy);
|
|
_clearOrderFilterCommand = new DelegateCommand(
|
|
() => OrderFilterText = string.Empty,
|
|
() => !string.IsNullOrWhiteSpace(OrderFilterText));
|
|
_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);
|
|
|
|
_realtime.NotificationReceived += OnNotificationReceived;
|
|
_realtime.StateChanged += OnRealtimeStateChanged;
|
|
SseState = TicketRealtimeState.Disconnected;
|
|
SseStatusText = "实时推送:未连接";
|
|
|
|
if (options != null)
|
|
Configure(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前账号和 APPID 下的历史工单。
|
|
/// </summary>
|
|
public ObservableCollection<WorkOrderSummaryViewModel> Tickets { get; }
|
|
|
|
/// <summary>
|
|
/// 当前选中工单的公开留言。
|
|
/// </summary>
|
|
public ObservableCollection<ChatMessageViewModel> Messages { get; }
|
|
|
|
/// <summary>
|
|
/// 宿主注入的全部可关联订单。
|
|
/// </summary>
|
|
public ObservableCollection<TicketOrderOption> AvailableOrders { get; }
|
|
|
|
/// <summary>
|
|
/// 经过订单号或商品名筛选后的关联订单。
|
|
/// </summary>
|
|
public ObservableCollection<TicketOrderOption> FilteredOrders { get; }
|
|
|
|
/// <summary>
|
|
/// 打开新建工单草稿的命令。
|
|
/// </summary>
|
|
public ICommand NewTicketCommand => _newTicketCommand;
|
|
|
|
/// <summary>
|
|
/// 清除关联订单筛选条件的命令。
|
|
/// </summary>
|
|
public ICommand ClearOrderFilterCommand => _clearOrderFilterCommand;
|
|
|
|
/// <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));
|
|
RaiseCreateCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 OrderFilterText
|
|
{
|
|
get => _orderFilterText;
|
|
set
|
|
{
|
|
if (SetValue(ref _orderFilterText, value ?? string.Empty))
|
|
{
|
|
RefreshFilteredOrders();
|
|
_clearOrderFilterCommand.RaiseCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
_newTicketCommand.RaiseCanExecuteChanged();
|
|
_retryRealtimeCommand.RaiseCanExecuteChanged();
|
|
_selectTicketCommand.RaiseCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// 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 bool HasAvailableOrders => AvailableOrders.Count > 0;
|
|
|
|
/// <summary>
|
|
/// 当前筛选结果中是否存在订单。
|
|
/// </summary>
|
|
public bool HasFilteredOrders => FilteredOrders.Count > 0;
|
|
|
|
/// <summary>
|
|
/// 关联订单总数或筛选结果数量文本。
|
|
/// </summary>
|
|
public string OrderFilterSummaryText =>
|
|
HasAvailableOrders
|
|
? (string.IsNullOrWhiteSpace(OrderFilterText)
|
|
? $"共 {FilteredOrders.Count} 个订单"
|
|
: $"匹配 {FilteredOrders.Count} 个订单")
|
|
: "暂无可关联订单";
|
|
|
|
/// <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 ?? "请选择关联订单";
|
|
|
|
public void Configure(TicketClientOptions options)
|
|
{
|
|
if (_disposed)
|
|
throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel));
|
|
|
|
_ = _realtime.StopAsync();
|
|
SseState = TicketRealtimeState.Disconnected;
|
|
SseStatusText = "实时推送:未连接";
|
|
_configured = false;
|
|
_initialized = false;
|
|
_options = CloneOptions(options);
|
|
HasNewData = false;
|
|
NewDataText = string.Empty;
|
|
|
|
IsDraft = false;
|
|
SelectedTicket = null;
|
|
Tickets.Clear();
|
|
Messages.Clear();
|
|
AvailableOrders.Clear();
|
|
foreach (var order in _options.Orders ?? Enumerable.Empty<TicketOrderOption>())
|
|
{
|
|
if (order != null)
|
|
AvailableOrders.Add(order);
|
|
}
|
|
OrderFilterText = string.Empty;
|
|
RefreshFilteredOrders();
|
|
RaisePropertyChanged(nameof(HasAvailableOrders));
|
|
|
|
if (_options.AppIds == null || _options.AppIds.Count == 0)
|
|
{
|
|
_options.AppIds = AvailableOrders
|
|
.Select(o => o.AppId)
|
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
LoadSavedOrderTimes();
|
|
|
|
try
|
|
{
|
|
TicketApiService.ValidateOptions(_options);
|
|
if (_options.AppIds.Count == 0)
|
|
throw new ArgumentException("appIds 至少需要一个 APPID。");
|
|
_configured = true;
|
|
NoticeText = string.Empty;
|
|
IsNoticeError = false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
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;
|
|
if (!_initialized || _realtime.State == TicketRealtimeState.Disconnected)
|
|
{
|
|
_realtime.Start(_options);
|
|
started = true;
|
|
}
|
|
|
|
if (!_initialized || started)
|
|
{
|
|
await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
|
|
_initialized = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetNotice(DescribeException(ex), true);
|
|
}
|
|
}
|
|
|
|
public async Task DeactivateAsync()
|
|
{
|
|
SaveWorkOrderList();
|
|
await _realtime.StopAsync();
|
|
}
|
|
|
|
public void OpenNewTicket(TicketOrderOption order)
|
|
{
|
|
if (order == null)
|
|
return;
|
|
if (!AvailableOrders.Any(o =>
|
|
string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal)))
|
|
{
|
|
AvailableOrders.Add(order);
|
|
RaisePropertyChanged(nameof(HasAvailableOrders));
|
|
}
|
|
OrderFilterText = string.Empty;
|
|
RefreshFilteredOrders();
|
|
SelectedOrder = AvailableOrders.First(o =>
|
|
string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal));
|
|
OpenNewDraft();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
SaveWorkOrderList();
|
|
_disposed = true;
|
|
_realtime.NotificationReceived -= OnNotificationReceived;
|
|
_realtime.StateChanged -= OnRealtimeStateChanged;
|
|
_realtime.Dispose();
|
|
}
|
|
|
|
private void OpenNewDraft()
|
|
{
|
|
IsDraft = true;
|
|
SelectedTicket = null;
|
|
Messages.Clear();
|
|
HasMoreMessages = false;
|
|
_nextBefore = null;
|
|
if (SelectedOrder == null)
|
|
SelectedOrder = FilteredOrders.FirstOrDefault();
|
|
if (AvailableOrders.Count == 0)
|
|
{
|
|
SetNotice(
|
|
"没有可关联的本地订单。请通过 TicketClientOptions.Orders 注入订单后再新建工单。",
|
|
true);
|
|
}
|
|
else if (FilteredOrders.Count == 0)
|
|
{
|
|
SetNotice(
|
|
"当前筛选条件下没有匹配订单,请调整订单号或商品名关键词。",
|
|
true);
|
|
}
|
|
else
|
|
{
|
|
ClearNotice();
|
|
}
|
|
}
|
|
|
|
private async Task SelectTicketAsync(WorkOrderSummaryViewModel ticket)
|
|
{
|
|
if (ticket == null || IsBusy)
|
|
return;
|
|
|
|
IsDraft = false;
|
|
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 _api.CreateWorkOrderAsync(
|
|
_options,
|
|
SelectedOrder,
|
|
description,
|
|
null);
|
|
|
|
var targetKey = result.AppId + "|" + result.WechatOrderId;
|
|
var operation = (result.Operation ?? string.Empty).ToLowerInvariant();
|
|
var isExisting = operation == "existing";
|
|
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 _api.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 _api.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 _realtime.StopAsync();
|
|
_realtime.Start(_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 _api.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 _api.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();
|
|
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 _api.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();
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
ticket.MarkRead();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _api.MarkReadAsync(
|
|
_options,
|
|
ticket.AppId,
|
|
ticket.WechatOrderId,
|
|
maxSequence);
|
|
_lastReadSequences[ticket.Key] = maxSequence;
|
|
ticket.MarkRead();
|
|
SaveWorkOrderList();
|
|
}
|
|
catch
|
|
{
|
|
// 已读失败不影响留言展示;下次打开工单会重新尝试。
|
|
}
|
|
}
|
|
|
|
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 = args.State switch
|
|
{
|
|
TicketRealtimeState.Connected => "实时推送:已连接",
|
|
TicketRealtimeState.Connecting => "实时推送:连接中…",
|
|
TicketRealtimeState.Reconnecting => "实时推送:重连中…",
|
|
_ => "实时推送:已断开"
|
|
};
|
|
}));
|
|
}
|
|
|
|
private bool CanCreateTicket()
|
|
{
|
|
return !IsBusy
|
|
&& IsDraft
|
|
&& SelectedOrder != null
|
|
&& !string.IsNullOrWhiteSpace(DraftText)
|
|
&& DraftText.Trim().Length <= 2000;
|
|
}
|
|
|
|
private void RefreshFilteredOrders()
|
|
{
|
|
var selectedKey = SelectedOrder?.OrderKey;
|
|
var keyword = (OrderFilterText ?? string.Empty).Trim();
|
|
var matches = string.IsNullOrWhiteSpace(keyword)
|
|
? AvailableOrders
|
|
: AvailableOrders.Where(order =>
|
|
ContainsIgnoreCase(order.OrderId, keyword)
|
|
|| ContainsIgnoreCase(order.GoodsName, keyword));
|
|
|
|
FilteredOrders.Clear();
|
|
foreach (var order in matches)
|
|
FilteredOrders.Add(order);
|
|
|
|
SelectedOrder = FilteredOrders.FirstOrDefault(order =>
|
|
string.Equals(
|
|
order.OrderKey,
|
|
selectedKey,
|
|
StringComparison.Ordinal))
|
|
?? FilteredOrders.FirstOrDefault();
|
|
|
|
RaisePropertyChanged(nameof(HasFilteredOrders));
|
|
RaisePropertyChanged(nameof(OrderFilterSummaryText));
|
|
}
|
|
|
|
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 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;
|
|
|
|
if (!File.Exists(ConfigHelper.GetConfigUserPath(SavedListConfigName)))
|
|
return;
|
|
|
|
WorkOrderListCache cache;
|
|
try
|
|
{
|
|
cache = ConfigHelper.LoadConfig<WorkOrderListCache>(SavedListConfigName);
|
|
}
|
|
catch
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (cache?.Items == null
|
|
|| !string.Equals(cache.UserName, _options.UserName, StringComparison.Ordinal))
|
|
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;
|
|
|
|
UpdateSavedWorkOrders();
|
|
var cache = new WorkOrderListCache
|
|
{
|
|
UserName = _options.UserName,
|
|
Items = _savedWorkOrders.Values
|
|
.OrderByDescending(item => item.LastMessageAt)
|
|
.ToList()
|
|
};
|
|
|
|
try
|
|
{
|
|
ConfigHelper.SaveConfig(SavedListConfigName, 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 ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt;
|
|
}
|
|
|
|
private static WorkOrderListCacheItem ToCacheItem(
|
|
WorkOrderSummaryViewModel ticket)
|
|
{
|
|
return new WorkOrderListCacheItem
|
|
{
|
|
OrderId = ticket.WechatOrderId,
|
|
LastMessageAt = ticket.LastMessageAt
|
|
};
|
|
}
|
|
|
|
private static DateTimeOffset ParseTimestamp(string value)
|
|
{
|
|
if (DateTimeOffset.TryParse(
|
|
value,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal,
|
|
out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
return default;
|
|
}
|
|
|
|
private static string NewOperationId(string prefix)
|
|
{
|
|
return prefix
|
|
+ "-"
|
|
+ DateTime.UtcNow.ToString("yyyyMMddHHmmss")
|
|
+ "-"
|
|
+ Guid.NewGuid().ToString("N");
|
|
}
|
|
|
|
private static TicketClientOptions CloneOptions(TicketClientOptions options)
|
|
{
|
|
if (options == null)
|
|
return new TicketClientOptions();
|
|
return new TicketClientOptions
|
|
{
|
|
UserName = options.UserName,
|
|
AppIds = (options.AppIds ?? Enumerable.Empty<string>())
|
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
|
.Select(a => a.Trim())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList(),
|
|
Orders = (options.Orders ?? Enumerable.Empty<TicketOrderOption>())
|
|
.Where(o => o != null)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|