修复工单Bug

This commit is contained in:
chfei committed 2026-10-19 14:35:25 +08:00
1 parent 7982f7e348
commit 8e0cc20649
12 files changed
+602 -236

No files matched your search

+40 -17
View File
@@ -1,4 +1,4 @@
using _29WxOrderWpf.BaseWindows;
using _29WxOrderWpf.BaseWindows;
using _29WxOrderWpf.Ctrls.BuyerCtrls;
using _29WxOrderWpf.Ctrls.OrderCtrls;
using _29WxOrderWpf.Ctrls.Settings;
@@ -59,6 +59,7 @@ namespace _29WxOrderWpf
public static MainWindow Instance { get; private set; }
private readonly TicketRealtimeMonitor _ticketMonitor = TicketRealtimeMonitor.Current;
private readonly WorkOrderUnreadService _workOrderUnreadService = new WorkOrderUnreadService();
private TicketClientOptions _ticketOptions;
private OrderSupportTicketView _orderSupportView;
@@ -597,8 +598,10 @@ namespace _29WxOrderWpf
{
try
{
_ticketMonitor.Start(GetTicketOptions().Clone());
var options = GetTicketOptions().Clone();
_ticketMonitor.Start(options);
UpdateOrderSupportBadge();
_ = LoadHistoricalTicketUnreadAsync(options);
}
catch (Exception ex)
{
@@ -606,6 +609,23 @@ namespace _29WxOrderWpf
}
}
/// <summary>
/// 启动后根据服务端工单列表和历史缓存补齐未读角标。
/// </summary>
private async Task LoadHistoricalTicketUnreadAsync(TicketClientOptions options)
{
try
{
var unreadByTicket = await _workOrderUnreadService
.GetUnreadByTicketAsync(options);
_ticketMonitor.MergeUnread(unreadByTicket);
}
catch (Exception ex)
{
LogSave.Add("TicketUnreadLoadError:" + ex);
}
}
private TicketClientOptions GetTicketOptions()
{
if (_ticketOptions != null)
@@ -614,7 +634,7 @@ namespace _29WxOrderWpf
_ticketOptions = new TicketClientOptions
{
UserName = BaseClass.UserName,
AppIds = GlobalConfig.ShopLoginList.Select(a=>a.ShopId).ToList(),
AppIds = GlobalConfig.ShopLoginList.Select(a => a.ShopId).ToList(),
Orders = []
};
@@ -673,10 +693,11 @@ namespace _29WxOrderWpf
if (_orderSupportView == null)
{
_orderSupportView = new OrderSupportTicketView(
new TicketApiService(),
_ticketMonitor.RealtimeService,
GetTicketOptions().Clone(),
ownsRealtime: false);
new TicketService(
new TicketApiService(),
_ticketMonitor.RealtimeService,
ownsRealtime: false),
GetTicketOptions().Clone());
}
return _orderSupportView;
@@ -722,16 +743,18 @@ namespace _29WxOrderWpf
{
if (borderOrderSupportUnread == null)
return;
borderOrderSupportUnread.Visibility = unreadCount > 0
? Visibility.Visible
: Visibility.Collapsed;
tbOrderSupportUnread.Text = unreadCount > 99
? "99+"
: unreadCount.ToString();
btOrderSupport.ToolTip = unreadCount > 0
? $"客户工单({unreadCount} 条未读消息)"
: "客户工单";
this.Dispatcher.Invoke(() =>
{
borderOrderSupportUnread.Visibility = unreadCount > 0
? Visibility.Visible
: Visibility.Collapsed;
tbOrderSupportUnread.Text = unreadCount > 99
? "99+"
: unreadCount.ToString();
btOrderSupport.ToolTip = unreadCount > 0
? $"客户工单({unreadCount} 条未读消息)"
: "客户工单";
});
}
private void MainWindow_Closed(object sender, EventArgs e)
@@ -12,7 +12,7 @@ namespace OrderSupportTicket.Services;
/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
/// </summary>
public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
public sealed class EventSourceTicketRealtimeService
{
private const string EventsPath = "/api/open/v2/customer-work-orders/events";
private readonly object _sync = new object();
@@ -1,87 +0,0 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 客户工单 v2 接口。
/// </summary>
public interface ITicketApiService
{
/// <summary>
/// 查询工单摘要列表。
/// </summary>
Task<WorkOrderListResponse> GetWorkOrdersAsync(
TicketClientOptions options,
string wechatOrderId = null,
string status = null,
int limit = 20,
string cursor = null);
/// <summary>
/// 查询指定订单的公开留言板。
/// </summary>
Task<WorkOrderMessagesResponse> GetMessagesAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string before = null,
int limit = 50);
/// <summary>
/// 提交问题或创建工单。
/// </summary>
Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
TicketClientOptions options,
TicketOrderOption order,
string description,
string clientRequestId = null);
/// <summary>
/// 提交客户留言。
/// </summary>
Task<SendMessageResponse> SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId);
/// <summary>
/// 标记当前订单留言已读。
/// </summary>
Task MarkReadAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
int throughSequenceNo);
}
/// <summary>
/// 工单接口调用失败时返回的异常。
/// </summary>
public sealed class TicketApiException : System.Exception
{
/// <summary>
/// 创建工单接口异常。
/// </summary>
public TicketApiException(
int statusCode,
string message,
string responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
/// <summary>
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
/// </summary>
public int StatusCode { get; }
/// <summary>
/// 服务端返回的原始响应正文。
/// </summary>
public string ResponseBody { get; }
}
@@ -1,36 +0,0 @@
using OrderSupportTicket.Models;
using System;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单 SSE 实时通知服务。
/// </summary>
public interface ITicketRealtimeService : IDisposable
{
/// <summary>
/// 收到工单变更摘要通知时触发。
/// </summary>
event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
/// <summary>
/// SSE 连接状态变化时触发。
/// </summary>
event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
TicketRealtimeState State { get; }
/// <summary>
/// 使用指定工单参数建立 SSE 连接。
/// </summary>
void Start(TicketClientOptions options);
/// <summary>
/// 停止 SSE 连接。
/// </summary>
Task StopAsync();
}
@@ -0,0 +1,32 @@
using System;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单接口调用失败时返回的异常。
/// </summary>
public sealed class TicketApiException : Exception
{
/// <summary>
/// 创建工单接口异常。
/// </summary>
public TicketApiException(
int statusCode,
string message,
string responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
/// <summary>
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
/// </summary>
public int StatusCode { get; }
/// <summary>
/// 服务端返回的原始响应正文。
/// </summary>
public string ResponseBody { get; }
}
@@ -15,7 +15,7 @@ namespace OrderSupportTicket.Services;
/// <summary>
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
/// </summary>
public sealed class TicketApiService : ITicketApiService
public sealed class TicketApiService
{
internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
@@ -40,7 +40,7 @@ public sealed class TicketRealtimeMonitor : IDisposable
/// <summary>
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
/// </summary>
public ITicketRealtimeService RealtimeService { get; }
public EventSourceTicketRealtimeService RealtimeService { get; }
/// <summary>
/// 收到新的工单留言通知时触发。
@@ -132,6 +132,38 @@ public sealed class TicketRealtimeMonitor : IDisposable
RaiseUnreadCountChanged();
}
/// <summary>
/// 合并历史未读数据。与实时事件产生的未读取较大值,避免重复计数。
/// </summary>
/// <param name="unreadByTicket">
/// 键为 "appId|wechatOrderId",值为该工单的未读消息数。
/// </param>
public void MergeUnread(IDictionary<string, int> unreadByTicket)
{
if (unreadByTicket == null || unreadByTicket.Count == 0)
return;
var changed = false;
lock (_sync)
{
foreach (var item in unreadByTicket)
{
if (string.IsNullOrWhiteSpace(item.Key) || item.Value <= 0)
continue;
_unreadMessages.TryGetValue(item.Key, out var current);
if (item.Value <= current)
continue;
_unreadMessages[item.Key] = item.Value;
changed = true;
}
}
if (changed)
RaiseUnreadCountChanged();
}
/// <summary>
/// 清空全部未读提示。
/// </summary>
@@ -0,0 +1,229 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单业务服务,聚合工单 API 客户端与 SSE 实时服务。
/// </summary>
public sealed class TicketService : IDisposable
{
private readonly TicketApiService _api;
private readonly EventSourceTicketRealtimeService _realtime;
private bool _disposed;
/// <summary>
/// 创建使用默认依赖的工单业务服务。
/// </summary>
public TicketService(bool ownsRealtime = true)
: this(
new TicketApiService(),
new EventSourceTicketRealtimeService(),
ownsRealtime)
{
}
/// <summary>
/// 创建工单业务服务。
/// </summary>
/// <param name="api">工单 API 客户端。</param>
/// <param name="realtime">工单实时通知服务。</param>
/// <param name="ownsRealtime">
/// 是否由当前实例负责实时连接的停止与释放;共享连接应传入 false。
/// </param>
public TicketService(
TicketApiService api,
EventSourceTicketRealtimeService realtime,
bool ownsRealtime = true)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_realtime = realtime ?? throw new ArgumentNullException(nameof(realtime));
OwnsRealtime = ownsRealtime;
_realtime.NotificationReceived += OnNotificationReceived;
_realtime.StateChanged += OnStateChanged;
}
/// <summary>
/// 收到工单变更摘要通知时触发。
/// </summary>
public event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
/// <summary>
/// SSE 连接状态变化时触发。
/// </summary>
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
public TicketRealtimeState RealtimeState => _realtime.State;
/// <summary>
/// 是否由当前服务实例管理实时连接的生命周期。
/// </summary>
public bool OwnsRealtime { get; }
/// <summary>
/// 校验工单页运行参数。
/// </summary>
public void ValidateOptions(TicketClientOptions options)
{
ThrowIfDisposed();
TicketApiService.ValidateOptions(options);
}
/// <summary>
/// 查询工单摘要列表。
/// </summary>
public Task<WorkOrderListResponse> GetWorkOrdersAsync(
TicketClientOptions options,
string wechatOrderId = null,
string status = null,
int limit = 20,
string cursor = null)
{
ThrowIfDisposed();
return _api.GetWorkOrdersAsync(
options,
wechatOrderId,
status,
limit,
cursor);
}
/// <summary>
/// 查询指定订单的公开留言板。
/// </summary>
public Task<WorkOrderMessagesResponse> GetMessagesAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string before = null,
int limit = 50)
{
ThrowIfDisposed();
return _api.GetMessagesAsync(
options,
appId,
wechatOrderId,
before,
limit);
}
/// <summary>
/// 提交问题或创建工单。
/// </summary>
public Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
TicketClientOptions options,
TicketOrderOption order,
string description,
string clientRequestId = null)
{
ThrowIfDisposed();
return _api.CreateWorkOrderAsync(
options,
order,
description,
clientRequestId);
}
/// <summary>
/// 提交客户留言。
/// </summary>
public Task<SendMessageResponse> SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId)
{
ThrowIfDisposed();
return _api.SendMessageAsync(
options,
workOrder,
text,
clientMessageId);
}
/// <summary>
/// 标记当前订单留言已读。
/// </summary>
public Task MarkReadAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
int throughSequenceNo)
{
ThrowIfDisposed();
return _api.MarkReadAsync(
options,
appId,
wechatOrderId,
throughSequenceNo);
}
/// <summary>
/// 使用指定工单参数建立 SSE 连接。
/// </summary>
public void StartRealtime(TicketClientOptions options)
{
ThrowIfDisposed();
ValidateOptions(options);
_realtime.Start(options);
}
/// <summary>
/// 停止 SSE 连接;共享连接由宿主管理时,调用方应先检查 <see cref="OwnsRealtime"/>。
/// </summary>
public Task StopRealtimeAsync()
{
ThrowIfDisposed();
return _realtime.StopAsync();
}
/// <summary>
/// 停止并重新建立 SSE 连接。
/// </summary>
public async Task RestartRealtimeAsync(TicketClientOptions options)
{
ThrowIfDisposed();
await _realtime.StopAsync();
ThrowIfDisposed();
StartRealtime(options);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_realtime.NotificationReceived -= OnNotificationReceived;
_realtime.StateChanged -= OnStateChanged;
if (OwnsRealtime)
_realtime.Dispose();
}
private void OnNotificationReceived(
object sender,
TicketRealtimeNotificationEventArgs args)
{
NotificationReceived?.Invoke(this, args);
}
private void OnStateChanged(
object sender,
TicketRealtimeStateChangedEventArgs args)
{
StateChanged?.Invoke(this, args);
}
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(TicketService));
}
}
@@ -0,0 +1,149 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单列表本地缓存的读取与未读比较。
/// </summary>
public sealed class WorkOrderCacheService
{
private const string ConfigName = "ordersupportticket";
private static readonly object SyncRoot = new object();
/// <summary>
/// 读取指定账号的工单列表缓存。
/// </summary>
public WorkOrderListCache Load(string userName)
{
if (string.IsNullOrWhiteSpace(userName))
return null;
try
{
lock (SyncRoot)
{
var path = NaiBa.Util.Helper.ConfigHelper.GetConfigUserPath(ConfigName);
if (!File.Exists(path))
return null;
var cache = NaiBa.Util.Helper.ConfigHelper
.LoadConfig<WorkOrderListCache>(ConfigName);
if (cache?.Items == null)
return null;
if (!string.Equals(cache.UserName, userName, StringComparison.Ordinal))
return null;
return cache;
}
}
catch
{
// 本地缓存损坏或读取失败时按无缓存处理。
return null;
}
}
/// <summary>
/// 保存工单列表缓存。
/// </summary>
public void Save(WorkOrderListCache cache)
{
if (cache == null)
throw new ArgumentNullException(nameof(cache));
if (string.IsNullOrWhiteSpace(cache.UserName))
throw new ArgumentException("缓存缺少登录账号。", nameof(cache));
lock (SyncRoot)
{
NaiBa.Util.Helper.ConfigHelper.SaveConfig(ConfigName, cache);
}
}
/// <summary>
/// 根据服务端工单列表和历史缓存计算每个工单的未读消息数。
/// 返回值的键为 "appId|wechatOrderId"。
/// </summary>
public Dictionary<string, int> BuildUnreadMap(
IEnumerable<WorkOrderSummaryDto> workOrders,
WorkOrderListCache cache)
{
var result = new Dictionary<string, int>(StringComparer.Ordinal);
var savedItems = (cache?.Items ?? new List<WorkOrderListCacheItem>())
.Where(item => item != null && !string.IsNullOrWhiteSpace(item.OrderId))
.GroupBy(item => item.OrderId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.First(),
StringComparer.Ordinal);
var hasSavedCache = cache?.Items != null && cache.Items.Count > 0;
foreach (var dto in workOrders ?? Enumerable.Empty<WorkOrderSummaryDto>())
{
if (dto == null
|| string.IsNullOrWhiteSpace(dto.AppId)
|| string.IsNullOrWhiteSpace(dto.WechatOrderId))
{
continue;
}
var hasNewMessage = !hasSavedCache;
if (hasSavedCache)
{
if (savedItems.TryGetValue(dto.WechatOrderId, out var saved))
{
hasNewMessage = ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt;
}
else
{
// 缓存中没有该工单,说明是新增工单。
hasNewMessage = true;
}
}
if (!hasNewMessage)
continue;
// 有历史基线时,时间更新但服务端未读数为 0 也按 1 条提示;
// 首次运行没有基线时只信任服务端未读数,避免把历史工单全部标成未读。
var unreadCount = hasSavedCache
? Math.Max(1, dto.UnreadCount)
: dto.UnreadCount;
if (unreadCount <= 0)
continue;
result[BuildTicketKey(dto.AppId, dto.WechatOrderId)] = unreadCount;
}
return result;
}
/// <summary>
/// 构建工单实时通知使用的唯一键。
/// </summary>
public static string BuildTicketKey(string appId, string wechatOrderId)
{
return appId + "|" + wechatOrderId;
}
/// <summary>
/// 解析服务端 ISO 8601 时间字符串。
/// </summary>
public static DateTimeOffset ParseTimestamp(string value)
{
if (DateTimeOffset.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out var parsed))
{
return parsed;
}
return default;
}
}
@@ -0,0 +1,75 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 启动时从服务端工单列表和历史本地缓存计算未读消息。
/// </summary>
public sealed class WorkOrderUnreadService
{
private const int PageSize = 100;
private const int MaxPages = 20;
private readonly TicketApiService _api;
private readonly WorkOrderCacheService _cache;
/// <summary>
/// 使用默认工单 API 和缓存服务创建未读计算服务。
/// </summary>
public WorkOrderUnreadService()
: this(new TicketApiService(), new WorkOrderCacheService())
{
}
/// <summary>
/// 创建未读计算服务。
/// </summary>
public WorkOrderUnreadService(
TicketApiService api,
WorkOrderCacheService cache)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
}
/// <summary>
/// 获取每个工单的未读消息数。返回值的键为 "appId|wechatOrderId"。
/// </summary>
public async Task<Dictionary<string, int>> GetUnreadByTicketAsync(
TicketClientOptions options)
{
if (options == null)
throw new ArgumentNullException(nameof(options));
TicketApiService.ValidateOptions(options);
var cache = _cache.Load(options.UserName);
var workOrders = new List<WorkOrderSummaryDto>();
string cursor = null;
var page = 0;
do
{
var response = await _api.GetWorkOrdersAsync(
options,
limit: PageSize,
cursor: cursor).ConfigureAwait(false);
if (response?.Items != null)
workOrders.AddRange(response.Items);
cursor = response?.NextCursor;
page++;
if (response == null
|| !response.HasMore
|| string.IsNullOrWhiteSpace(cursor))
{
break;
}
}
while (page < MaxPages);
return _cache.BuildUnreadMap(workOrders, cache);
}
}
@@ -1,6 +1,5 @@
using AllBase.Helper;
using AllBase.Model.DBModel;
using NaiBa.Util.Helper;
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using OrderSupportTicket.Services;
@@ -9,7 +8,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
@@ -32,9 +30,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
/// </summary>
public const string OrderParameterKey = "order";
private const string SavedListConfigName = "ordersupportticket";
private readonly ITicketApiService _api;
private readonly ITicketRealtimeService _realtime;
private readonly TicketService _ticketService;
private readonly WorkOrderCacheService _cacheService = new WorkOrderCacheService();
private readonly Dispatcher _dispatcher;
private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand;
private readonly AsyncDelegateCommand _createTicketCommand;
@@ -52,7 +49,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private bool _configured;
private bool _initialized;
private bool _disposed;
private readonly bool _ownsRealtime;
private string _pendingOpenKey;
private WorkOrderSummaryViewModel _selectedTicket;
private TicketOrderOption _selectedOrder;
@@ -82,14 +78,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private int _selectionVersion;
public OrderSupportTicketViewModel(
ITicketApiService api,
ITicketRealtimeService realtime,
TicketClientOptions options = null,
bool ownsRealtime = true)
TicketService ticketService,
TicketClientOptions options = null)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_realtime = realtime ?? throw new ArgumentNullException(nameof(realtime));
_ownsRealtime = ownsRealtime;
_ticketService = ticketService
?? throw new ArgumentNullException(nameof(ticketService));
_dispatcher = System.Windows.Application.Current?.Dispatcher
?? Dispatcher.CurrentDispatcher;
@@ -112,10 +105,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
_retryRealtimeCommand = new AsyncDelegateCommand(
RestartRealtimeAsync,
() => !IsBusy && _configured);
_realtime.NotificationReceived += OnNotificationReceived;
_realtime.StateChanged += OnRealtimeStateChanged;
SseState = TicketRealtimeState.Disconnected;
SseStatusText = "实时推送:未连接";
_ticketService.NotificationReceived += OnNotificationReceived;
_ticketService.StateChanged += OnRealtimeStateChanged;
SseState = _ticketService.RealtimeState;
SseStatusText = GetRealtimeStatusText(SseState);
if (options != null)
Configure(options);
@@ -636,17 +629,16 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
if (_disposed)
throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel));
if (_ownsRealtime)
if (_ticketService.OwnsRealtime)
{
_ = _realtime.StopAsync();
_ = _ticketService.StopRealtimeAsync();
SseState = TicketRealtimeState.Disconnected;
SseStatusText = GetRealtimeStatusText(SseState);
}
else
{
SseState = _realtime.State;
SseStatusText = GetRealtimeStatusText(SseState);
SseState = _ticketService.RealtimeState;
}
SseStatusText = GetRealtimeStatusText(SseState);
_configured = false;
_initialized = false;
_pendingOpenKey = null;
@@ -680,7 +672,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
{
try
{
TicketApiService.ValidateOptions(_options);
_ticketService.ValidateOptions(_options);
if (_options.AppIds == null || _options.AppIds.Count == 0)
throw new ArgumentException("appIds 至少需要一个 APPID。");
_configured = true;
@@ -718,11 +710,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var started = false;
// 共享连接由宿主统一维护,页面首次打开时不能因为 _initialized=false 重启连接。
var shouldStartRealtime =
_realtime.State == TicketRealtimeState.Disconnected
|| (_ownsRealtime && !_initialized);
_ticketService.RealtimeState == TicketRealtimeState.Disconnected
|| (_ticketService.OwnsRealtime && !_initialized);
if (shouldStartRealtime)
{
_realtime.Start(_options);
_ticketService.StartRealtime(_options);
started = true;
}
@@ -743,8 +735,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
public async Task DeactivateAsync()
{
SaveWorkOrderList();
if (_ownsRealtime)
await _realtime.StopAsync();
if (_ticketService.OwnsRealtime)
await _ticketService.StopRealtimeAsync();
}
public void OpenNewTicket(TicketOrderOption order)
@@ -834,10 +826,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
return;
SaveWorkOrderList();
_disposed = true;
_realtime.NotificationReceived -= OnNotificationReceived;
_realtime.StateChanged -= OnRealtimeStateChanged;
if (_ownsRealtime)
_realtime.Dispose();
_ticketService.NotificationReceived -= OnNotificationReceived;
_ticketService.StateChanged -= OnRealtimeStateChanged;
_ticketService.Dispose();
}
private void OpenNewDraft()
@@ -938,7 +929,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
ClearNotice();
try
{
var result = await _api.CreateWorkOrderAsync(
var result = await _ticketService.CreateWorkOrderAsync(
_options,
SelectedOrder,
description,
@@ -1010,7 +1001,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
ClearNotice();
try
{
var result = await _api.SendMessageAsync(
var result = await _ticketService.SendMessageAsync(
_options,
ToSummaryDto(ticket),
text,
@@ -1068,7 +1059,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsBusy = true;
try
{
var response = await _api.GetMessagesAsync(
var response = await _ticketService.GetMessagesAsync(
_options,
ticket.AppId,
ticket.WechatOrderId,
@@ -1096,8 +1087,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsBusy = true;
try
{
await _realtime.StopAsync();
_realtime.Start(_options);
await _ticketService.RestartRealtimeAsync(_options);
await RefreshAllWorkOrdersAsync(loadSelectedMessages: false);
if (SelectedTicket != null && !IsDraft)
await LoadMessagesAsync(SelectedTicket, replace: true);
@@ -1121,7 +1111,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsLoadingMessages = true;
try
{
var response = await _api.GetMessagesAsync(
var response = await _ticketService.GetMessagesAsync(
_options,
ticket.AppId,
ticket.WechatOrderId,
@@ -1168,7 +1158,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var page = 0;
do
{
var response = await _api.GetWorkOrdersAsync(
var response = await _ticketService.GetWorkOrdersAsync(
_options,
limit: 100,
cursor: cursor);
@@ -1240,7 +1230,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var options = CloneOptions(_options);
options.AppIds = new List<string> { appId };
var response = await _api.GetWorkOrdersAsync(
var response = await _ticketService.GetWorkOrdersAsync(
options,
wechatOrderId,
limit: 1);
@@ -1338,7 +1328,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
try
{
await _api.MarkReadAsync(
await _ticketService.MarkReadAsync(
_options,
ticket.AppId,
ticket.WechatOrderId,
@@ -1671,21 +1661,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
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))
var cache = _cacheService.Load(_options.UserName);
if (cache?.Items == null)
return;
foreach (var saved in cache.Items)
@@ -1735,7 +1712,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
try
{
ConfigHelper.SaveConfig(SavedListConfigName, cache);
_cacheService.Save(cache);
_hasSavedCache = true;
}
catch
@@ -1751,7 +1728,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
if (!_savedWorkOrders.TryGetValue(dto.WechatOrderId, out var saved))
return _hasSavedCache;
return ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt;
return WorkOrderCacheService.ParseTimestamp(dto.LastMessageAt)
> saved.LastMessageAt;
}
private static WorkOrderListCacheItem ToCacheItem(
@@ -1767,18 +1745,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
};
}
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)
{
@@ -18,38 +18,21 @@ public partial class OrderSupportTicketView : UserControl
private Window _hostWindow;
public OrderSupportTicketView()
: this(
new TicketApiService(),
new EventSourceTicketRealtimeService(),
CreateDefaultOptions(),
ownsRealtime: true)
{
}
public OrderSupportTicketView(
ITicketApiService api,
ITicketRealtimeService realtime,
TicketClientOptions options = null)
: this(api, realtime, options, ownsRealtime: true)
: this(new TicketService(), CreateDefaultOptions())
{
}
/// <summary>
/// 创建工单页。ownsRealtime 为 false 时复用宿主提供的共享 SSE 连接,
/// 页面关闭不会停止该连接。
/// 使用工单业务服务创建工单页。
/// </summary>
public OrderSupportTicketView(
ITicketApiService api,
ITicketRealtimeService realtime,
TicketClientOptions options,
bool ownsRealtime)
TicketService ticketService,
TicketClientOptions options = null)
{
InitializeComponent();
ViewModel = new OrderSupportTicketViewModel(
api,
realtime,
options ?? CreateDefaultOptions(),
ownsRealtime);
ticketService,
options ?? CreateDefaultOptions());
DataContext = ViewModel;
Loaded += OnLoaded;
Unloaded += OnUnloaded;