diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs
index b15ec5a..3e488aa 100644
--- a/29WxOrderWpf/MainWindow.xaml.cs
+++ b/29WxOrderWpf/MainWindow.xaml.cs
@@ -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
}
}
+ ///
+ /// 启动后根据服务端工单列表和历史缓存补齐未读角标。
+ ///
+ 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)
diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
index eb5bcf9..c251ad4 100644
--- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
+++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
@@ -12,7 +12,7 @@ namespace OrderSupportTicket.Services;
/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
///
-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();
diff --git a/OrderSupportTicket/Services/ITicketApiService.cs b/OrderSupportTicket/Services/ITicketApiService.cs
deleted file mode 100644
index e5c6866..0000000
--- a/OrderSupportTicket/Services/ITicketApiService.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using OrderSupportTicket.Models;
-using OrderSupportTicket.Models.Api;
-using System.Threading.Tasks;
-
-namespace OrderSupportTicket.Services;
-
-///
-/// 客户工单 v2 接口。
-///
-public interface ITicketApiService
-{
- ///
- /// 查询工单摘要列表。
- ///
- Task GetWorkOrdersAsync(
- TicketClientOptions options,
- string wechatOrderId = null,
- string status = null,
- int limit = 20,
- string cursor = null);
-
- ///
- /// 查询指定订单的公开留言板。
- ///
- Task GetMessagesAsync(
- TicketClientOptions options,
- string appId,
- string wechatOrderId,
- string before = null,
- int limit = 50);
-
- ///
- /// 提交问题或创建工单。
- ///
- Task CreateWorkOrderAsync(
- TicketClientOptions options,
- TicketOrderOption order,
- string description,
- string clientRequestId = null);
-
- ///
- /// 提交客户留言。
- ///
- Task SendMessageAsync(
- TicketClientOptions options,
- WorkOrderSummaryDto workOrder,
- string text,
- string clientMessageId);
-
- ///
- /// 标记当前订单留言已读。
- ///
- Task MarkReadAsync(
- TicketClientOptions options,
- string appId,
- string wechatOrderId,
- int throughSequenceNo);
-}
-
-///
-/// 工单接口调用失败时返回的异常。
-///
-public sealed class TicketApiException : System.Exception
-{
- ///
- /// 创建工单接口异常。
- ///
- public TicketApiException(
- int statusCode,
- string message,
- string responseBody = null)
- : base(message)
- {
- StatusCode = statusCode;
- ResponseBody = responseBody;
- }
-
- ///
- /// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
- ///
- public int StatusCode { get; }
-
- ///
- /// 服务端返回的原始响应正文。
- ///
- public string ResponseBody { get; }
-}
diff --git a/OrderSupportTicket/Services/ITicketRealtimeService.cs b/OrderSupportTicket/Services/ITicketRealtimeService.cs
deleted file mode 100644
index 2dc950c..0000000
--- a/OrderSupportTicket/Services/ITicketRealtimeService.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using OrderSupportTicket.Models;
-using System;
-using System.Threading.Tasks;
-
-namespace OrderSupportTicket.Services;
-
-///
-/// 工单 SSE 实时通知服务。
-///
-public interface ITicketRealtimeService : IDisposable
-{
- ///
- /// 收到工单变更摘要通知时触发。
- ///
- event EventHandler NotificationReceived;
-
- ///
- /// SSE 连接状态变化时触发。
- ///
- event EventHandler StateChanged;
-
- ///
- /// 当前 SSE 连接状态。
- ///
- TicketRealtimeState State { get; }
-
- ///
- /// 使用指定工单参数建立 SSE 连接。
- ///
- void Start(TicketClientOptions options);
-
- ///
- /// 停止 SSE 连接。
- ///
- Task StopAsync();
-}
diff --git a/OrderSupportTicket/Services/TicketApiException.cs b/OrderSupportTicket/Services/TicketApiException.cs
new file mode 100644
index 0000000..e78e14e
--- /dev/null
+++ b/OrderSupportTicket/Services/TicketApiException.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 工单接口调用失败时返回的异常。
+///
+public sealed class TicketApiException : Exception
+{
+ ///
+ /// 创建工单接口异常。
+ ///
+ public TicketApiException(
+ int statusCode,
+ string message,
+ string responseBody = null)
+ : base(message)
+ {
+ StatusCode = statusCode;
+ ResponseBody = responseBody;
+ }
+
+ ///
+ /// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
+ ///
+ public int StatusCode { get; }
+
+ ///
+ /// 服务端返回的原始响应正文。
+ ///
+ public string ResponseBody { get; }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs
index 9d78453..65a5230 100644
--- a/OrderSupportTicket/Services/TicketApiService.cs
+++ b/OrderSupportTicket/Services/TicketApiService.cs
@@ -15,7 +15,7 @@ namespace OrderSupportTicket.Services;
///
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
///
-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";
diff --git a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs
index dee49e5..bf3691d 100644
--- a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs
+++ b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs
@@ -40,7 +40,7 @@ public sealed class TicketRealtimeMonitor : IDisposable
///
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
///
- public ITicketRealtimeService RealtimeService { get; }
+ public EventSourceTicketRealtimeService RealtimeService { get; }
///
/// 收到新的工单留言通知时触发。
@@ -132,6 +132,38 @@ public sealed class TicketRealtimeMonitor : IDisposable
RaiseUnreadCountChanged();
}
+ ///
+ /// 合并历史未读数据。与实时事件产生的未读取较大值,避免重复计数。
+ ///
+ ///
+ /// 键为 "appId|wechatOrderId",值为该工单的未读消息数。
+ ///
+ public void MergeUnread(IDictionary 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();
+ }
+
///
/// 清空全部未读提示。
///
diff --git a/OrderSupportTicket/Services/TicketService.cs b/OrderSupportTicket/Services/TicketService.cs
new file mode 100644
index 0000000..e063320
--- /dev/null
+++ b/OrderSupportTicket/Services/TicketService.cs
@@ -0,0 +1,229 @@
+using OrderSupportTicket.Models;
+using OrderSupportTicket.Models.Api;
+using System;
+using System.Threading.Tasks;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 工单业务服务,聚合工单 API 客户端与 SSE 实时服务。
+///
+public sealed class TicketService : IDisposable
+{
+ private readonly TicketApiService _api;
+ private readonly EventSourceTicketRealtimeService _realtime;
+ private bool _disposed;
+
+ ///
+ /// 创建使用默认依赖的工单业务服务。
+ ///
+ public TicketService(bool ownsRealtime = true)
+ : this(
+ new TicketApiService(),
+ new EventSourceTicketRealtimeService(),
+ ownsRealtime)
+ {
+ }
+
+ ///
+ /// 创建工单业务服务。
+ ///
+ /// 工单 API 客户端。
+ /// 工单实时通知服务。
+ ///
+ /// 是否由当前实例负责实时连接的停止与释放;共享连接应传入 false。
+ ///
+ 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;
+ }
+
+ ///
+ /// 收到工单变更摘要通知时触发。
+ ///
+ public event EventHandler NotificationReceived;
+
+ ///
+ /// SSE 连接状态变化时触发。
+ ///
+ public event EventHandler StateChanged;
+
+ ///
+ /// 当前 SSE 连接状态。
+ ///
+ public TicketRealtimeState RealtimeState => _realtime.State;
+
+ ///
+ /// 是否由当前服务实例管理实时连接的生命周期。
+ ///
+ public bool OwnsRealtime { get; }
+
+ ///
+ /// 校验工单页运行参数。
+ ///
+ public void ValidateOptions(TicketClientOptions options)
+ {
+ ThrowIfDisposed();
+ TicketApiService.ValidateOptions(options);
+ }
+
+ ///
+ /// 查询工单摘要列表。
+ ///
+ public Task GetWorkOrdersAsync(
+ TicketClientOptions options,
+ string wechatOrderId = null,
+ string status = null,
+ int limit = 20,
+ string cursor = null)
+ {
+ ThrowIfDisposed();
+ return _api.GetWorkOrdersAsync(
+ options,
+ wechatOrderId,
+ status,
+ limit,
+ cursor);
+ }
+
+ ///
+ /// 查询指定订单的公开留言板。
+ ///
+ public Task GetMessagesAsync(
+ TicketClientOptions options,
+ string appId,
+ string wechatOrderId,
+ string before = null,
+ int limit = 50)
+ {
+ ThrowIfDisposed();
+ return _api.GetMessagesAsync(
+ options,
+ appId,
+ wechatOrderId,
+ before,
+ limit);
+ }
+
+ ///
+ /// 提交问题或创建工单。
+ ///
+ public Task CreateWorkOrderAsync(
+ TicketClientOptions options,
+ TicketOrderOption order,
+ string description,
+ string clientRequestId = null)
+ {
+ ThrowIfDisposed();
+ return _api.CreateWorkOrderAsync(
+ options,
+ order,
+ description,
+ clientRequestId);
+ }
+
+ ///
+ /// 提交客户留言。
+ ///
+ public Task SendMessageAsync(
+ TicketClientOptions options,
+ WorkOrderSummaryDto workOrder,
+ string text,
+ string clientMessageId)
+ {
+ ThrowIfDisposed();
+ return _api.SendMessageAsync(
+ options,
+ workOrder,
+ text,
+ clientMessageId);
+ }
+
+ ///
+ /// 标记当前订单留言已读。
+ ///
+ public Task MarkReadAsync(
+ TicketClientOptions options,
+ string appId,
+ string wechatOrderId,
+ int throughSequenceNo)
+ {
+ ThrowIfDisposed();
+ return _api.MarkReadAsync(
+ options,
+ appId,
+ wechatOrderId,
+ throughSequenceNo);
+ }
+
+ ///
+ /// 使用指定工单参数建立 SSE 连接。
+ ///
+ public void StartRealtime(TicketClientOptions options)
+ {
+ ThrowIfDisposed();
+ ValidateOptions(options);
+ _realtime.Start(options);
+ }
+
+ ///
+ /// 停止 SSE 连接;共享连接由宿主管理时,调用方应先检查 。
+ ///
+ public Task StopRealtimeAsync()
+ {
+ ThrowIfDisposed();
+ return _realtime.StopAsync();
+ }
+
+ ///
+ /// 停止并重新建立 SSE 连接。
+ ///
+ 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));
+ }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/Services/WorkOrderCacheService.cs b/OrderSupportTicket/Services/WorkOrderCacheService.cs
new file mode 100644
index 0000000..91e32a3
--- /dev/null
+++ b/OrderSupportTicket/Services/WorkOrderCacheService.cs
@@ -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;
+
+///
+/// 工单列表本地缓存的读取与未读比较。
+///
+public sealed class WorkOrderCacheService
+{
+ private const string ConfigName = "ordersupportticket";
+ private static readonly object SyncRoot = new object();
+
+ ///
+ /// 读取指定账号的工单列表缓存。
+ ///
+ 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(ConfigName);
+ if (cache?.Items == null)
+ return null;
+ if (!string.Equals(cache.UserName, userName, StringComparison.Ordinal))
+ return null;
+
+ return cache;
+ }
+ }
+ catch
+ {
+ // 本地缓存损坏或读取失败时按无缓存处理。
+ return null;
+ }
+ }
+
+ ///
+ /// 保存工单列表缓存。
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 根据服务端工单列表和历史缓存计算每个工单的未读消息数。
+ /// 返回值的键为 "appId|wechatOrderId"。
+ ///
+ public Dictionary BuildUnreadMap(
+ IEnumerable workOrders,
+ WorkOrderListCache cache)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ var savedItems = (cache?.Items ?? new List())
+ .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())
+ {
+ 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;
+ }
+
+ ///
+ /// 构建工单实时通知使用的唯一键。
+ ///
+ public static string BuildTicketKey(string appId, string wechatOrderId)
+ {
+ return appId + "|" + wechatOrderId;
+ }
+
+ ///
+ /// 解析服务端 ISO 8601 时间字符串。
+ ///
+ public static DateTimeOffset ParseTimestamp(string value)
+ {
+ if (DateTimeOffset.TryParse(
+ value,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal,
+ out var parsed))
+ {
+ return parsed;
+ }
+ return default;
+ }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/Services/WorkOrderUnreadService.cs b/OrderSupportTicket/Services/WorkOrderUnreadService.cs
new file mode 100644
index 0000000..a9b7549
--- /dev/null
+++ b/OrderSupportTicket/Services/WorkOrderUnreadService.cs
@@ -0,0 +1,75 @@
+using OrderSupportTicket.Models;
+using OrderSupportTicket.Models.Api;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 启动时从服务端工单列表和历史本地缓存计算未读消息。
+///
+public sealed class WorkOrderUnreadService
+{
+ private const int PageSize = 100;
+ private const int MaxPages = 20;
+ private readonly TicketApiService _api;
+ private readonly WorkOrderCacheService _cache;
+
+ ///
+ /// 使用默认工单 API 和缓存服务创建未读计算服务。
+ ///
+ public WorkOrderUnreadService()
+ : this(new TicketApiService(), new WorkOrderCacheService())
+ {
+ }
+
+ ///
+ /// 创建未读计算服务。
+ ///
+ public WorkOrderUnreadService(
+ TicketApiService api,
+ WorkOrderCacheService cache)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ }
+
+ ///
+ /// 获取每个工单的未读消息数。返回值的键为 "appId|wechatOrderId"。
+ ///
+ public async Task> GetUnreadByTicketAsync(
+ TicketClientOptions options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ TicketApiService.ValidateOptions(options);
+ var cache = _cache.Load(options.UserName);
+ var workOrders = new List();
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
index 1560a0a..5df20d1 100644
--- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
+++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
@@ -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,
///
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 _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 { 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(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)
{
diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
index 4ce2cc3..123b8d4 100644
--- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
+++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
@@ -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())
{
}
///
- /// 创建工单页。ownsRealtime 为 false 时复用宿主提供的共享 SSE 连接,
- /// 页面关闭不会停止该连接。
+ /// 使用工单业务服务创建工单页。
///
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;