修改工单流程页面,订单列表增加工单按钮
This commit is contained in:
1 parent
f9dcae4610
commit
10bb0803d9
44 files changed
+5920
-1291
No files matched your search
@@ -77,7 +77,7 @@ public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
|
||||
{
|
||||
try
|
||||
{
|
||||
await source.StartAsync().ConfigureAwait(false);
|
||||
await source.StartAsync();
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_running || !ReferenceEquals(_eventSource, source))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商品缩略图加载器,限制并发下载数量,避免工单列表同时发起大量请求。
|
||||
/// </summary>
|
||||
internal static class ProductImageLoader
|
||||
{
|
||||
private static readonly SemaphoreSlim Gate = new SemaphoreSlim(4, 4);
|
||||
|
||||
public static async Task<ImageSource> LoadAsync(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
await Gate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => AllBase.ImageBuild.LoadBitmipImageFromUrl(url.Trim()))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using WxOrder.Framework.Commons;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
@@ -18,7 +19,7 @@ namespace OrderSupportTicket.Services;
|
||||
/// </summary>
|
||||
public sealed class TicketApiService : ITicketApiService
|
||||
{
|
||||
internal const string ServerUrl = "https://ai.e57.cn";
|
||||
internal static string ServerUrl = "https://ai.e57.cn";
|
||||
private const string ApiRoot = "/api/open/v2/customer-work-orders";
|
||||
private static readonly Regex AppIdRegex =
|
||||
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
@@ -28,7 +29,13 @@ public sealed class TicketApiService : ITicketApiService
|
||||
new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
|
||||
private static readonly Regex OperationIdRegex =
|
||||
new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
static TicketApiService()
|
||||
{
|
||||
if (EnvironmentHelper.IsTest)
|
||||
{
|
||||
ServerUrl = "http://localhost:5188";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取工单列表
|
||||
/// </summary>
|
||||
@@ -64,7 +71,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
|
||||
return await PostAsync<WorkOrderListResponse>(
|
||||
ApiRoot + "/list",
|
||||
request).ConfigureAwait(false);
|
||||
request);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取工单消息
|
||||
@@ -100,8 +107,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
|
||||
var path = ApiRoot
|
||||
+ "/messages";
|
||||
return await PostAsync<WorkOrderMessagesResponse>(path, request)
|
||||
.ConfigureAwait(false);
|
||||
return await PostAsync<WorkOrderMessagesResponse>(path, request);
|
||||
}
|
||||
/// <summary>
|
||||
/// 创建工单
|
||||
@@ -170,8 +176,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
+ "/orders/"
|
||||
+ Uri.EscapeDataString(workOrder.WechatOrderId)
|
||||
+ "/messages";
|
||||
return await PostAsync<SendMessageResponse>(path, request)
|
||||
.ConfigureAwait(false);
|
||||
return await PostAsync<SendMessageResponse>(path, request);
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(
|
||||
@@ -196,7 +201,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
+ "/orders/"
|
||||
+ Uri.EscapeDataString(wechatOrderId)
|
||||
+ "/read";
|
||||
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
|
||||
var html = await PostBodyAsync(path, request);
|
||||
EnsureSuccessBody(html);
|
||||
}
|
||||
|
||||
@@ -245,7 +250,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
string path,
|
||||
object request)
|
||||
{
|
||||
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
|
||||
var html = await PostBodyAsync(path, request);
|
||||
return DeserializeResponse<T>(html);
|
||||
}
|
||||
|
||||
@@ -256,7 +261,7 @@ public sealed class TicketApiService : ITicketApiService
|
||||
var url = BuildUri(path);
|
||||
// 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。
|
||||
var json = Serialize(request);
|
||||
var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false);
|
||||
var html = await GetHtml.PostJsonAsync(url, json);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
throw RequestFailed("工单接口未返回数据。");
|
||||
return html;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 应用级工单实时监视器。SSE 连接的生命周期与应用一致,不随工单页关闭而停止。
|
||||
/// </summary>
|
||||
public sealed class TicketRealtimeMonitor : IDisposable
|
||||
{
|
||||
private readonly object _sync = new object();
|
||||
private readonly HashSet<string> _seenEventIds =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _unreadMessages =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private TicketClientOptions _options;
|
||||
private string _optionsFingerprint;
|
||||
private TicketRealtimeNotification _latestNotification;
|
||||
private bool _subscribed;
|
||||
private bool _disposed;
|
||||
|
||||
private TicketRealtimeMonitor()
|
||||
{
|
||||
RealtimeService = new EventSourceTicketRealtimeService();
|
||||
RealtimeService.NotificationReceived += OnNotificationReceived;
|
||||
_subscribed = true;
|
||||
_dispatcher = System.Windows.Application.Current?.Dispatcher
|
||||
?? Dispatcher.CurrentDispatcher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全局唯一的工单实时监视器。
|
||||
/// </summary>
|
||||
public static TicketRealtimeMonitor Current { get; } = new TicketRealtimeMonitor();
|
||||
|
||||
/// <summary>
|
||||
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
|
||||
/// </summary>
|
||||
public ITicketRealtimeService RealtimeService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 收到新的工单留言通知时触发。
|
||||
/// </summary>
|
||||
public event EventHandler<TicketInboxMessageEventArgs> NewMessageReceived;
|
||||
|
||||
/// <summary>
|
||||
/// 未读工单数量变化时触发。
|
||||
/// </summary>
|
||||
public event EventHandler<TicketUnreadCountChangedEventArgs> UnreadCountChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 当前未读工单数量。
|
||||
/// </summary>
|
||||
public int UnreadCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
return _unreadMessages.Values.Sum();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已经启动过实时连接。
|
||||
/// </summary>
|
||||
public bool IsStarted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
return _options != null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最近一条新消息通知,供主页面点击角标时定位工单。
|
||||
/// </summary>
|
||||
public TicketRealtimeNotification LatestNotification
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
return _latestNotification;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动或复用实时连接。相同账号和 APPID 重复调用不会重建连接。
|
||||
/// </summary>
|
||||
public void Start(TicketClientOptions options)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(TicketRealtimeMonitor));
|
||||
if (options == null)
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
|
||||
TicketApiService.ValidateOptions(options);
|
||||
var fingerprint = BuildFingerprint(options);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal)
|
||||
&& RealtimeService.State != TicketRealtimeState.Disconnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_options = options;
|
||||
_optionsFingerprint = fingerprint;
|
||||
}
|
||||
|
||||
RealtimeService.Start(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将指定工单标记为已读。
|
||||
/// </summary>
|
||||
public void MarkRead(string appId, string wechatOrderId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId))
|
||||
return;
|
||||
|
||||
var key = BuildTicketKey(appId, wechatOrderId);
|
||||
var changed = false;
|
||||
lock (_sync)
|
||||
changed = _unreadMessages.Remove(key);
|
||||
if (changed)
|
||||
RaiseUnreadCountChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空全部未读提示。
|
||||
/// </summary>
|
||||
public void MarkAllRead()
|
||||
{
|
||||
var changed = false;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_unreadMessages.Count > 0)
|
||||
{
|
||||
_unreadMessages.Clear();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
RaiseUnreadCountChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
if (_subscribed)
|
||||
{
|
||||
RealtimeService.NotificationReceived -= OnNotificationReceived;
|
||||
_subscribed = false;
|
||||
}
|
||||
RealtimeService.Dispose();
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(
|
||||
object sender,
|
||||
TicketRealtimeNotificationEventArgs args)
|
||||
{
|
||||
var notification = args?.Notification;
|
||||
if (notification == null || notification.IsResyncRequired)
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(notification.AppId)
|
||||
|| string.IsNullOrWhiteSpace(notification.WechatOrderId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!IsMessageAdded(notification))
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.EventId))
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_seenEventIds.Add(notification.EventId))
|
||||
return;
|
||||
if (_seenEventIds.Count > 5000)
|
||||
_seenEventIds.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
var key = BuildTicketKey(notification.AppId, notification.WechatOrderId);
|
||||
int unreadCount;
|
||||
lock (_sync)
|
||||
{
|
||||
_unreadMessages.TryGetValue(key, out var ticketUnreadCount);
|
||||
_unreadMessages[key] = ticketUnreadCount + 1;
|
||||
_latestNotification = notification;
|
||||
unreadCount = _unreadMessages.Values.Sum();
|
||||
}
|
||||
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
NewMessageReceived?.Invoke(
|
||||
this,
|
||||
new TicketInboxMessageEventArgs(notification, unreadCount));
|
||||
UnreadCountChanged?.Invoke(
|
||||
this,
|
||||
new TicketUnreadCountChangedEventArgs(unreadCount));
|
||||
}));
|
||||
}
|
||||
|
||||
private void RaiseUnreadCountChanged()
|
||||
{
|
||||
var unreadCount = UnreadCount;
|
||||
_dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
UnreadCountChanged?.Invoke(
|
||||
this,
|
||||
new TicketUnreadCountChangedEventArgs(unreadCount));
|
||||
}));
|
||||
}
|
||||
|
||||
private static bool IsMessageAdded(TicketRealtimeNotification notification)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(notification.MessageId))
|
||||
return true;
|
||||
return !string.IsNullOrWhiteSpace(notification.Type)
|
||||
&& notification.Type.IndexOf(
|
||||
"message_added",
|
||||
StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string BuildTicketKey(string appId, string wechatOrderId)
|
||||
{
|
||||
return appId + "|" + wechatOrderId;
|
||||
}
|
||||
|
||||
private static string BuildFingerprint(TicketClientOptions options)
|
||||
{
|
||||
var appIds = (options.AppIds ?? Enumerable.Empty<string>())
|
||||
.Where(appId => !string.IsNullOrWhiteSpace(appId))
|
||||
.Select(appId => appId.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(appId => appId, StringComparer.Ordinal);
|
||||
return options.UserName + "|" + string.Join(",", appIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主页面收到的工单新消息事件参数。
|
||||
/// </summary>
|
||||
public sealed class TicketInboxMessageEventArgs : EventArgs
|
||||
{
|
||||
public TicketInboxMessageEventArgs(
|
||||
TicketRealtimeNotification notification,
|
||||
int unreadCount)
|
||||
{
|
||||
Notification = notification;
|
||||
UnreadCount = unreadCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务端推送的原始通知。
|
||||
/// </summary>
|
||||
public TicketRealtimeNotification Notification { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 收到该通知后的未读工单数量。
|
||||
/// </summary>
|
||||
public int UnreadCount { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 未读工单数量变化事件参数。
|
||||
/// </summary>
|
||||
public sealed class TicketUnreadCountChangedEventArgs : EventArgs
|
||||
{
|
||||
public TicketUnreadCountChangedEventArgs(int unreadCount)
|
||||
{
|
||||
UnreadCount = unreadCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前未读工单数量。
|
||||
/// </summary>
|
||||
public int UnreadCount { get; }
|
||||
}
|
||||
Reference in new issue
Block a user