Files
29WxOrder/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
T
2026-10-19 14:35:25 +08:00

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;
}
}