diff --git a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs index 3fc6678..e2ee8b9 100644 --- a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs +++ b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs @@ -1,549 +1,536 @@ using System.Collections.Generic; -using System.Runtime.Serialization; +using Newtonsoft.Json; namespace OrderSupportTicket.Models.Api; /// /// 创建工单请求。 /// -[DataContract] internal sealed class CreateWorkOrderRequest { /// /// 拍单账号。 /// - [DataMember(Name = "purchaseAccount")] + ////[JsonProperty("purchaseAccount")] public string PurchaseAccount { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 微信订单号,保持为数字字符串。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 首条问题描述,长度 1~2000 字。 /// - [DataMember(Name = "description")] + ////[JsonProperty("description")] public string Description { get; set; } /// /// 可选的创建幂等编号。 /// - [DataMember(Name = "clientRequestId", EmitDefaultValue = false)] + ////[JsonProperty("clientRequestId", NullValueHandling = NullValueHandling.Ignore)] public string ClientRequestId { get; set; } } /// /// 工单摘要列表请求。 /// -[DataContract] internal sealed class ListWorkOrdersRequest { /// /// 拍单账号。 /// - [DataMember(Name = "purchaseAccount")] + ////[JsonProperty("purchaseAccount")] public string PurchaseAccount { get; set; } /// /// 要查询的店铺 APPID 数组。 /// - [DataMember(Name = "appIds")] + ////[JsonProperty("appIds")] public IList AppIds { get; set; } /// /// 可选的微信订单号精确筛选条件。 /// - [DataMember(Name = "wechatOrderId", EmitDefaultValue = false)] + ////[JsonProperty("wechatOrderId", NullValueHandling = NullValueHandling.Ignore)] public string WechatOrderId { get; set; } /// /// 可选的工单状态筛选条件。 /// - [DataMember(Name = "status", EmitDefaultValue = false)] + ////[JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] public string Status { get; set; } /// /// 本页最大返回数量,范围 1~100。 /// - [DataMember(Name = "limit")] + ////[JsonProperty("limit")] public int Limit { get; set; } /// /// 下一页游标,使用服务端返回的原始值。 /// - [DataMember(Name = "cursor", EmitDefaultValue = false)] + ////[JsonProperty("cursor", NullValueHandling = NullValueHandling.Ignore)] public string Cursor { get; set; } } /// /// 查询订单留言板请求。 /// -[DataContract] internal sealed class GetMessagesRequest { /// /// 拍单账号。 /// - [DataMember(Name = "purchaseAccount")] + ////[JsonProperty("purchaseAccount")] public string PurchaseAccount { get; set; } /// /// 微信订单号,保持为数字字符串。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 本页最大返回数量,范围 1~100。 /// - [DataMember(Name = "limit")] + ////[JsonProperty("limit")] public int Limit { get; set; } /// /// 加载更早留言时使用的 before 游标。 /// - [DataMember(Name = "before", EmitDefaultValue = false)] + ////[JsonProperty("before", NullValueHandling = NullValueHandling.Ignore)] public string Before { get; set; } } /// /// 提交客户留言请求。 /// -[DataContract] internal sealed class SendMessageRequest { /// /// 拍单账号。 /// - [DataMember(Name = "purchaseAccount")] + ////[JsonProperty("purchaseAccount")] public string PurchaseAccount { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 留言幂等编号。 /// - [DataMember(Name = "clientMessageId")] + ////[JsonProperty("clientMessageId")] public string ClientMessageId { get; set; } /// /// 留言正文,长度 1~4000 字。 /// - [DataMember(Name = "text")] + ////[JsonProperty("text")] public string Text { get; set; } } /// /// 标记留言已读请求。 /// -[DataContract] internal sealed class MarkReadRequest { /// /// 拍单账号。 /// - [DataMember(Name = "purchaseAccount")] + ////[JsonProperty("purchaseAccount")] public string PurchaseAccount { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 已读到的最大留言序号。 /// - [DataMember(Name = "throughSequenceNo")] + ////[JsonProperty("throughSequenceNo")] public int ThroughSequenceNo { get; set; } } /// /// 工单摘要分页响应。 /// -[DataContract] public sealed class WorkOrderListResponse { /// /// 请求是否成功。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } /// /// 当前页工单摘要。 /// - [DataMember(Name = "items")] + ////[JsonProperty("items")] public List Items { get; set; } /// /// 是否还有下一页。 /// - [DataMember(Name = "hasMore")] + ////[JsonProperty("hasMore")] public bool HasMore { get; set; } /// /// 下一页游标。 /// - [DataMember(Name = "nextCursor")] + ////[JsonProperty("nextCursor")] public string NextCursor { get; set; } /// /// 接口返回的身份校验标记,当前恒为 false。 /// - [DataMember(Name = "identityVerified")] + ////[JsonProperty("identityVerified")] public bool IdentityVerified { get; set; } } /// /// 单个订单的工单摘要。 /// -[DataContract] public sealed class WorkOrderSummaryDto { /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 微信订单号,保持为数字字符串。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 工单状态编码。 /// - [DataMember(Name = "status")] + ////[JsonProperty("status")] public string Status { get; set; } /// /// 工单状态中文文本。 /// - [DataMember(Name = "statusText")] + ////[JsonProperty("statusText")] public string StatusText { get; set; } /// /// 工单创建时间,由接口以 ISO 8601 字符串传输。 /// - [DataMember(Name = "createdAt")] + ////[JsonProperty("createdAt")] public string CreatedAt { get; set; } /// /// 工单更新时间,由接口以 ISO 8601 字符串传输。 /// - [DataMember(Name = "updatedAt")] + ////[JsonProperty("updatedAt")] public string UpdatedAt { get; set; } /// /// 最近一条公开留言时间,由接口以 ISO 8601 字符串传输。 /// - [DataMember(Name = "lastMessageAt")] + ////[JsonProperty("lastMessageAt")] public string LastMessageAt { get; set; } /// /// 最近一次客服正式回复时间;从未回复时为 null。 /// - [DataMember(Name = "lastReplyAt")] + ////[JsonProperty("lastReplyAt")] public string LastReplyAt { get; set; } /// /// 最近一条公开留言的发送方类型。 /// - [DataMember(Name = "lastSenderType")] + ////[JsonProperty("lastSenderType")] public string LastSenderType { get; set; } /// /// 未读留言数量。 /// - [DataMember(Name = "unreadCount")] + ////[JsonProperty("unreadCount")] public int UnreadCount { get; set; } /// /// 工单版本号,按接口返回的字符串原样保存。 /// - [DataMember(Name = "version")] + ////[JsonProperty("version")] public string Version { get; set; } } /// /// 订单留言板响应。 /// -[DataContract] public sealed class WorkOrderMessagesResponse { /// /// 请求是否成功。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 微信订单号。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 当前页公开留言,按 sequenceNo 升序排列。 /// - [DataMember(Name = "items")] + ////[JsonProperty("items")] public List Items { get; set; } /// /// 是否还有更早的留言。 /// - [DataMember(Name = "hasMore")] + ////[JsonProperty("hasMore")] public bool HasMore { get; set; } /// /// 加载更早留言时使用的 before 游标。 /// - [DataMember(Name = "nextBefore")] + ////[JsonProperty("nextBefore")] public string NextBefore { get; set; } /// /// 接口返回的身份校验标记,当前恒为 false。 /// - [DataMember(Name = "identityVerified")] + ////[JsonProperty("identityVerified")] public bool IdentityVerified { get; set; } } /// /// 单条公开留言。 /// -[DataContract] public sealed class TicketMessageDto { /// /// 消息唯一 ID,用于去重。 /// - [DataMember(Name = "messageId")] + ////[JsonProperty("messageId")] public string MessageId { get; set; } /// /// 工单内连续递增的消息序号。 /// - [DataMember(Name = "sequenceNo")] + ////[JsonProperty("sequenceNo")] public int SequenceNo { get; set; } /// /// 发送方类型,例如 customer。 /// - [DataMember(Name = "senderType")] + ////[JsonProperty("senderType")] public string SenderType { get; set; } /// /// 发送方显示名称。 /// - [DataMember(Name = "senderName")] + ////[JsonProperty("senderName")] public string SenderName { get; set; } /// /// 留言正文;消息撤回后为空字符串。 /// - [DataMember(Name = "text")] + ////[JsonProperty("text")] public string Text { get; set; } /// /// 消息是否已撤回。 /// - [DataMember(Name = "recalled")] + ////[JsonProperty("recalled")] public bool Recalled { get; set; } /// /// 消息发送时间,由接口以 ISO 8601 字符串传输。 /// - [DataMember(Name = "createdAt")] + ////[JsonProperty("createdAt")] public string CreatedAt { get; set; } } /// /// 创建工单响应。 /// -[DataContract] public sealed class CreateWorkOrderResponse { /// /// 请求是否成功。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } /// /// 创建结果,取值 created、existing 或 reopened。 /// - [DataMember(Name = "operation")] + ////[JsonProperty("operation")] public string Operation { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 微信订单号。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 操作后的工单状态。 /// - [DataMember(Name = "status")] + ////[JsonProperty("status")] public string Status { get; set; } /// /// 创建或重开时追加的首条消息 ID。 /// - [DataMember(Name = "messageId")] + ////[JsonProperty("messageId")] public string MessageId { get; set; } /// /// 操作后的工单版本号字符串。 /// - [DataMember(Name = "version")] + ////[JsonProperty("version")] public string Version { get; set; } /// /// 是否为幂等编号对应的历史回执。 /// - [DataMember(Name = "replayed")] + ////[JsonProperty("replayed")] public bool Replayed { get; set; } /// /// 接口返回的身份校验标记,当前恒为 false。 /// - [DataMember(Name = "identityVerified")] + ////[JsonProperty("identityVerified")] public bool IdentityVerified { get; set; } } /// /// 提交客户留言响应。 /// -[DataContract] public sealed class SendMessageResponse { /// /// 请求是否成功。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } /// /// 订单所属店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 微信订单号。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 服务端正式生成的消息对象。 /// - [DataMember(Name = "message")] + ////[JsonProperty("message")] public TicketMessageDto Message { get; set; } /// /// 留言后的工单状态。 /// - [DataMember(Name = "status")] + ////[JsonProperty("status")] public string Status { get; set; } /// /// 留言后的工单版本号字符串。 /// - [DataMember(Name = "version")] + ////[JsonProperty("version")] public string Version { get; set; } /// /// 本次留言是否重新开启了已完结工单。 /// - [DataMember(Name = "reopened")] + ////[JsonProperty("reopened")] public bool Reopened { get; set; } /// /// 是否为幂等编号对应的历史回执。 /// - [DataMember(Name = "replayed")] + ////[JsonProperty("replayed")] public bool Replayed { get; set; } /// /// 接口返回的身份校验标记,当前恒为 false。 /// - [DataMember(Name = "identityVerified")] + ////[JsonProperty("identityVerified")] public bool IdentityVerified { get; set; } } /// /// 通用成功状态响应,仅用于在解析具体业务对象前检查顶层 success。 /// -[DataContract] internal sealed class ApiSuccessResponse { /// /// 请求是否成功。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } } /// /// 工单接口错误响应。 /// -[DataContract] internal sealed class ApiErrorResponse { /// /// 请求是否成功,错误响应通常为 false。 /// - [DataMember(Name = "success")] + ////[JsonProperty("success")] public bool Success { get; set; } /// /// 可展示给用户的错误说明。 /// - [DataMember(Name = "message")] + ////[JsonProperty("message")] public string Message { get; set; } /// /// 兼容旧接口的错误说明字段。 /// - [DataMember(Name = "error")] + ////[JsonProperty("error")] public string Error { get; set; } /// /// 服务端错误编码。 /// - [DataMember(Name = "code")] + ////[JsonProperty("code")] public string Code { get; set; } } diff --git a/OrderSupportTicket/Models/TicketRealtimeNotification.cs b/OrderSupportTicket/Models/TicketRealtimeNotification.cs index f9b19e2..8ff2725 100644 --- a/OrderSupportTicket/Models/TicketRealtimeNotification.cs +++ b/OrderSupportTicket/Models/TicketRealtimeNotification.cs @@ -1,58 +1,58 @@ -using System.Runtime.Serialization; +using Newtonsoft.Json; namespace OrderSupportTicket.Models; /// /// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。 /// -[DataContract] public sealed class TicketRealtimeNotification { /// /// 服务端事件 ID,用于至少一次投递时的去重。 /// - [DataMember(Name = "eventId")] + ////[JsonProperty("eventId")] public string EventId { get; set; } /// /// 事件类型,例如 relay.ticket.message_added 或 resync_required。 /// - [DataMember(Name = "type")] + ////[JsonProperty("type")] public string Type { get; set; } /// /// 发生变更的店铺 APPID。 /// - [DataMember(Name = "appId")] + ////[JsonProperty("appId")] public string AppId { get; set; } /// /// 发生变更的微信订单号,保持为数字字符串。 /// - [DataMember(Name = "wechatOrderId")] + ////[JsonProperty("wechatOrderId")] public string WechatOrderId { get; set; } /// /// 新增消息的消息 ID。 /// - [DataMember(Name = "messageId")] + ////[JsonProperty("messageId")] public string MessageId { get; set; } /// /// 工单版本号,按服务端返回的字符串原样保存。 /// - [DataMember(Name = "version")] + ////[JsonProperty("version")] public string Version { get; set; } /// /// 事件发生时间,保留服务端 ISO 8601 字符串。 /// - [DataMember(Name = "occurredAt")] + ////[JsonProperty("occurredAt")] public string OccurredAt { get; set; } /// /// 是否要求客户端执行全量重新同步。 /// + [JsonIgnore] public bool IsResyncRequired => string.Equals(Type, "resync_required", System.StringComparison.OrdinalIgnoreCase); } diff --git a/OrderSupportTicket/OrderSupportTicket.csproj b/OrderSupportTicket/OrderSupportTicket.csproj index d15a26e..cfaaea6 100644 --- a/OrderSupportTicket/OrderSupportTicket.csproj +++ b/OrderSupportTicket/OrderSupportTicket.csproj @@ -11,7 +11,6 @@ - @@ -23,7 +22,6 @@ - ..\DllReference\NaiBa.Util.dll true diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index ca3e573..eb5bcf9 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -1,11 +1,9 @@ using LaunchDarkly.EventSource; +using Newtonsoft.Json; using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; using System; -using System.IO; using System.Linq; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Json; -using System.Text; using System.Threading.Tasks; namespace OrderSupportTicket.Services; @@ -134,14 +132,9 @@ public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService TicketRealtimeNotification notification; try { - var serializer = new DataContractJsonSerializer( - typeof(TicketRealtimeNotification)); - using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(data))) - { - notification = (TicketRealtimeNotification)serializer.ReadObject(stream); - } + notification = JsonConvert.DeserializeObject(data, TicketApiService.JsonSettings); } - catch (SerializationException) + catch (JsonException) { return; } diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs index 14d6fb3..9d78453 100644 --- a/OrderSupportTicket/Services/TicketApiService.cs +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -1,13 +1,11 @@ using AllBase; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Json; -using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using WxOrder.Framework.Commons; @@ -29,6 +27,11 @@ 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); + internal static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; static TicketApiService() { if (EnvironmentHelper.IsTest) @@ -259,8 +262,8 @@ public sealed class TicketApiService : ITicketApiService object request) { var url = BuildUri(path); - // 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。 - var json = Serialize(request); + // 请求 DTO 使用 JsonProperty 驼峰字段,先序列化后走 GetHtml 的字符串重载。 + var json = JsonConvert.SerializeObject(request, JsonSettings); var html = await GetHtml.PostJsonAsync(url, json); if (string.IsNullOrWhiteSpace(html)) throw RequestFailed("工单接口未返回数据。"); @@ -275,9 +278,9 @@ public sealed class TicketApiService : ITicketApiService ApiSuccessResponse response; try { - response = Deserialize(html); + response = JsonConvert.DeserializeObject(html, JsonSettings); } - catch (SerializationException) + catch (JsonException) { throw RequestFailed(html); } @@ -296,12 +299,12 @@ public sealed class TicketApiService : ITicketApiService EnsureSuccessBody(html); try { - var result = Deserialize(html); + var result = JsonConvert.DeserializeObject(html, JsonSettings); if (result == null) - throw new SerializationException("接口响应为空。"); + throw new JsonSerializationException("接口响应为空。"); return result; } - catch (SerializationException) + catch (JsonException) { throw RequestFailed("工单接口返回了无法解析的数据。", html); } @@ -314,38 +317,19 @@ public sealed class TicketApiService : ITicketApiService try { - var error = Deserialize(body); + var error = JsonConvert.DeserializeObject(body, JsonSettings); return error == null ? null : error.Message ?? error.Error ?? error.Code; } - catch (SerializationException) + catch (JsonException) { return null; } } - private static string Serialize(object value) - { - var serializer = new DataContractJsonSerializer(value.GetType()); - using (var stream = new MemoryStream()) - { - serializer.WriteObject(stream, value); - return Encoding.UTF8.GetString(stream.ToArray()); - } - } - - private static T Deserialize(string json) - { - var serializer = new DataContractJsonSerializer(typeof(T)); - using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json ?? string.Empty))) - { - return (T)serializer.ReadObject(stream); - } - } - private static string BuildUri(string path) { return ServerUrl + path; @@ -368,11 +352,11 @@ public sealed class TicketApiService : ITicketApiService try { - var error = Deserialize(responseBody); + var error = JsonConvert.DeserializeObject(responseBody, JsonSettings); if (error != null && int.TryParse(error.Code, out var code)) return code; } - catch (SerializationException) + catch (JsonException) { // GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。 } diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs index 953bee4..4ce2cc3 100644 --- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs @@ -2,6 +2,7 @@ using _29WxOrderWpf.ToolCtrls; using OrderSupportTicket.Models; using OrderSupportTicket.Services; using OrderSupportTicket.ViewModels; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -154,16 +155,15 @@ public partial class OrderSupportTicketView : UserControl if (window == null) return; - try - { - Clipboard.SetText(orderId); - ToastHelper.Show(window, "订单号已复制"); - } - catch (Exception ex) - { - ToastHelper.Show(window, "复制失败:" + ex.Message, ToastType.Error); - } - } + if (ClipboardHelper.TrySetText(orderId)) + { + ToastHelper.Show(window, "订单号已复制"); + } + else + { + ToastHelper.Show(window, "复制失败"); + } + } private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) { diff --git a/WxOrder.Framework/Commons/ClipboardHelper.cs b/WxOrder.Framework/Commons/ClipboardHelper.cs new file mode 100644 index 0000000..6a377e3 --- /dev/null +++ b/WxOrder.Framework/Commons/ClipboardHelper.cs @@ -0,0 +1,224 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using SysClipboard = System.Windows.Clipboard; + +/// +/// 剪贴板帮助类(WinForms / WPF 通用) +/// 解决的问题: +/// 1. CLIPBRD_E_CANT_OPEN (0x800401D0) 等 COMException / ExternalException +/// (剪贴板被其他进程占用,如远程桌面、剪贴板管理器、杀毒软件、微信/QQ 等) +/// 2. 非 STA 线程调用导致的 ThreadStateException +/// 3. 空字符串 / null 导致的 ArgumentNullException +/// +public static class ClipboardHelper +{ + /// 默认重试次数 + public static int DefaultRetryCount { get; set; } = 10; + + /// 默认重试间隔(毫秒) + public static int DefaultRetryDelayMs { get; set; } = 100; + + #region 同步 API + + /// 复制文本,失败返回 false,不抛异常 + public static bool TrySetText(string text, int retryCount = -1, int retryDelayMs = -1) + { + return TryExecute(() => + { + if (string.IsNullOrEmpty(text)) + SysClipboard.Clear(); + else + SetDataObjectCore(text); + return true; + }, out _, retryCount, retryDelayMs); + } + + /// 复制任意数据(文本、文件列表、图片、DataObject 等) + public static bool TrySetData(object data, int retryCount = -1, int retryDelayMs = -1) + { + if (data == null) return false; + return TryExecute(() => + { + SetDataObjectCore(data); + return true; + }, out _, retryCount, retryDelayMs); + } + + /// 读取文本,失败或无文本返回 null + public static string TryGetText(int retryCount = -1, int retryDelayMs = -1) + { + TryExecute(() => SysClipboard.ContainsText() ? SysClipboard.GetText() : null, + out string result, retryCount, retryDelayMs); + return result; + } + + /// 清空剪贴板 + public static bool TryClear(int retryCount = -1, int retryDelayMs = -1) + { + return TryExecute(() => { SysClipboard.Clear(); return true; }, + out _, retryCount, retryDelayMs); + } + + /// 复制文本,失败时抛出最后一次的异常 + public static void SetText(string text, int retryCount = -1, int retryDelayMs = -1) + { + Execute(() => + { + if (string.IsNullOrEmpty(text)) + SysClipboard.Clear(); + else + SetDataObjectCore(text); + return true; + }, retryCount, retryDelayMs); + } + + #endregion + + #region 异步 API(不阻塞 UI 线程,建议在 UI 线程 await 调用) + + public static async Task TrySetTextAsync(string text, int retryCount = -1, int retryDelayMs = -1) + { + retryCount = retryCount < 0 ? DefaultRetryCount : retryCount; + retryDelayMs = retryDelayMs < 0 ? DefaultRetryDelayMs : retryDelayMs; + + for (int i = 0; i < retryCount; i++) + { + try + { + RunSta(() => + { + if (string.IsNullOrEmpty(text)) + SysClipboard.Clear(); + else + SetDataObjectCore(text); + return true; + }); + return true; + } + catch (Exception ex) when (IsRetryable(ex)) + { + if (i < retryCount - 1) + await Task.Delay(retryDelayMs).ConfigureAwait(true); // 回到 UI 线程 + } + catch + { + return false; + } + } + return false; + } + + public static async Task TryGetTextAsync(int retryCount = -1, int retryDelayMs = -1) + { + retryCount = retryCount < 0 ? DefaultRetryCount : retryCount; + retryDelayMs = retryDelayMs < 0 ? DefaultRetryDelayMs : retryDelayMs; + + for (int i = 0; i < retryCount; i++) + { + try + { + return RunSta(() => SysClipboard.ContainsText() ? SysClipboard.GetText() : null); + } + catch (Exception ex) when (IsRetryable(ex)) + { + if (i < retryCount - 1) + await Task.Delay(retryDelayMs).ConfigureAwait(true); + } + catch + { + return null; + } + } + return null; + } + + #endregion + + #region 内部实现 + + private static void SetDataObjectCore(object data) + { + SysClipboard.SetDataObject(data, true); + } + + private static T Execute(Func action, int retryCount, int retryDelayMs) + { + retryCount = retryCount < 0 ? DefaultRetryCount : retryCount; + retryDelayMs = retryDelayMs < 0 ? DefaultRetryDelayMs : retryDelayMs; + if (retryCount < 1) retryCount = 1; + + for (int i = 0; ; i++) + { + try + { + return RunSta(action); + } + catch (Exception ex) when (IsRetryable(ex) && i < retryCount - 1) + { + Thread.Sleep(retryDelayMs); + } + } + } + + private static bool TryExecute(Func action, out T result, int retryCount, int retryDelayMs) + { + try + { + result = Execute(action, retryCount, retryDelayMs); + return true; + } + catch + { + result = default(T); + return false; + } + } + + /// 剪贴板必须在 STA 线程访问;当前不是 STA 就新建一个 STA 线程执行 + private static T RunSta(Func action) + { + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + return action(); + + T result = default(T); + Exception error = null; + var thread = new Thread(() => + { + try { result = action(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error != null) + { + // 保留原始异常类型,便于上层判断 + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error).Throw(); + } + return result; + } + + /// + /// 判断是否为"剪贴板暂时被占用"类的可重试异常 + /// COMException 继承自 ExternalException,所以一起覆盖 + /// + private static bool IsRetryable(Exception ex) + { + if (ex is ExternalException) + { + // 0x800401D0 CLIPBRD_E_CANT_OPEN + // 0x800401D1 CLIPBRD_E_CANT_EMPTY + // 0x800401D2 CLIPBRD_E_CANT_SET + // 0x800401D3 CLIPBRD_E_BAD_DATA + // 0x800401D4 CLIPBRD_E_CANT_CLOSE + // WinForms 的 "Requested Clipboard operation did not succeed" 也是 ExternalException + return true; + } + return false; + } + + #endregion +} \ No newline at end of file