工单 增加 接收服务端推送的新工单,支持 推送增加消息
This commit is contained in:
1 parent
f0f1f1ed9e
commit
6ed2cede16
11 files changed
+429
-205
No files matched your search
@@ -1,4 +1,4 @@
|
||||
using _29WxOrderWpf.BaseWindows;
|
||||
using _29WxOrderWpf.BaseWindows;
|
||||
using _29WxOrderWpf.Ctrls.BuyerCtrls;
|
||||
using _29WxOrderWpf.Ctrls.OrderCtrls;
|
||||
using _29WxOrderWpf.Ctrls.Settings;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AllBase;
|
||||
using AllBase;
|
||||
using AllBase.Model.DBModel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -13,63 +13,68 @@ namespace OrderSupportTicket.Models;
|
||||
/// </summary>
|
||||
public sealed class TicketClientOptions
|
||||
{
|
||||
public TicketClientOptions()
|
||||
{
|
||||
AppIds = new List<string>();
|
||||
Orders = new List<TicketOrderOption>();
|
||||
}
|
||||
public TicketClientOptions()
|
||||
{
|
||||
AppIds = new List<string>();
|
||||
Orders = new List<TicketOrderOption>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前拍单账号,对应接口字段 purchaseAccount。
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
/// <summary>
|
||||
/// 当前拍单账号,对应接口字段 purchaseAccount。
|
||||
/// </summary>
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。
|
||||
/// </summary>
|
||||
public IList<string> AppIds { get; set; }
|
||||
/// <summary>会话数据来源:直接修改此处默认值切换 History / Realtime。</summary>
|
||||
public TicketConversationMode ConversationMode { get; set; } = TicketConversationMode.Realtime;
|
||||
|
||||
/// <summary>
|
||||
/// 可关联到新工单的本地订单集合。
|
||||
/// </summary>
|
||||
public IList<TicketOrderOption> Orders { get; set; }
|
||||
/// <summary>
|
||||
/// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。
|
||||
/// </summary>
|
||||
public IList<string> AppIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建参数副本,避免宿主与页面共享可变集合。
|
||||
/// </summary>
|
||||
public TicketClientOptions Clone()
|
||||
{
|
||||
return new TicketClientOptions
|
||||
{
|
||||
UserName = UserName,
|
||||
AppIds = (AppIds ?? Enumerable.Empty<string>())
|
||||
.Where(appId => !string.IsNullOrWhiteSpace(appId))
|
||||
.Select(appId => appId.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
Orders = (Orders ?? Enumerable.Empty<TicketOrderOption>())
|
||||
.Where(order => order != null)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// 可关联到新工单的本地订单集合。
|
||||
/// </summary>
|
||||
public IList<TicketOrderOption> Orders { get; set; }
|
||||
|
||||
public static TicketClientOptions FromApplicationSettings()
|
||||
{
|
||||
var purchaseAccount =
|
||||
ConfigurationManager.AppSettings["CustomerWorkOrder.PurchaseAccount"];
|
||||
var appIds = ConfigurationManager.AppSettings["CustomerWorkOrder.AppIds"];
|
||||
/// <summary>
|
||||
/// 创建参数副本,避免宿主与页面共享可变集合。
|
||||
/// </summary>
|
||||
public TicketClientOptions Clone()
|
||||
{
|
||||
return new TicketClientOptions
|
||||
{
|
||||
UserName = UserName,
|
||||
ConversationMode = ConversationMode,
|
||||
AppIds = (AppIds ?? Enumerable.Empty<string>())
|
||||
.Where(appId => !string.IsNullOrWhiteSpace(appId))
|
||||
.Select(appId => appId.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
Orders = (Orders ?? Enumerable.Empty<TicketOrderOption>())
|
||||
.Where(order => order != null)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
return new TicketClientOptions
|
||||
{
|
||||
UserName = purchaseAccount,
|
||||
AppIds = (appIds ?? string.Empty)
|
||||
.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(a => a.Trim())
|
||||
.Where(a => a.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
public static TicketClientOptions FromApplicationSettings()
|
||||
{
|
||||
var purchaseAccount =
|
||||
ConfigurationManager.AppSettings["CustomerWorkOrder.PurchaseAccount"];
|
||||
var appIds = ConfigurationManager.AppSettings["CustomerWorkOrder.AppIds"];
|
||||
|
||||
return new TicketClientOptions
|
||||
{
|
||||
UserName = purchaseAccount,
|
||||
AppIds = (appIds ?? string.Empty)
|
||||
.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(a => a.Trim())
|
||||
.Where(a => a.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
ConversationMode = TicketConversationMode.Realtime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -78,101 +83,114 @@ public sealed class TicketClientOptions
|
||||
/// </summary>
|
||||
public sealed class TicketOrderOption
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。
|
||||
/// </summary>
|
||||
public string AppId { get; set; }
|
||||
/// <summary>
|
||||
/// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。
|
||||
/// </summary>
|
||||
public string AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。
|
||||
/// </summary>
|
||||
public string OrderId { get; set; }
|
||||
/// <summary>
|
||||
/// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。
|
||||
/// </summary>
|
||||
public string OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称,用于订单筛选和页面展示。
|
||||
/// </summary>
|
||||
public string GoodsName { get; set; }
|
||||
/// <summary>
|
||||
/// 商品名称,用于订单筛选和页面展示。
|
||||
/// </summary>
|
||||
public string GoodsName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品缩略图地址,用于新建工单页展示关联商品。
|
||||
/// </summary>
|
||||
public string ImageUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 商品缩略图地址,用于新建工单页展示关联商品。
|
||||
/// </summary>
|
||||
public string ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>
|
||||
/// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
|
||||
/// </summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单下单时间。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; set; }
|
||||
/// <summary>
|
||||
/// 订单下单时间。
|
||||
/// </summary>
|
||||
public DateTime OrderedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。
|
||||
/// </summary>
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var product = string.IsNullOrWhiteSpace(GoodsName)
|
||||
? "未命名商品"
|
||||
: GoodsName;
|
||||
return $"{OrderId} · {product}";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。
|
||||
/// </summary>
|
||||
public string DisplayText
|
||||
{
|
||||
get
|
||||
{
|
||||
var product = string.IsNullOrWhiteSpace(GoodsName)
|
||||
? "未命名商品"
|
||||
: GoodsName;
|
||||
return $"{OrderId} · {product}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订单详情展示文本。商品名已包含在 <see cref="DisplayText"/> 中,这里只展示金额和下单时间。
|
||||
/// </summary>
|
||||
public string ProductDescription
|
||||
{
|
||||
get
|
||||
{
|
||||
var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN"));
|
||||
var orderedAt = OrderedAt == default
|
||||
? "下单时间未知"
|
||||
: OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);
|
||||
return $"金额 {amount} · {orderedAt}";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 订单详情展示文本。商品名已包含在 <see cref="DisplayText"/> 中,这里只展示金额和下单时间。
|
||||
/// </summary>
|
||||
public string ProductDescription
|
||||
{
|
||||
get
|
||||
{
|
||||
var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN"));
|
||||
var orderedAt = OrderedAt == default
|
||||
? "下单时间未知"
|
||||
: OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);
|
||||
return $"金额 {amount} · {orderedAt}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。
|
||||
/// </summary>
|
||||
public static TicketOrderOption FromWechatOrder(
|
||||
oms_wei_order_info order,
|
||||
string appId)
|
||||
{
|
||||
if (order == null)
|
||||
return null;
|
||||
/// <summary>
|
||||
/// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。
|
||||
/// </summary>
|
||||
public static TicketOrderOption FromWechatOrder(
|
||||
oms_wei_order_info order,
|
||||
string appId)
|
||||
{
|
||||
if (order == null)
|
||||
return null;
|
||||
|
||||
var goodsName = string.Join(
|
||||
"、",
|
||||
(order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
|
||||
.Select(item => item.title)
|
||||
.Where(title => !string.IsNullOrWhiteSpace(title))
|
||||
.Distinct());
|
||||
if (goodsName.Length > 200)
|
||||
goodsName = goodsName.Substring(0, 200) + "…";
|
||||
var goodsName = string.Join(
|
||||
"、",
|
||||
(order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
|
||||
.Select(item => item.title)
|
||||
.Where(title => !string.IsNullOrWhiteSpace(title))
|
||||
.Distinct());
|
||||
if (goodsName.Length > 200)
|
||||
goodsName = goodsName.Substring(0, 200) + "…";
|
||||
|
||||
return new TicketOrderOption
|
||||
{
|
||||
AppId = appId?.Trim(),
|
||||
OrderId = order.order_id,
|
||||
GoodsName = goodsName,
|
||||
ImageUrl = (order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
|
||||
.Select(item => item.thumb_img)
|
||||
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
|
||||
Amount = (order.order_price ?? 0) / 100m,
|
||||
OrderedAt = order.create_time > 0
|
||||
? BaseClass.StampToDateTime(order.create_time)
|
||||
: default
|
||||
};
|
||||
}
|
||||
return new TicketOrderOption
|
||||
{
|
||||
AppId = appId?.Trim(),
|
||||
OrderId = order.order_id,
|
||||
GoodsName = goodsName,
|
||||
ImageUrl = (order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
|
||||
.Select(item => item.thumb_img)
|
||||
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
|
||||
Amount = (order.order_price ?? 0) / 100m,
|
||||
OrderedAt = order.create_time > 0
|
||||
? BaseClass.StampToDateTime(order.create_time)
|
||||
: default
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
|
||||
/// </summary>
|
||||
public string OrderKey => $"{AppId}|{OrderId}";
|
||||
/// <summary>
|
||||
/// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
|
||||
/// </summary>
|
||||
public string OrderKey => $"{AppId}|{OrderId}";
|
||||
}
|
||||
|
||||
/// <summary>History 点击加载历史;Realtime 仅使用推送和发送回执构建会话。</summary>
|
||||
public enum TicketConversationMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 点击加载历史
|
||||
/// </summary>
|
||||
History,
|
||||
/// <summary>
|
||||
/// 仅使用推送和发送回执构建会话
|
||||
/// </summary>
|
||||
Realtime
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
|
||||
namespace OrderSupportTicket.Models;
|
||||
|
||||
/// <summary>
|
||||
/// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。
|
||||
/// SSE 实时通知。包含工单摘要与完整消息,可直接增量构建会话。
|
||||
/// </summary>
|
||||
public sealed class TicketRealtimeNotification
|
||||
{
|
||||
@@ -49,6 +50,10 @@ public sealed class TicketRealtimeNotification
|
||||
////[JsonProperty("occurredAt")]
|
||||
public string OccurredAt { get; set; }
|
||||
|
||||
public WorkOrderSummaryDto Ticket { get; set; }
|
||||
|
||||
public TicketMessageDto Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否要求客户端执行全量重新同步。
|
||||
/// </summary>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Newtonsoft.Json;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
@@ -137,6 +138,7 @@ public sealed class EventSourceTicketRealtimeService
|
||||
TicketRealtimeNotification notification;
|
||||
try
|
||||
{
|
||||
Debug.WriteLine(data);
|
||||
notification = JsonConvert.DeserializeObject<TicketRealtimeNotification>(data, TicketApiService.JsonSettings);
|
||||
}
|
||||
catch (JsonException)
|
||||
|
||||
@@ -5,6 +5,7 @@ using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -128,7 +129,7 @@ public sealed class TicketApiService
|
||||
Limit = limit,
|
||||
Before = string.IsNullOrWhiteSpace(before) ? null : before
|
||||
};
|
||||
|
||||
Debug.WriteLine($"{wechatOrderId}加载所有对话");
|
||||
var path = ApiRoot
|
||||
+ "/messages";
|
||||
return await PostAsync<WorkOrderMessagesResponse>(path, request);
|
||||
@@ -456,7 +457,7 @@ public sealed class TicketApiService
|
||||
EnsureSuccessBody(html);
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<T>(html, JsonSettings);
|
||||
var result = JsonConvert.DeserializeObject<T>(html, JsonSettings);
|
||||
if (result == null)
|
||||
throw new JsonSerializationException("接口响应为空。");
|
||||
return result;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -109,6 +109,10 @@ public sealed class TicketRealtimeMonitor : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
_seenEventIds.Clear();
|
||||
}
|
||||
_options = options;
|
||||
_optionsFingerprint = fingerprint;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
if (dto == null)
|
||||
return;
|
||||
|
||||
SequenceNo = dto.SequenceNo;
|
||||
SenderType = dto.SenderType;
|
||||
|
||||
SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName)
|
||||
? (IsCustomer ? "我" : "客服")
|
||||
: dto.SenderName;
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private readonly AsyncDelegateCommand _sendMessageCommand;
|
||||
private readonly AsyncDelegateCommand<PendingAttachmentViewModel> _removePendingAttachmentCommand;
|
||||
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
|
||||
private readonly AsyncDelegateCommand _refreshMessagesCommand;
|
||||
private readonly AsyncDelegateCommand _retryRealtimeCommand;
|
||||
private readonly DelegateCommand<FrameworkElement> _addImagesCommand;
|
||||
private readonly HashSet<string> _seenEventIds =
|
||||
@@ -50,6 +51,12 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private Dictionary<string, WorkOrderListCacheItem> _savedWorkOrders =
|
||||
new Dictionary<string, WorkOrderListCacheItem>(StringComparer.Ordinal);
|
||||
// 按 APPID 和订单号隔离各工单消息,切换会话不丢失推送。
|
||||
private readonly Dictionary<string, Dictionary<string, TicketMessageDto>> _conversationMessages =
|
||||
new Dictionary<string, Dictionary<string, TicketMessageDto>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, WorkOrderMessagesResponse> _conversationPages = new Dictionary<string, WorkOrderMessagesResponse>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, Task> _conversationLoads = new Dictionary<string, Task>(StringComparer.Ordinal);
|
||||
private bool IsRealtimeConversation => _options?.ConversationMode == TicketConversationMode.Realtime;
|
||||
private bool _hasSavedCache;
|
||||
private TicketClientOptions _options;
|
||||
private bool _configured;
|
||||
@@ -114,6 +121,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
_loadEarlierMessagesCommand = new AsyncDelegateCommand(
|
||||
LoadEarlierMessagesAsync,
|
||||
() => !IsBusy && SelectedTicket != null && HasMoreMessages);
|
||||
_refreshMessagesCommand = new AsyncDelegateCommand(
|
||||
RefreshMessagesAsync,
|
||||
() => _configured && !IsBusy && !IsLoadingMessages && !IsDraft && SelectedTicket != null);
|
||||
_retryRealtimeCommand = new AsyncDelegateCommand(
|
||||
RestartRealtimeAsync,
|
||||
() => _configured);
|
||||
@@ -198,6 +208,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
/// </summary>
|
||||
public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand;
|
||||
|
||||
/// <summary>强制重新获取当前工单消息,两种会话模式均不使用缓存代替请求。</summary>
|
||||
public ICommand RefreshMessagesCommand => _refreshMessagesCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 手动重新连接 SSE 的命令。
|
||||
/// </summary>
|
||||
@@ -377,7 +390,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
public bool IsLoadingMessages
|
||||
{
|
||||
get => _isLoadingMessages;
|
||||
private set => SetValue(ref _isLoadingMessages, value);
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _isLoadingMessages, value))
|
||||
_refreshMessagesCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -865,6 +882,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
_initialized = false;
|
||||
_pendingOpenKey = null;
|
||||
_options = CloneOptions(options);
|
||||
_conversationMessages.Clear();
|
||||
_conversationPages.Clear();
|
||||
_conversationLoads.Clear();
|
||||
_seenEventIds.Clear();
|
||||
_lastReadSequences.Clear();
|
||||
HasNewData = false;
|
||||
NewDataText = string.Empty;
|
||||
|
||||
@@ -943,6 +965,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
if (!_initialized || started)
|
||||
{
|
||||
// 会话模式不改变初始化列表和历史对话加载流程。
|
||||
await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
|
||||
_initialized = true;
|
||||
}
|
||||
@@ -1272,7 +1295,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
receipts);
|
||||
|
||||
if (result.Message != null)
|
||||
AppendMessage(result.Message);
|
||||
{
|
||||
RememberMessage(ticket.Key, result.Message);
|
||||
MergeMessages(new[] { result.Message });
|
||||
}
|
||||
ticket.ApplySendResult(result.Status, result.Version);
|
||||
MoveTicketToTop(ticket);
|
||||
TicketView.Refresh();
|
||||
@@ -1337,6 +1363,36 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshMessagesAsync()
|
||||
{
|
||||
var ticket = SelectedTicket;
|
||||
if (!_configured || IsBusy || IsLoadingMessages || IsDraft || ticket == null)
|
||||
return;
|
||||
|
||||
var options = _options;
|
||||
IsBusy = true;
|
||||
IsLoadingMessages = true;
|
||||
ClearNotice();
|
||||
try
|
||||
{
|
||||
await FetchConversationAsync(ticket.AppId, ticket.WechatOrderId, force: true);
|
||||
if (_disposed || !ReferenceEquals(options, _options)
|
||||
|| IsDraft || SelectedTicket?.Key != ticket.Key)
|
||||
return;
|
||||
ShowCachedConversation(SelectedTicket);
|
||||
await MarkReadBestEffortAsync(SelectedTicket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetNotice("刷新工单消息失败:" + DescribeException(ex), true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingMessages = false;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadEarlierMessagesAsync()
|
||||
{
|
||||
var ticket = SelectedTicket;
|
||||
@@ -1352,6 +1408,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
ticket.WechatOrderId,
|
||||
_nextBefore,
|
||||
50);
|
||||
foreach (var message in response.Items ?? new List<TicketMessageDto>())
|
||||
RememberMessage(ticket.Key, message);
|
||||
_conversationPages[ticket.Key] = response;
|
||||
MergeMessages(response.Items ?? new List<TicketMessageDto>());
|
||||
HasMoreMessages = response.HasMore;
|
||||
_nextBefore = response.NextBefore;
|
||||
@@ -1398,41 +1457,82 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
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))
|
||||
{
|
||||
// 点击工单始终可读取历史,Realtime 模式复用已加载的会话。
|
||||
if (!IsRealtimeConversation || !_conversationMessages.ContainsKey(ticket.Key))
|
||||
await FetchConversationAsync(ticket.AppId, ticket.WechatOrderId, force: !IsRealtimeConversation);
|
||||
if (version != _selectionVersion || SelectedTicket?.Key != ticket.Key || IsDraft)
|
||||
return;
|
||||
}
|
||||
|
||||
if (replace)
|
||||
Messages.Clear();
|
||||
MergeMessages(response.Items ?? new List<TicketMessageDto>());
|
||||
HasMoreMessages = response.HasMore;
|
||||
_nextBefore = response.NextBefore;
|
||||
ShowCachedConversation(ticket);
|
||||
await MarkReadBestEffortAsync(ticket);
|
||||
MessageScrollVersion++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (replace)
|
||||
Messages.Clear();
|
||||
HasMoreMessages = false;
|
||||
_nextBefore = null;
|
||||
SetNotice(DescribeException(ex), true);
|
||||
if (version == _selectionVersion)
|
||||
{
|
||||
if (replace) Messages.Clear();
|
||||
HasMoreMessages = false;
|
||||
_nextBefore = null;
|
||||
SetNotice(DescribeException(ex), true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingMessages = false;
|
||||
if (version == _selectionVersion)
|
||||
IsLoadingMessages = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchConversationAsync(string appId, string orderId, bool force = false)
|
||||
{
|
||||
var key = appId + "|" + orderId;
|
||||
if (_conversationLoads.TryGetValue(key, out var loading))
|
||||
{
|
||||
await loading;
|
||||
if (!force) return;
|
||||
}
|
||||
var task = FetchConversationCoreAsync(appId, orderId);
|
||||
_conversationLoads[key] = task;
|
||||
try { await task; }
|
||||
finally
|
||||
{
|
||||
if (_conversationLoads.TryGetValue(key, out var current) && ReferenceEquals(current, task))
|
||||
_conversationLoads.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchConversationCoreAsync(string appId, string orderId)
|
||||
{
|
||||
var options = _options;
|
||||
var key = appId + "|" + orderId;
|
||||
var before = _conversationMessages.TryGetValue(key, out var existing)
|
||||
? new Dictionary<string, TicketMessageDto>(existing, StringComparer.Ordinal) : null;
|
||||
var response = await _ticketService.GetMessagesAsync(options, appId, orderId, null, 50);
|
||||
if (_disposed || !ReferenceEquals(options, _options)) return;
|
||||
// 刷新历史期间收到的增量不能被较早的接口快照覆盖。
|
||||
var addedDuringLoad = before != null && _conversationMessages.TryGetValue(key, out var latest)
|
||||
? latest.Where(pair => !before.TryGetValue(pair.Key, out var old)
|
||||
|| !ReferenceEquals(old, pair.Value)).Select(pair => pair.Value).ToList()
|
||||
: new List<TicketMessageDto>();
|
||||
// 只在历史请求成功后建立缓存;首次推送触发的历史已包含该消息,不再追加。
|
||||
_conversationMessages[key] = new Dictionary<string, TicketMessageDto>(StringComparer.Ordinal);
|
||||
foreach (var message in response.Items ?? new List<TicketMessageDto>())
|
||||
RememberMessage(key, message);
|
||||
foreach (var message in addedDuringLoad)
|
||||
RememberMessage(key, message);
|
||||
_conversationPages[key] = response;
|
||||
}
|
||||
|
||||
private void ShowCachedConversation(WorkOrderSummaryViewModel ticket)
|
||||
{
|
||||
Messages.Clear();
|
||||
if (_conversationMessages.TryGetValue(ticket.Key, out var cached))
|
||||
MergeMessages(cached.Values);
|
||||
_conversationPages.TryGetValue(ticket.Key, out var page);
|
||||
HasMoreMessages = page?.HasMore ?? false;
|
||||
_nextBefore = page?.NextBefore;
|
||||
MessageScrollVersion++;
|
||||
}
|
||||
|
||||
private async Task RefreshAllWorkOrdersAsync(
|
||||
bool loadSelectedMessages,
|
||||
string preferredKey = null)
|
||||
@@ -1471,8 +1571,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
var hasNewMessage = HasNewMessage(dto);
|
||||
if (existing.TryGetValue(key, out var item))
|
||||
{
|
||||
if (!item.TryApply(dto))
|
||||
continue;
|
||||
// 查询期间可能先收到更新的 SSE,保留较新摘要,不能丢掉该工单。
|
||||
item.TryApply(dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1498,7 +1598,15 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
SaveWorkOrderList();
|
||||
|
||||
if (loadSelectedMessages && SelectedTicket != null && !IsDraft)
|
||||
await LoadMessagesAsync(SelectedTicket, replace: true);
|
||||
{
|
||||
var selected = SelectedTicket;
|
||||
await FetchConversationAsync(selected.AppId, selected.WechatOrderId, force: true);
|
||||
if (!IsDraft && SelectedTicket?.Key == selected.Key)
|
||||
{
|
||||
ShowCachedConversation(selected);
|
||||
await MarkReadBestEffortAsync(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1579,26 +1687,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
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)
|
||||
@@ -1680,21 +1768,33 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
try
|
||||
{
|
||||
if (notification.IsResyncRequired)
|
||||
if (notification.IsResyncRequired
|
||||
|| string.Equals(notification.Type, "relay.ticket.created", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await RefreshAllWorkOrdersAsync(
|
||||
loadSelectedMessages: SelectedTicket != null && !IsDraft);
|
||||
// 刷新后仍恢复当前工单,不能自动切换到新工单。
|
||||
await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
|
||||
var created = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
|
||||
if (created != null && !notification.IsResyncRequired) MoveTicketToTop(created);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.AppId)
|
||||
|| string.IsNullOrWhiteSpace(notification.WechatOrderId))
|
||||
|| string.IsNullOrWhiteSpace(notification.WechatOrderId)
|
||||
|| !_options.AppIds.Contains(notification.AppId)) return;
|
||||
|
||||
if (IsRealtimeConversation)
|
||||
await ApplyRealtimeNotificationAsync(notification);
|
||||
else
|
||||
{
|
||||
return;
|
||||
// History 保持原有查询摘要、刷新当前对话的流程。
|
||||
var ticket = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
|
||||
var unreadCount = (ticket?.UnreadCount ?? 0) + 1;
|
||||
ticket?.EnsureUnreadCount(unreadCount);
|
||||
await RefreshSingleWorkOrderAsync(notification.AppId, notification.WechatOrderId);
|
||||
ticket = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
|
||||
if (ticket != null && (SelectedTicket?.Key != ticket.Key || !_isWindowActive))
|
||||
ticket.EnsureUnreadCount(unreadCount);
|
||||
SaveWorkOrderList();
|
||||
}
|
||||
await RefreshSingleWorkOrderAsync(
|
||||
notification.AppId,
|
||||
notification.WechatOrderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1702,6 +1802,64 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
}
|
||||
}
|
||||
|
||||
private void RememberMessage(string key, TicketMessageDto message, bool preserveExisting = false)
|
||||
{
|
||||
if (message == null)
|
||||
return;
|
||||
if (!_conversationMessages.TryGetValue(key, out var messages))
|
||||
_conversationMessages[key] = messages = new Dictionary<string, TicketMessageDto>(StringComparer.Ordinal);
|
||||
var id = string.IsNullOrWhiteSpace(message.MessageId)
|
||||
? "sequence:" + message.SequenceNo : message.MessageId;
|
||||
if (!preserveExisting || !messages.ContainsKey(id))
|
||||
messages[id] = message;
|
||||
}
|
||||
|
||||
private async Task ApplyRealtimeNotificationAsync(TicketRealtimeNotification notification)
|
||||
{
|
||||
var key = notification.AppId + "|" + notification.WechatOrderId;
|
||||
var ticket = FindTicket(key);
|
||||
// 未加载过会话:获取历史,推送消息不再额外插入。
|
||||
if (!_conversationMessages.TryGetValue(key, out var cached))
|
||||
{
|
||||
await FetchConversationAsync(notification.AppId, notification.WechatOrderId);
|
||||
ticket?.MarkHasNewMessage();
|
||||
if (!IsDraft && SelectedTicket?.Key == key)
|
||||
{
|
||||
ShowCachedConversation(SelectedTicket);
|
||||
await MarkReadBestEffortAsync(SelectedTicket);
|
||||
}
|
||||
SaveWorkOrderList();
|
||||
return;
|
||||
}
|
||||
var message = notification.Message;
|
||||
if (message == null) return;
|
||||
var id = string.IsNullOrWhiteSpace(message.MessageId) ? "sequence:" + message.SequenceNo : message.MessageId;
|
||||
var isNew = !cached.ContainsKey(id);
|
||||
RememberMessage(key, message);
|
||||
if (notification.Ticket != null && ticket != null)
|
||||
{
|
||||
var dto = notification.Ticket;
|
||||
dto.AppId = notification.AppId;
|
||||
dto.WechatOrderId = notification.WechatOrderId;
|
||||
dto.Version = dto.Version ?? notification.Version;
|
||||
dto.LastMessageAt = message.CreatedAt;
|
||||
dto.LastSenderType = message.SenderType;
|
||||
dto.UnreadCount = ticket.UnreadCount;
|
||||
ticket.TryApply(dto);
|
||||
}
|
||||
if (isNew) ticket?.IncrementUnreadCount();
|
||||
if (!IsDraft && SelectedTicket?.Key == key)
|
||||
{
|
||||
var existing = Messages.FirstOrDefault(m => m.MessageId == id);
|
||||
if (existing != null) existing.UpdateFrom(message);
|
||||
else Messages.Add(new ChatMessageViewModel(message));
|
||||
MessageScrollVersion++;
|
||||
await MarkReadBestEffortAsync(SelectedTicket);
|
||||
}
|
||||
TicketView.Refresh();
|
||||
SaveWorkOrderList();
|
||||
}
|
||||
|
||||
private void OnRealtimeStateChanged(
|
||||
object sender,
|
||||
TicketRealtimeStateChangedEventArgs args)
|
||||
@@ -1801,6 +1959,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private void RaiseLoadEarlierCanExecuteChanged()
|
||||
{
|
||||
_loadEarlierMessagesCommand.RaiseCanExecuteChanged();
|
||||
_refreshMessagesCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void SetNotice(string text, bool isError)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AllBase.Helper;
|
||||
using AllBase.Helper;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
using System;
|
||||
@@ -296,6 +296,8 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
|
||||
|
||||
public void ApplySendResult(string status, string version)
|
||||
{
|
||||
if (!IsAtLeastCurrentVersion(version))
|
||||
return;
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
Status = status;
|
||||
@@ -309,6 +311,18 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
|
||||
HasNewMessage = false;
|
||||
}
|
||||
|
||||
public void EnsureUnreadCount(int count)
|
||||
{
|
||||
UnreadCount = Math.Max(UnreadCount, count);
|
||||
if (UnreadCount > 0) HasNewMessage = true;
|
||||
}
|
||||
|
||||
public void IncrementUnreadCount()
|
||||
{
|
||||
UnreadCount++;
|
||||
HasNewMessage = true;
|
||||
}
|
||||
|
||||
public void MarkRead()
|
||||
{
|
||||
UnreadCount = 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Services;
|
||||
using OrderSupportTicket.ViewModels;
|
||||
using System;
|
||||
@@ -39,6 +39,7 @@ public partial class OrderSupportTicketView : UserControl
|
||||
return new TicketClientOptions
|
||||
{
|
||||
UserName = userName,
|
||||
ConversationMode = configured.ConversationMode,
|
||||
AppIds = appIds,
|
||||
Orders =
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl
|
||||
<UserControl
|
||||
x:Class="OrderSupportTicket.Views.TicketConversationView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
@@ -31,6 +31,7 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
@@ -76,6 +77,22 @@
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Margin="8,0,0,0"
|
||||
Padding="5"
|
||||
VerticalAlignment="Top"
|
||||
AutomationProperties.Name="刷新当前工单消息"
|
||||
Command="{Binding RefreshMessagesCommand}"
|
||||
Style="{StaticResource IconButtonStyle}"
|
||||
ToolTip="刷新当前工单消息">
|
||||
<TextBlock
|
||||
FontSize="18"
|
||||
Style="{StaticResource TextBlockIcon}"
|
||||
Text="{StaticResource txt-arrow_clockwise}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="1">
|
||||
|
||||
Reference in new issue
Block a user