diff --git a/29WxOrder.sln b/29WxOrder.sln
index 56fb0f0..98ea212 100644
--- a/29WxOrder.sln
+++ b/29WxOrder.sln
@@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".test", ".test", "{14261B9B
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WXOrder.Test", "test\WXOrder.Test\WXOrder.Test.csproj", "{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderSupportTicket", "OrderSupportTicket\OrderSupportTicket.csproj", "{22D31944-2087-437A-B374-C0874E9EC53A}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -117,6 +119,22 @@ Global
{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x64.Build.0 = Release|Any CPU
{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x86.ActiveCfg = Release|Any CPU
{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x86.Build.0 = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x64.ActiveCfg = Debug|x64
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x64.Build.0 = Debug|x64
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x86.Build.0 = Debug|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x64.ActiveCfg = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x64.Build.0 = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x86.ActiveCfg = Release|Any CPU
+ {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs b/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs
new file mode 100644
index 0000000..091ce91
--- /dev/null
+++ b/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Threading;
+
+namespace OrderSupportTicket.Behaviors;
+
+///
+/// 仅承载视图交互:列表点击命令、消息列表滚动到底部。
+///
+public static class ListBoxInteractionBehavior
+{
+ public static readonly DependencyProperty SelectedItemCommandProperty =
+ DependencyProperty.RegisterAttached(
+ "SelectedItemCommand",
+ typeof(ICommand),
+ typeof(ListBoxInteractionBehavior),
+ new PropertyMetadata(null, OnSelectedItemCommandChanged));
+
+ public static readonly DependencyProperty ScrollVersionProperty =
+ DependencyProperty.RegisterAttached(
+ "ScrollVersion",
+ typeof(int),
+ typeof(ListBoxInteractionBehavior),
+ new PropertyMetadata(0, OnScrollVersionChanged));
+
+ public static void SetSelectedItemCommand(DependencyObject element, ICommand value)
+ {
+ element.SetValue(SelectedItemCommandProperty, value);
+ }
+
+ public static ICommand GetSelectedItemCommand(DependencyObject element)
+ {
+ return (ICommand)element.GetValue(SelectedItemCommandProperty);
+ }
+
+ public static void SetScrollVersion(DependencyObject element, int value)
+ {
+ element.SetValue(ScrollVersionProperty, value);
+ }
+
+ public static int GetScrollVersion(DependencyObject element)
+ {
+ return (int)element.GetValue(ScrollVersionProperty);
+ }
+
+ private static void OnSelectedItemCommandChanged(
+ DependencyObject d,
+ DependencyPropertyChangedEventArgs e)
+ {
+ if (!(d is ListBox listBox))
+ return;
+
+ listBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp;
+ listBox.PreviewKeyUp -= OnPreviewKeyUp;
+ if (e.NewValue is ICommand)
+ {
+ listBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp;
+ listBox.PreviewKeyUp += OnPreviewKeyUp;
+ }
+ }
+
+ private static void OnPreviewMouseLeftButtonUp(
+ object sender,
+ MouseButtonEventArgs e)
+ {
+ ExecuteForSelectedItem(sender as ListBox);
+ }
+
+ private static void OnPreviewKeyUp(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Enter && e.Key != Key.Space)
+ return;
+ ExecuteForSelectedItem(sender as ListBox);
+ }
+
+ private static void ExecuteForSelectedItem(ListBox listBox)
+ {
+ if (listBox == null || listBox.SelectedItem == null)
+ return;
+ var command = GetSelectedItemCommand(listBox);
+ if (command != null && command.CanExecute(listBox.SelectedItem))
+ command.Execute(listBox.SelectedItem);
+ }
+
+ private static void OnScrollVersionChanged(
+ DependencyObject d,
+ DependencyPropertyChangedEventArgs e)
+ {
+ if (!(d is ListBox listBox))
+ return;
+
+ listBox.Dispatcher.BeginInvoke(
+ new Action(() =>
+ {
+ var scrollViewer = FindVisualChild(listBox);
+ if (scrollViewer != null)
+ scrollViewer.ScrollToEnd();
+ }),
+ DispatcherPriority.Background);
+ }
+
+ private static T FindVisualChild(DependencyObject parent)
+ where T : DependencyObject
+ {
+ if (parent == null)
+ return null;
+ for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (child is T match)
+ return match;
+ var nested = FindVisualChild(child);
+ if (nested != null)
+ return nested;
+ }
+ return null;
+ }
+}
diff --git a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs
new file mode 100644
index 0000000..3fc6678
--- /dev/null
+++ b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs
@@ -0,0 +1,549 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+namespace OrderSupportTicket.Models.Api;
+
+///
+/// 创建工单请求。
+///
+[DataContract]
+internal sealed class CreateWorkOrderRequest
+{
+ ///
+ /// 拍单账号。
+ ///
+ [DataMember(Name = "purchaseAccount")]
+ public string PurchaseAccount { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号,保持为数字字符串。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 首条问题描述,长度 1~2000 字。
+ ///
+ [DataMember(Name = "description")]
+ public string Description { get; set; }
+
+ ///
+ /// 可选的创建幂等编号。
+ ///
+ [DataMember(Name = "clientRequestId", EmitDefaultValue = false)]
+ public string ClientRequestId { get; set; }
+}
+
+///
+/// 工单摘要列表请求。
+///
+[DataContract]
+internal sealed class ListWorkOrdersRequest
+{
+ ///
+ /// 拍单账号。
+ ///
+ [DataMember(Name = "purchaseAccount")]
+ public string PurchaseAccount { get; set; }
+
+ ///
+ /// 要查询的店铺 APPID 数组。
+ ///
+ [DataMember(Name = "appIds")]
+ public IList AppIds { get; set; }
+
+ ///
+ /// 可选的微信订单号精确筛选条件。
+ ///
+ [DataMember(Name = "wechatOrderId", EmitDefaultValue = false)]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 可选的工单状态筛选条件。
+ ///
+ [DataMember(Name = "status", EmitDefaultValue = false)]
+ public string Status { get; set; }
+
+ ///
+ /// 本页最大返回数量,范围 1~100。
+ ///
+ [DataMember(Name = "limit")]
+ public int Limit { get; set; }
+
+ ///
+ /// 下一页游标,使用服务端返回的原始值。
+ ///
+ [DataMember(Name = "cursor", EmitDefaultValue = false)]
+ public string Cursor { get; set; }
+}
+
+///
+/// 查询订单留言板请求。
+///
+[DataContract]
+internal sealed class GetMessagesRequest
+{
+ ///
+ /// 拍单账号。
+ ///
+ [DataMember(Name = "purchaseAccount")]
+ public string PurchaseAccount { get; set; }
+ ///
+ /// 微信订单号,保持为数字字符串。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 本页最大返回数量,范围 1~100。
+ ///
+ [DataMember(Name = "limit")]
+ public int Limit { get; set; }
+
+ ///
+ /// 加载更早留言时使用的 before 游标。
+ ///
+ [DataMember(Name = "before", EmitDefaultValue = false)]
+ public string Before { get; set; }
+}
+
+///
+/// 提交客户留言请求。
+///
+[DataContract]
+internal sealed class SendMessageRequest
+{
+ ///
+ /// 拍单账号。
+ ///
+ [DataMember(Name = "purchaseAccount")]
+ public string PurchaseAccount { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 留言幂等编号。
+ ///
+ [DataMember(Name = "clientMessageId")]
+ public string ClientMessageId { get; set; }
+
+ ///
+ /// 留言正文,长度 1~4000 字。
+ ///
+ [DataMember(Name = "text")]
+ public string Text { get; set; }
+}
+
+///
+/// 标记留言已读请求。
+///
+[DataContract]
+internal sealed class MarkReadRequest
+{
+ ///
+ /// 拍单账号。
+ ///
+ [DataMember(Name = "purchaseAccount")]
+ public string PurchaseAccount { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 已读到的最大留言序号。
+ ///
+ [DataMember(Name = "throughSequenceNo")]
+ public int ThroughSequenceNo { get; set; }
+}
+
+///
+/// 工单摘要分页响应。
+///
+[DataContract]
+public sealed class WorkOrderListResponse
+{
+ ///
+ /// 请求是否成功。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+
+ ///
+ /// 当前页工单摘要。
+ ///
+ [DataMember(Name = "items")]
+ public List Items { get; set; }
+
+ ///
+ /// 是否还有下一页。
+ ///
+ [DataMember(Name = "hasMore")]
+ public bool HasMore { get; set; }
+
+ ///
+ /// 下一页游标。
+ ///
+ [DataMember(Name = "nextCursor")]
+ public string NextCursor { get; set; }
+
+ ///
+ /// 接口返回的身份校验标记,当前恒为 false。
+ ///
+ [DataMember(Name = "identityVerified")]
+ public bool IdentityVerified { get; set; }
+}
+
+///
+/// 单个订单的工单摘要。
+///
+[DataContract]
+public sealed class WorkOrderSummaryDto
+{
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号,保持为数字字符串。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 工单状态编码。
+ ///
+ [DataMember(Name = "status")]
+ public string Status { get; set; }
+
+ ///
+ /// 工单状态中文文本。
+ ///
+ [DataMember(Name = "statusText")]
+ public string StatusText { get; set; }
+
+ ///
+ /// 工单创建时间,由接口以 ISO 8601 字符串传输。
+ ///
+ [DataMember(Name = "createdAt")]
+ public string CreatedAt { get; set; }
+
+ ///
+ /// 工单更新时间,由接口以 ISO 8601 字符串传输。
+ ///
+ [DataMember(Name = "updatedAt")]
+ public string UpdatedAt { get; set; }
+
+ ///
+ /// 最近一条公开留言时间,由接口以 ISO 8601 字符串传输。
+ ///
+ [DataMember(Name = "lastMessageAt")]
+ public string LastMessageAt { get; set; }
+
+ ///
+ /// 最近一次客服正式回复时间;从未回复时为 null。
+ ///
+ [DataMember(Name = "lastReplyAt")]
+ public string LastReplyAt { get; set; }
+
+ ///
+ /// 最近一条公开留言的发送方类型。
+ ///
+ [DataMember(Name = "lastSenderType")]
+ public string LastSenderType { get; set; }
+
+ ///
+ /// 未读留言数量。
+ ///
+ [DataMember(Name = "unreadCount")]
+ public int UnreadCount { get; set; }
+
+ ///
+ /// 工单版本号,按接口返回的字符串原样保存。
+ ///
+ [DataMember(Name = "version")]
+ public string Version { get; set; }
+}
+
+///
+/// 订单留言板响应。
+///
+[DataContract]
+public sealed class WorkOrderMessagesResponse
+{
+ ///
+ /// 请求是否成功。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 当前页公开留言,按 sequenceNo 升序排列。
+ ///
+ [DataMember(Name = "items")]
+ public List Items { get; set; }
+
+ ///
+ /// 是否还有更早的留言。
+ ///
+ [DataMember(Name = "hasMore")]
+ public bool HasMore { get; set; }
+
+ ///
+ /// 加载更早留言时使用的 before 游标。
+ ///
+ [DataMember(Name = "nextBefore")]
+ public string NextBefore { get; set; }
+
+ ///
+ /// 接口返回的身份校验标记,当前恒为 false。
+ ///
+ [DataMember(Name = "identityVerified")]
+ public bool IdentityVerified { get; set; }
+}
+
+///
+/// 单条公开留言。
+///
+[DataContract]
+public sealed class TicketMessageDto
+{
+ ///
+ /// 消息唯一 ID,用于去重。
+ ///
+ [DataMember(Name = "messageId")]
+ public string MessageId { get; set; }
+
+ ///
+ /// 工单内连续递增的消息序号。
+ ///
+ [DataMember(Name = "sequenceNo")]
+ public int SequenceNo { get; set; }
+
+ ///
+ /// 发送方类型,例如 customer。
+ ///
+ [DataMember(Name = "senderType")]
+ public string SenderType { get; set; }
+
+ ///
+ /// 发送方显示名称。
+ ///
+ [DataMember(Name = "senderName")]
+ public string SenderName { get; set; }
+
+ ///
+ /// 留言正文;消息撤回后为空字符串。
+ ///
+ [DataMember(Name = "text")]
+ public string Text { get; set; }
+
+ ///
+ /// 消息是否已撤回。
+ ///
+ [DataMember(Name = "recalled")]
+ public bool Recalled { get; set; }
+
+ ///
+ /// 消息发送时间,由接口以 ISO 8601 字符串传输。
+ ///
+ [DataMember(Name = "createdAt")]
+ public string CreatedAt { get; set; }
+}
+
+///
+/// 创建工单响应。
+///
+[DataContract]
+public sealed class CreateWorkOrderResponse
+{
+ ///
+ /// 请求是否成功。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+
+ ///
+ /// 创建结果,取值 created、existing 或 reopened。
+ ///
+ [DataMember(Name = "operation")]
+ public string Operation { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 操作后的工单状态。
+ ///
+ [DataMember(Name = "status")]
+ public string Status { get; set; }
+
+ ///
+ /// 创建或重开时追加的首条消息 ID。
+ ///
+ [DataMember(Name = "messageId")]
+ public string MessageId { get; set; }
+
+ ///
+ /// 操作后的工单版本号字符串。
+ ///
+ [DataMember(Name = "version")]
+ public string Version { get; set; }
+
+ ///
+ /// 是否为幂等编号对应的历史回执。
+ ///
+ [DataMember(Name = "replayed")]
+ public bool Replayed { get; set; }
+
+ ///
+ /// 接口返回的身份校验标记,当前恒为 false。
+ ///
+ [DataMember(Name = "identityVerified")]
+ public bool IdentityVerified { get; set; }
+}
+
+///
+/// 提交客户留言响应。
+///
+[DataContract]
+public sealed class SendMessageResponse
+{
+ ///
+ /// 请求是否成功。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 服务端正式生成的消息对象。
+ ///
+ [DataMember(Name = "message")]
+ public TicketMessageDto Message { get; set; }
+
+ ///
+ /// 留言后的工单状态。
+ ///
+ [DataMember(Name = "status")]
+ public string Status { get; set; }
+
+ ///
+ /// 留言后的工单版本号字符串。
+ ///
+ [DataMember(Name = "version")]
+ public string Version { get; set; }
+
+ ///
+ /// 本次留言是否重新开启了已完结工单。
+ ///
+ [DataMember(Name = "reopened")]
+ public bool Reopened { get; set; }
+
+ ///
+ /// 是否为幂等编号对应的历史回执。
+ ///
+ [DataMember(Name = "replayed")]
+ public bool Replayed { get; set; }
+
+ ///
+ /// 接口返回的身份校验标记,当前恒为 false。
+ ///
+ [DataMember(Name = "identityVerified")]
+ public bool IdentityVerified { get; set; }
+}
+
+///
+/// 通用成功状态响应,仅用于在解析具体业务对象前检查顶层 success。
+///
+[DataContract]
+internal sealed class ApiSuccessResponse
+{
+ ///
+ /// 请求是否成功。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+}
+
+///
+/// 工单接口错误响应。
+///
+[DataContract]
+internal sealed class ApiErrorResponse
+{
+ ///
+ /// 请求是否成功,错误响应通常为 false。
+ ///
+ [DataMember(Name = "success")]
+ public bool Success { get; set; }
+
+ ///
+ /// 可展示给用户的错误说明。
+ ///
+ [DataMember(Name = "message")]
+ public string Message { get; set; }
+
+ ///
+ /// 兼容旧接口的错误说明字段。
+ ///
+ [DataMember(Name = "error")]
+ public string Error { get; set; }
+
+ ///
+ /// 服务端错误编码。
+ ///
+ [DataMember(Name = "code")]
+ public string Code { get; set; }
+}
diff --git a/OrderSupportTicket/Models/TicketClientOptions.cs b/OrderSupportTicket/Models/TicketClientOptions.cs
new file mode 100644
index 0000000..19e3b8b
--- /dev/null
+++ b/OrderSupportTicket/Models/TicketClientOptions.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Configuration;
+using System.Globalization;
+using System.Linq;
+
+namespace OrderSupportTicket.Models;
+
+///
+/// 工单页运行参数。APPID 与拍单账号由宿主程序注入,页面内不硬编码账号。
+///
+public sealed class TicketClientOptions
+{
+ public TicketClientOptions()
+ {
+ AppIds = new List();
+ Orders = new List();
+ }
+
+ ///
+ /// 当前拍单账号,对应接口字段 purchaseAccount。
+ ///
+ public string UserName { get; set; }
+
+ ///
+ /// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。
+ ///
+ public IList AppIds { get; set; }
+
+ ///
+ /// 可关联到新工单的本地订单集合。
+ ///
+ public IList Orders { get; set; }
+
+ public static TicketClientOptions FromApplicationSettings()
+ {
+ var purchaseAccount =
+ ConfigurationManager.AppSettings["CustomerWorkOrder.PurchaseAccount"];
+ var appIds = ConfigurationManager.AppSettings["CustomerWorkOrder.AppIds"];
+
+ return new TicketClientOptions
+ {
+ UserName = purchaseAccount,
+ AppIds = (appIds ?? string.Empty)
+ .Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(a => a.Trim())
+ .Where(a => a.Length > 0)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList()
+ };
+ }
+}
+
+///
+/// 新建工单时需要在本地订单列表中关联的订单。
+/// 微信订单号始终是字符串,禁止转换为 Number。
+///
+public sealed class TicketOrderOption
+{
+ ///
+ /// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。
+ ///
+ public string AppId { get; set; }
+
+ ///
+ /// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。
+ ///
+ public string OrderId { get; set; }
+
+ ///
+ /// 商品名称,用于订单筛选和页面展示。
+ ///
+ public string GoodsName { get; set; }
+
+ ///
+ /// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// 订单下单时间。
+ ///
+ public DateTime OrderedAt { get; set; }
+
+ ///
+ /// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。
+ ///
+ public string DisplayText
+ {
+ get
+ {
+ var product = string.IsNullOrWhiteSpace(GoodsName)
+ ? "未命名商品"
+ : GoodsName;
+ return $"{OrderId} · {product}";
+ }
+ }
+
+ ///
+ /// 订单详情展示文本,包含商品名、金额和下单时间。
+ ///
+ public string ProductDescription
+ {
+ get
+ {
+ var product = string.IsNullOrWhiteSpace(GoodsName)
+ ? "未提供商品名称"
+ : GoodsName;
+ var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN"));
+ var orderedAt = OrderedAt == default
+ ? "下单时间未知"
+ : OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);
+ return $"商品:{product}\n金额:{amount} 下单时间:{orderedAt}";
+ }
+ }
+
+ ///
+ /// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
+ ///
+ public string OrderKey => $"{AppId}|{OrderId}";
+}
diff --git a/OrderSupportTicket/Models/TicketRealtimeNotification.cs b/OrderSupportTicket/Models/TicketRealtimeNotification.cs
new file mode 100644
index 0000000..f9b19e2
--- /dev/null
+++ b/OrderSupportTicket/Models/TicketRealtimeNotification.cs
@@ -0,0 +1,133 @@
+using System.Runtime.Serialization;
+
+namespace OrderSupportTicket.Models;
+
+///
+/// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。
+///
+[DataContract]
+public sealed class TicketRealtimeNotification
+{
+ ///
+ /// 服务端事件 ID,用于至少一次投递时的去重。
+ ///
+ [DataMember(Name = "eventId")]
+ public string EventId { get; set; }
+
+ ///
+ /// 事件类型,例如 relay.ticket.message_added 或 resync_required。
+ ///
+ [DataMember(Name = "type")]
+ public string Type { get; set; }
+
+ ///
+ /// 发生变更的店铺 APPID。
+ ///
+ [DataMember(Name = "appId")]
+ public string AppId { get; set; }
+
+ ///
+ /// 发生变更的微信订单号,保持为数字字符串。
+ ///
+ [DataMember(Name = "wechatOrderId")]
+ public string WechatOrderId { get; set; }
+
+ ///
+ /// 新增消息的消息 ID。
+ ///
+ [DataMember(Name = "messageId")]
+ public string MessageId { get; set; }
+
+ ///
+ /// 工单版本号,按服务端返回的字符串原样保存。
+ ///
+ [DataMember(Name = "version")]
+ public string Version { get; set; }
+
+ ///
+ /// 事件发生时间,保留服务端 ISO 8601 字符串。
+ ///
+ [DataMember(Name = "occurredAt")]
+ public string OccurredAt { get; set; }
+
+ ///
+ /// 是否要求客户端执行全量重新同步。
+ ///
+ public bool IsResyncRequired =>
+ string.Equals(Type, "resync_required", System.StringComparison.OrdinalIgnoreCase);
+}
+
+///
+/// SSE 连接状态。
+///
+public enum TicketRealtimeState
+{
+ ///
+ /// 未连接。
+ ///
+ Disconnected,
+
+ ///
+ /// 正在建立连接。
+ ///
+ Connecting,
+
+ ///
+ /// 已连接并接收事件。
+ ///
+ Connected,
+
+ ///
+ /// 连接中断,正在自动重连。
+ ///
+ Reconnecting
+}
+
+///
+/// SSE 通知事件参数。
+///
+public sealed class TicketRealtimeNotificationEventArgs : System.EventArgs
+{
+ ///
+ /// 创建 SSE 通知事件参数。
+ ///
+ /// 收到的 SSE 通知。
+ public TicketRealtimeNotificationEventArgs(TicketRealtimeNotification notification)
+ {
+ Notification = notification;
+ }
+
+ ///
+ /// 收到的 SSE 通知。
+ ///
+ public TicketRealtimeNotification Notification { get; }
+}
+
+///
+/// SSE 连接状态变化事件参数。
+///
+public sealed class TicketRealtimeStateChangedEventArgs : System.EventArgs
+{
+ ///
+ /// 创建 SSE 连接状态变化事件参数。
+ ///
+ /// 变化后的连接状态。
+ /// 可选的连接状态说明。
+ public TicketRealtimeStateChangedEventArgs(
+ TicketRealtimeState state,
+ string message = null)
+ {
+ State = state;
+ Message = message;
+ }
+
+ ///
+ /// 变化后的连接状态。
+ ///
+ public TicketRealtimeState State { get; }
+
+ ///
+ /// 连接状态说明。
+ ///
+ public string Message { get; }
+}
diff --git a/OrderSupportTicket/Models/WorkOrderListCache.cs b/OrderSupportTicket/Models/WorkOrderListCache.cs
new file mode 100644
index 0000000..b615b64
--- /dev/null
+++ b/OrderSupportTicket/Models/WorkOrderListCache.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace OrderSupportTicket.Models;
+
+///
+/// 工单列表本地缓存,仅用于比较服务器列表是否有新数据。
+///
+public sealed class WorkOrderListCache
+{
+ ///
+ /// 缓存对应的登录账号。
+ ///
+ public string UserName { get; set; }
+
+ ///
+ /// 用于判断新消息的订单时间缓存项。
+ ///
+ public List Items { get; set; } =
+ new List();
+}
+
+///
+/// 单个订单的新消息比较信息。
+///
+public sealed class WorkOrderListCacheItem
+{
+ ///
+ /// 微信订单号。
+ ///
+ public string OrderId { get; set; }
+
+ ///
+ /// 最后一条公开消息时间,用于判断是否有任意新消息。
+ ///
+ public DateTimeOffset LastMessageAt { get; set; }
+}
diff --git a/OrderSupportTicket/OrderSupportTicket.csproj b/OrderSupportTicket/OrderSupportTicket.csproj
new file mode 100644
index 0000000..ce49198
--- /dev/null
+++ b/OrderSupportTicket/OrderSupportTicket.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net462
+ 12.0
+ true
+ true
+ disable
+ x64
+ AnyCPU;x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\DllReference\NaiBa.Util.dll
+ true
+
+
+
+
diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
new file mode 100644
index 0000000..62eccf1
--- /dev/null
+++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs
@@ -0,0 +1,206 @@
+using LaunchDarkly.EventSource;
+using OrderSupportTicket.Models;
+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;
+
+///
+/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
+/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
+///
+public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
+{
+ private const string EventsPath = "/api/open/v2/customer-work-orders/events";
+ private readonly object _sync = new object();
+ private EventSource _eventSource;
+ private bool _running;
+ private bool _disposed;
+
+ public event EventHandler NotificationReceived;
+
+ public event EventHandler StateChanged;
+
+ ///
+ /// 当前 SSE 连接状态。
+ ///
+ public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected;
+
+ public void Start(TicketClientOptions options)
+ {
+ if (_disposed)
+ throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService));
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+
+ TicketApiService.ValidateOptions(options);
+ StopCore();
+
+ var uri = BuildEventsUri(options);
+ var source = new EventSource(uri);
+ source.Opened += OnOpened;
+ source.Closed += OnClosed;
+ source.MessageReceived += OnMessageReceived;
+ source.Error += OnError;
+
+ lock (_sync)
+ {
+ _running = true;
+ _eventSource = source;
+ }
+
+ SetState(TicketRealtimeState.Connecting, "连接中…");
+ _ = RunAsync(source);
+ }
+
+ public Task StopAsync()
+ {
+ StopCore();
+ SetState(TicketRealtimeState.Disconnected, "已断开");
+ return Task.FromResult(0);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ StopCore();
+ _disposed = true;
+ }
+
+ private async Task RunAsync(EventSource source)
+ {
+ try
+ {
+ await source.StartAsync().ConfigureAwait(false);
+ lock (_sync)
+ {
+ if (!_running || !ReferenceEquals(_eventSource, source))
+ return;
+ }
+ SetState(TicketRealtimeState.Reconnecting, "连接已关闭,等待重连…");
+ }
+ catch (Exception ex)
+ {
+ lock (_sync)
+ {
+ if (!_running || !ReferenceEquals(_eventSource, source))
+ return;
+ }
+ SetState(TicketRealtimeState.Reconnecting, "连接失败:" + ex.Message);
+ }
+ }
+
+ private void OnOpened(object sender, StateChangedEventArgs args)
+ {
+ SetState(TicketRealtimeState.Connected, "已连接");
+ }
+
+ private void OnClosed(object sender, StateChangedEventArgs args)
+ {
+ lock (_sync)
+ {
+ if (!_running || !ReferenceEquals(_eventSource, sender))
+ return;
+ }
+ SetState(TicketRealtimeState.Reconnecting, "连接断开,重连中…");
+ }
+
+ private void OnError(object sender, ExceptionEventArgs args)
+ {
+ lock (_sync)
+ {
+ if (!_running || !ReferenceEquals(_eventSource, sender))
+ return;
+ }
+ SetState(
+ TicketRealtimeState.Reconnecting,
+ "连接异常,重连中:" + (args.Exception?.Message ?? "未知错误"));
+ }
+
+ private void OnMessageReceived(object sender, MessageReceivedEventArgs args)
+ {
+ if (args == null)
+ return;
+ var data = args.Message.Data;
+ if (string.IsNullOrWhiteSpace(data))
+ return;
+
+ TicketRealtimeNotification notification;
+ try
+ {
+ var serializer = new DataContractJsonSerializer(
+ typeof(TicketRealtimeNotification));
+ using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(data)))
+ {
+ notification = (TicketRealtimeNotification)serializer.ReadObject(stream);
+ }
+ }
+ catch (SerializationException)
+ {
+ return;
+ }
+
+ if (notification == null)
+ return;
+ if (string.IsNullOrWhiteSpace(notification.EventId))
+ notification.EventId = args.Message.LastEventId;
+ if (string.IsNullOrWhiteSpace(notification.Type))
+ notification.Type = args.EventName;
+
+ NotificationReceived?.Invoke(
+ this,
+ new TicketRealtimeNotificationEventArgs(notification));
+ }
+
+ private void StopCore()
+ {
+ EventSource source;
+ lock (_sync)
+ {
+ _running = false;
+ source = _eventSource;
+ _eventSource = null;
+ }
+
+ if (source == null)
+ return;
+ source.Opened -= OnOpened;
+ source.Closed -= OnClosed;
+ source.MessageReceived -= OnMessageReceived;
+ source.Error -= OnError;
+ source.Close();
+ }
+
+ private void SetState(TicketRealtimeState state, string message)
+ {
+ State = state;
+ StateChanged?.Invoke(
+ this,
+ new TicketRealtimeStateChangedEventArgs(state, message));
+ }
+
+ private static Uri BuildEventsUri(TicketClientOptions options)
+ {
+ var appIds = options.AppIds
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Distinct(StringComparer.Ordinal)
+ .Select(a => "appId=" + Uri.EscapeDataString(a.Trim()));
+ var query = new[]
+ {
+ "purchaseAccount=" + Uri.EscapeDataString(options.UserName)
+ }.Concat(appIds);
+
+ return new Uri(
+ TicketApiService.ServerUrl
+ + EventsPath
+ + "?"
+ + string.Join("&", query),
+ UriKind.Absolute);
+ }
+}
diff --git a/OrderSupportTicket/Services/ITicketApiService.cs b/OrderSupportTicket/Services/ITicketApiService.cs
new file mode 100644
index 0000000..e5c6866
--- /dev/null
+++ b/OrderSupportTicket/Services/ITicketApiService.cs
@@ -0,0 +1,87 @@
+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
new file mode 100644
index 0000000..2dc950c
--- /dev/null
+++ b/OrderSupportTicket/Services/ITicketRealtimeService.cs
@@ -0,0 +1,36 @@
+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/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs
new file mode 100644
index 0000000..b04533a
--- /dev/null
+++ b/OrderSupportTicket/Services/TicketApiService.cs
@@ -0,0 +1,432 @@
+using AllBase;
+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;
+
+namespace OrderSupportTicket.Services;
+
+///
+/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
+///
+public sealed class TicketApiService : ITicketApiService
+{
+ internal const 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);
+ private static readonly Regex OrderIdRegex =
+ new Regex(@"^[0-9]{10,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ private static readonly Regex PurchaseAccountRegex =
+ 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);
+
+ ///
+ /// 获取工单列表
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task GetWorkOrdersAsync(
+ TicketClientOptions options,
+ string wechatOrderId = null,
+ string status = null,
+ int limit = 20,
+ string cursor = null)
+ {
+ ValidateOptions(options);
+ if (!string.IsNullOrWhiteSpace(wechatOrderId))
+ ValidateWechatOrderId(wechatOrderId);
+ if (limit < 1 || limit > 100)
+ throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。");
+
+ var request = new ListWorkOrdersRequest
+ {
+ PurchaseAccount = options.UserName,
+ AppIds = NormalizeAppIds(options.AppIds),
+ WechatOrderId = string.IsNullOrWhiteSpace(wechatOrderId) ? null : wechatOrderId,
+ Status = string.IsNullOrWhiteSpace(status) ? null : status,
+ Limit = limit,
+ Cursor = string.IsNullOrWhiteSpace(cursor) ? null : cursor
+ };
+
+ return await PostAsync(
+ ApiRoot + "/list",
+ request).ConfigureAwait(false);
+ }
+ ///
+ /// 获取工单消息
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task GetMessagesAsync(
+ TicketClientOptions options,
+ string appId,
+ string wechatOrderId,
+ string before = null,
+ int limit = 50)
+ {
+ ValidateOptions(options);
+ ValidateAppId(appId);
+ ValidateWechatOrderId(wechatOrderId);
+ if (limit < 1 || limit > 100)
+ throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。");
+
+ var request = new GetMessagesRequest
+ {
+ PurchaseAccount = options.UserName,
+ AppId = appId,
+ WechatOrderId = wechatOrderId,
+ Limit = limit,
+ Before = string.IsNullOrWhiteSpace(before) ? null : before
+ };
+
+ var path = ApiRoot
+ + "/messages";
+ return await PostAsync(path, request)
+ .ConfigureAwait(false);
+ }
+ ///
+ /// 创建工单
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task CreateWorkOrderAsync(
+ TicketClientOptions options,
+ TicketOrderOption order,
+ string description,
+ string clientRequestId = null)
+ {
+ ValidateOptions(options);
+ ValidateOrder(order);
+ ValidateDescription(description);
+ ValidateOperationId(clientRequestId, "clientRequestId", required: false);
+
+ var request = new CreateWorkOrderRequest
+ {
+ PurchaseAccount = options.UserName,
+ AppId = order.AppId,
+ WechatOrderId = order.OrderId,
+ Description = description,
+ ClientRequestId = string.IsNullOrWhiteSpace(clientRequestId)
+ ? null
+ : clientRequestId
+ };
+ return await PostAsync(
+ ApiRoot,
+ request);
+ }
+ ///
+ /// 发送工单消息
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task SendMessageAsync(
+ TicketClientOptions options,
+ WorkOrderSummaryDto workOrder,
+ string text,
+ string clientMessageId)
+ {
+ ValidateOptions(options);
+ if (workOrder == null)
+ throw new ArgumentNullException(nameof(workOrder));
+ ValidateAppId(workOrder.AppId);
+ ValidateWechatOrderId(workOrder.WechatOrderId);
+ ValidateMessageText(text);
+ ValidateOperationId(clientMessageId, "clientMessageId", required: true);
+
+ var request = new SendMessageRequest
+ {
+ PurchaseAccount = options.UserName,
+ AppId = workOrder.AppId,
+ ClientMessageId = clientMessageId,
+ Text = text
+ };
+ var path = ApiRoot
+ + "/orders/"
+ + Uri.EscapeDataString(workOrder.WechatOrderId)
+ + "/messages";
+ return await PostAsync(path, request)
+ .ConfigureAwait(false);
+ }
+
+ public async Task MarkReadAsync(
+ TicketClientOptions options,
+ string appId,
+ string wechatOrderId,
+ int throughSequenceNo)
+ {
+ ValidateOptions(options);
+ ValidateAppId(appId);
+ ValidateWechatOrderId(wechatOrderId);
+ if (throughSequenceNo < 1)
+ return;
+
+ var request = new MarkReadRequest
+ {
+ PurchaseAccount = options.UserName,
+ AppId = appId,
+ ThroughSequenceNo = throughSequenceNo
+ };
+ var path = ApiRoot
+ + "/orders/"
+ + Uri.EscapeDataString(wechatOrderId)
+ + "/read";
+ var html = await PostBodyAsync(path, request).ConfigureAwait(false);
+ EnsureSuccessBody(html);
+ }
+
+ public static void ValidateOptions(TicketClientOptions options)
+ {
+ if (options == null)
+ throw new ArgumentNullException(nameof(options));
+ if (string.IsNullOrWhiteSpace(options.UserName)
+ || !PurchaseAccountRegex.IsMatch(options.UserName))
+ {
+ throw new ArgumentException(
+ "purchaseAccount 必填,且只能为 2~120 位中英文、数字或 _.@-。",
+ nameof(options));
+ }
+ NormalizeAppIds(options.AppIds);
+ }
+
+ public static void ValidateAppId(string appId)
+ {
+ if (string.IsNullOrWhiteSpace(appId) || !AppIdRegex.IsMatch(appId))
+ throw new ArgumentException(
+ "appId 必须为 wx 加 16 位小写十六进制字符。",
+ nameof(appId));
+ }
+
+ public static void ValidateWechatOrderId(string wechatOrderId)
+ {
+ if (string.IsNullOrWhiteSpace(wechatOrderId)
+ || !OrderIdRegex.IsMatch(wechatOrderId))
+ {
+ throw new ArgumentException(
+ "wechatOrderId 必须保持为 10~64 位数字字符串。",
+ nameof(wechatOrderId));
+ }
+ }
+
+ public static void ValidateOrder(TicketOrderOption order)
+ {
+ if (order == null)
+ throw new ArgumentNullException(nameof(order));
+ ValidateAppId(order.AppId);
+ ValidateWechatOrderId(order.OrderId);
+ }
+
+ private static async Task PostAsync(
+ string path,
+ object request)
+ {
+ var html = await PostBodyAsync(path, request).ConfigureAwait(false);
+ return DeserializeResponse(html);
+ }
+
+ private static async Task PostBodyAsync(
+ string path,
+ object request)
+ {
+ var url = BuildUri(path);
+ // 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。
+ var json = Serialize(request);
+ var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(html))
+ throw RequestFailed("工单接口未返回数据。");
+ return html;
+ }
+
+ private static void EnsureSuccessBody(string html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ throw RequestFailed("工单接口未返回数据。");
+
+ ApiSuccessResponse response;
+ try
+ {
+ response = Deserialize(html);
+ }
+ catch (SerializationException)
+ {
+ throw RequestFailed(html);
+ }
+
+ if (response != null && !response.Success)
+ {
+ throw RequestFailed(
+ ExtractErrorMessage(html)
+ ?? "工单接口请求失败。",
+ html);
+ }
+ }
+
+ private static T DeserializeResponse(string html)
+ {
+ EnsureSuccessBody(html);
+ try
+ {
+ var result = Deserialize(html);
+ if (result == null)
+ throw new SerializationException("接口响应为空。");
+ return result;
+ }
+ catch (SerializationException)
+ {
+ throw RequestFailed("工单接口返回了无法解析的数据。", html);
+ }
+ }
+
+ private static string ExtractErrorMessage(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return null;
+
+ try
+ {
+ var error = Deserialize(body);
+ return error == null
+ ? null
+ : error.Message
+ ?? error.Error
+ ?? error.Code;
+ }
+ catch (SerializationException)
+ {
+ 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;
+ }
+
+ private static TicketApiException RequestFailed(
+ string message,
+ string responseBody = null)
+ {
+ return new TicketApiException(
+ ExtractStatusCode(responseBody),
+ message,
+ responseBody);
+ }
+
+ private static int ExtractStatusCode(string responseBody)
+ {
+ if (string.IsNullOrWhiteSpace(responseBody))
+ return 0;
+
+ try
+ {
+ var error = Deserialize(responseBody);
+ if (error != null && int.TryParse(error.Code, out var code))
+ return code;
+ }
+ catch (SerializationException)
+ {
+ // GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。
+ }
+ return 0;
+ }
+
+ private static List NormalizeAppIds(IEnumerable appIds)
+ {
+ var normalized = (appIds ?? Enumerable.Empty())
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Select(a => a.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (normalized.Count == 0)
+ throw new ArgumentException("appIds 至少需要一个 APPID。", nameof(appIds));
+ if (normalized.Count > 50)
+ throw new ArgumentException("appIds 最多支持 50 个 APPID。", nameof(appIds));
+ foreach (var appId in normalized)
+ ValidateAppId(appId);
+ return normalized;
+ }
+
+ private static void ValidateDescription(string description)
+ {
+ if (string.IsNullOrWhiteSpace(description)
+ || description.Length < 1
+ || description.Length > 2000)
+ {
+ throw new ArgumentException("description 必须为 1~2000 字。", nameof(description));
+ }
+ }
+
+ private static void ValidateMessageText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text)
+ || text.Length < 1
+ || text.Length > 4000)
+ {
+ throw new ArgumentException("text 必须为 1~4000 字。", nameof(text));
+ }
+ }
+
+ private static void ValidateOperationId(
+ string value,
+ string parameterName,
+ bool required)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ if (required)
+ throw new ArgumentException(parameterName + " 必填。", parameterName);
+ return;
+ }
+ if (!OperationIdRegex.IsMatch(value))
+ {
+ throw new ArgumentException(
+ parameterName + " 必须为 1~128 位字母、数字或 _.:-。",
+ parameterName);
+ }
+ }
+
+}
diff --git a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs
new file mode 100644
index 0000000..fb93383
--- /dev/null
+++ b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs
@@ -0,0 +1,135 @@
+using OrderSupportTicket.Models.Api;
+using System;
+using System.Globalization;
+using WPFCommon.Models;
+
+namespace OrderSupportTicket.ViewModels;
+
+///
+/// 工单留言板中的单条消息。
+///
+public sealed class ChatMessageViewModel : BindableBaseEx
+{
+ private string _messageId;
+ private int _sequenceNo;
+ private string _senderType;
+ private string _senderDisplayName;
+ private string _content;
+ private bool _isRecalled;
+ private DateTimeOffset _createdAt;
+
+ public ChatMessageViewModel(TicketMessageDto dto)
+ {
+ if (dto == null)
+ throw new ArgumentNullException(nameof(dto));
+ MessageId = dto.MessageId;
+ SequenceNo = dto.SequenceNo;
+ SenderType = dto.SenderType;
+ IsRecalled = dto.Recalled;
+ SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName)
+ ? (IsCustomer ? "我" : "客服")
+ : dto.SenderName;
+ Content = dto.Recalled
+ ? "该消息已撤回"
+ : (dto.Text ?? string.Empty);
+ CreatedAt = ParseTimestamp(dto.CreatedAt);
+ }
+
+ ///
+ /// 消息唯一 ID。
+ ///
+ public string MessageId
+ {
+ get => _messageId;
+ private set => SetValue(ref _messageId, value);
+ }
+
+ ///
+ /// 工单内连续递增的消息序号。
+ ///
+ public int SequenceNo
+ {
+ get => _sequenceNo;
+ private set => SetValue(ref _sequenceNo, value);
+ }
+
+ ///
+ /// 消息发送方类型。
+ ///
+ public string SenderType
+ {
+ get => _senderType;
+ private set
+ {
+ if (SetValue(ref _senderType, value))
+ RaisePropertyChanged(nameof(IsCustomer));
+ }
+ }
+
+ ///
+ /// 消息发送方显示名称。
+ ///
+ public string SenderDisplayName
+ {
+ get => _senderDisplayName;
+ private set => SetValue(ref _senderDisplayName, value);
+ }
+
+ ///
+ /// 消息展示内容;撤回消息显示固定提示文本。
+ ///
+ public string Content
+ {
+ get => _content;
+ private set => SetValue(ref _content, value);
+ }
+
+ ///
+ /// 消息是否已撤回。
+ ///
+ public bool IsRecalled
+ {
+ get => _isRecalled;
+ private set => SetValue(ref _isRecalled, value);
+ }
+
+ ///
+ /// 消息发送时间。
+ ///
+ public DateTimeOffset CreatedAt
+ {
+ get => _createdAt;
+ private set
+ {
+ if (SetValue(ref _createdAt, value))
+ RaisePropertyChanged(nameof(CreatedAtText));
+ }
+ }
+
+ ///
+ /// 当前消息是否由客户发送。
+ ///
+ public bool IsCustomer =>
+ string.Equals(SenderType, "customer", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// 适合聊天界面展示的本地时间文本。
+ ///
+ public string CreatedAtText =>
+ CreatedAt == default
+ ? string.Empty
+ : CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture);
+
+ private static DateTimeOffset ParseTimestamp(string value)
+ {
+ if (DateTimeOffset.TryParse(
+ value,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal,
+ out var parsed))
+ {
+ return parsed;
+ }
+ return default;
+ }
+}
diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
new file mode 100644
index 0000000..303bd77
--- /dev/null
+++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs
@@ -0,0 +1,1388 @@
+using NaiBa.Util.Helper;
+using OrderSupportTicket.Models;
+using OrderSupportTicket.Models.Api;
+using OrderSupportTicket.Services;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using System.Windows.Threading;
+using WPFCommon.Models;
+using WxOrder.Framework.Commons;
+
+namespace OrderSupportTicket.ViewModels;
+
+///
+/// 工单页总 ViewModel,负责工单查询、留言操作、订单筛选和 SSE 刷新。
+///
+public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable
+{
+ private const string SavedListConfigName = "OrderSupportTicket";
+ private readonly ITicketApiService _api;
+ private readonly ITicketRealtimeService _realtime;
+ private readonly Dispatcher _dispatcher;
+ private readonly DelegateCommand _newTicketCommand;
+ private readonly DelegateCommand _clearOrderFilterCommand;
+ private readonly AsyncDelegateCommand _selectTicketCommand;
+ private readonly AsyncDelegateCommand _createTicketCommand;
+ private readonly AsyncDelegateCommand _sendMessageCommand;
+ private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
+ private readonly AsyncDelegateCommand _retryRealtimeCommand;
+ private readonly HashSet _seenEventIds =
+ new HashSet(StringComparer.Ordinal);
+ private readonly Dictionary _lastReadSequences =
+ new Dictionary(StringComparer.Ordinal);
+ private Dictionary _savedWorkOrders =
+ new Dictionary(StringComparer.Ordinal);
+ private bool _hasSavedCache;
+ private TicketClientOptions _options;
+ private bool _configured;
+ private bool _initialized;
+ private bool _disposed;
+ private WorkOrderSummaryViewModel _selectedTicket;
+ private TicketOrderOption _selectedOrder;
+ private string _chatText;
+ private string _draftText;
+ private string _orderFilterText = string.Empty;
+ private bool _isDraft;
+ private bool _isBusy;
+ private bool _isLoading;
+ private bool _isLoadingMessages;
+ private bool _hasMoreMessages;
+ private string _nextBefore;
+ private string _noticeText;
+ private bool _isNoticeError;
+ private bool _hasNewData;
+ private string _newDataText;
+ private TicketRealtimeState _sseState;
+ private string _sseStatusText;
+ private int _messageScrollVersion;
+ private string _pendingCreateRequestId;
+ private string _pendingCreateFingerprint;
+ private string _pendingMessageId;
+ private string _pendingMessageFingerprint;
+ private int _selectionVersion;
+
+ public OrderSupportTicketViewModel(
+ ITicketApiService api,
+ ITicketRealtimeService realtime,
+ TicketClientOptions options = null)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _realtime = realtime ?? throw new ArgumentNullException(nameof(realtime));
+ _dispatcher = System.Windows.Application.Current?.Dispatcher
+ ?? Dispatcher.CurrentDispatcher;
+
+ Tickets = new ObservableCollection();
+ Messages = new ObservableCollection();
+ AvailableOrders = new ObservableCollection();
+ FilteredOrders = new ObservableCollection();
+
+ _newTicketCommand = new DelegateCommand(OpenNewDraft, () => !IsBusy);
+ _clearOrderFilterCommand = new DelegateCommand(
+ () => OrderFilterText = string.Empty,
+ () => !string.IsNullOrWhiteSpace(OrderFilterText));
+ _selectTicketCommand = new AsyncDelegateCommand(
+ SelectTicketAsync,
+ ticket => ticket != null && !IsBusy);
+ _createTicketCommand = new AsyncDelegateCommand(
+ CreateTicketAsync,
+ CanCreateTicket);
+ _sendMessageCommand = new AsyncDelegateCommand(
+ SendMessageAsync,
+ CanSendMessage);
+ _loadEarlierMessagesCommand = new AsyncDelegateCommand(
+ LoadEarlierMessagesAsync,
+ () => !IsBusy && SelectedTicket != null && HasMoreMessages);
+ _retryRealtimeCommand = new AsyncDelegateCommand(
+ RestartRealtimeAsync,
+ () => !IsBusy && _configured);
+
+ _realtime.NotificationReceived += OnNotificationReceived;
+ _realtime.StateChanged += OnRealtimeStateChanged;
+ SseState = TicketRealtimeState.Disconnected;
+ SseStatusText = "实时推送:未连接";
+
+ if (options != null)
+ Configure(options);
+ }
+
+ ///
+ /// 当前账号和 APPID 下的历史工单。
+ ///
+ public ObservableCollection Tickets { get; }
+
+ ///
+ /// 当前选中工单的公开留言。
+ ///
+ public ObservableCollection Messages { get; }
+
+ ///
+ /// 宿主注入的全部可关联订单。
+ ///
+ public ObservableCollection AvailableOrders { get; }
+
+ ///
+ /// 经过订单号或商品名筛选后的关联订单。
+ ///
+ public ObservableCollection FilteredOrders { get; }
+
+ ///
+ /// 打开新建工单草稿的命令。
+ ///
+ public ICommand NewTicketCommand => _newTicketCommand;
+
+ ///
+ /// 清除关联订单筛选条件的命令。
+ ///
+ public ICommand ClearOrderFilterCommand => _clearOrderFilterCommand;
+
+ ///
+ /// 选择并加载历史工单的命令。
+ ///
+ public ICommand SelectTicketCommand => _selectTicketCommand;
+
+ ///
+ /// 提交新工单的命令。
+ ///
+ public ICommand CreateTicketCommand => _createTicketCommand;
+
+ ///
+ /// 提交客户留言的命令。
+ ///
+ public ICommand SendMessageCommand => _sendMessageCommand;
+
+ ///
+ /// 加载当前工单更早留言的命令。
+ ///
+ public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand;
+
+ ///
+ /// 手动重新连接 SSE 的命令。
+ ///
+ public ICommand RetryRealtimeCommand => _retryRealtimeCommand;
+
+ ///
+ /// 当前正在查看的历史工单。
+ ///
+ public WorkOrderSummaryViewModel SelectedTicket
+ {
+ get => _selectedTicket;
+ private set
+ {
+ if (SetValue(ref _selectedTicket, value))
+ {
+ RaisePropertyChanged(nameof(IsChatVisible));
+ RaisePropertyChanged(nameof(IsEmptyVisible));
+ RaisePropertyChanged(nameof(ComposerPlaceholder));
+ RaiseSendCanExecuteChanged();
+ RaiseLoadEarlierCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 新建工单时选中的关联订单。
+ ///
+ public TicketOrderOption SelectedOrder
+ {
+ get => _selectedOrder;
+ set
+ {
+ if (SetValue(ref _selectedOrder, value))
+ {
+ RaisePropertyChanged(nameof(SelectedOrderDescription));
+ RaiseCreateCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 当前工单输入框中的留言文本。
+ ///
+ public string ChatText
+ {
+ get => _chatText;
+ set
+ {
+ if (SetValue(ref _chatText, value ?? string.Empty))
+ {
+ RaisePropertyChanged(nameof(ChatCharacterCountText));
+ RaiseSendCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 新建工单输入框中的问题描述。
+ ///
+ public string DraftText
+ {
+ get => _draftText;
+ set
+ {
+ if (SetValue(ref _draftText, value ?? string.Empty))
+ {
+ RaisePropertyChanged(nameof(DraftCharacterCountText));
+ RaiseCreateCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 关联订单筛选关键词,可匹配订单号或商品名。
+ ///
+ public string OrderFilterText
+ {
+ get => _orderFilterText;
+ set
+ {
+ if (SetValue(ref _orderFilterText, value ?? string.Empty))
+ {
+ RefreshFilteredOrders();
+ _clearOrderFilterCommand.RaiseCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 当前是否处于新建工单草稿状态。
+ ///
+ public bool IsDraft
+ {
+ get => _isDraft;
+ private set
+ {
+ if (SetValue(ref _isDraft, value))
+ {
+ RaisePropertyChanged(nameof(IsChatVisible));
+ RaisePropertyChanged(nameof(IsEmptyVisible));
+ RaisePropertyChanged(nameof(IsDraftVisible));
+ RaiseCreateCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 是否正在执行创建、留言或刷新操作。
+ ///
+ public bool IsBusy
+ {
+ get => _isBusy;
+ private set
+ {
+ if (SetValue(ref _isBusy, value))
+ {
+ RaisePropertyChanged(nameof(IsNotBusy));
+ RaiseSendCanExecuteChanged();
+ RaiseCreateCanExecuteChanged();
+ RaiseLoadEarlierCanExecuteChanged();
+ _newTicketCommand.RaiseCanExecuteChanged();
+ _retryRealtimeCommand.RaiseCanExecuteChanged();
+ _selectTicketCommand.RaiseCanExecuteChanged();
+ }
+ }
+ }
+
+ ///
+ /// 是否正在加载工单摘要列表。
+ ///
+ public bool IsLoading
+ {
+ get => _isLoading;
+ private set => SetValue(ref _isLoading, value);
+ }
+
+ ///
+ /// 是否正在加载当前工单留言。
+ ///
+ public bool IsLoadingMessages
+ {
+ get => _isLoadingMessages;
+ private set => SetValue(ref _isLoadingMessages, value);
+ }
+
+ ///
+ /// 当前留言板是否还有更早留言。
+ ///
+ public bool HasMoreMessages
+ {
+ get => _hasMoreMessages;
+ private set
+ {
+ if (SetValue(ref _hasMoreMessages, value))
+ RaiseLoadEarlierCanExecuteChanged();
+ }
+ }
+
+ ///
+ /// 当前操作提示或错误信息。
+ ///
+ public string NoticeText
+ {
+ get => _noticeText;
+ private set => SetValue(ref _noticeText, value);
+ }
+
+ ///
+ /// 当前提示是否为错误类型。
+ ///
+ public bool IsNoticeError
+ {
+ get => _isNoticeError;
+ private set => SetValue(ref _isNoticeError, value);
+ }
+
+ ///
+ /// 当前页面是否检测到服务器有新的工单消息。
+ ///
+ public bool HasNewData
+ {
+ get => _hasNewData;
+ private set => SetValue(ref _hasNewData, value);
+ }
+
+ ///
+ /// 新数据提示文本。
+ ///
+ public string NewDataText
+ {
+ get => _newDataText;
+ private set => SetValue(ref _newDataText, value);
+ }
+
+ ///
+ /// SSE 当前连接状态。
+ ///
+ public TicketRealtimeState SseState
+ {
+ get => _sseState;
+ private set => SetValue(ref _sseState, value);
+ }
+
+ ///
+ /// SSE 当前状态的中文显示文本。
+ ///
+ public string SseStatusText
+ {
+ get => _sseStatusText;
+ private set => SetValue(ref _sseStatusText, value);
+ }
+
+ ///
+ /// 触发消息列表滚动到底部的版本号。
+ ///
+ public int MessageScrollVersion
+ {
+ get => _messageScrollVersion;
+ private set => SetValue(ref _messageScrollVersion, value);
+ }
+
+ ///
+ /// 是否显示当前工单的聊天面板。
+ ///
+ public bool IsChatVisible => !IsDraft && SelectedTicket != null;
+
+ ///
+ /// 是否显示新建工单面板。
+ ///
+ public bool IsDraftVisible => IsDraft;
+
+ ///
+ /// 是否显示未选择工单时的空状态。
+ ///
+ public bool IsEmptyVisible => !IsDraft && SelectedTicket == null;
+
+ ///
+ /// 当前是否没有执行中的操作。
+ ///
+ public bool IsNotBusy => !IsBusy;
+
+ ///
+ /// 是否存在任何可关联订单。
+ ///
+ public bool HasAvailableOrders => AvailableOrders.Count > 0;
+
+ ///
+ /// 当前筛选结果中是否存在订单。
+ ///
+ public bool HasFilteredOrders => FilteredOrders.Count > 0;
+
+ ///
+ /// 关联订单总数或筛选结果数量文本。
+ ///
+ public string OrderFilterSummaryText =>
+ HasAvailableOrders
+ ? (string.IsNullOrWhiteSpace(OrderFilterText)
+ ? $"共 {FilteredOrders.Count} 个订单"
+ : $"匹配 {FilteredOrders.Count} 个订单")
+ : "暂无可关联订单";
+
+ ///
+ /// 留言输入框字数统计文本。
+ ///
+ public string ChatCharacterCountText => (ChatText ?? string.Empty).Length + " / 4000";
+
+ ///
+ /// 新建工单输入框字数统计文本。
+ ///
+ public string DraftCharacterCountText => (DraftText ?? string.Empty).Length + " / 2000";
+
+ ///
+ /// 当前工单输入框提示文本。
+ ///
+ public string ComposerPlaceholder =>
+ SelectedTicket != null && SelectedTicket.IsCompleted
+ ? "工单已完结,继续留言将重新开启"
+ : "输入留言";
+
+ ///
+ /// 当前关联订单的商品、金额和下单时间说明。
+ ///
+ public string SelectedOrderDescription =>
+ SelectedOrder?.ProductDescription ?? "请选择关联订单";
+
+ public void Configure(TicketClientOptions options)
+ {
+ if (_disposed)
+ throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel));
+
+ _ = _realtime.StopAsync();
+ SseState = TicketRealtimeState.Disconnected;
+ SseStatusText = "实时推送:未连接";
+ _configured = false;
+ _initialized = false;
+ _options = CloneOptions(options);
+ HasNewData = false;
+ NewDataText = string.Empty;
+
+ IsDraft = false;
+ SelectedTicket = null;
+ Tickets.Clear();
+ Messages.Clear();
+ AvailableOrders.Clear();
+ foreach (var order in _options.Orders ?? Enumerable.Empty())
+ {
+ if (order != null)
+ AvailableOrders.Add(order);
+ }
+ OrderFilterText = string.Empty;
+ RefreshFilteredOrders();
+ RaisePropertyChanged(nameof(HasAvailableOrders));
+
+ if (_options.AppIds == null || _options.AppIds.Count == 0)
+ {
+ _options.AppIds = AvailableOrders
+ .Select(o => o.AppId)
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ }
+
+ LoadSavedOrderTimes();
+
+ try
+ {
+ TicketApiService.ValidateOptions(_options);
+ if (_options.AppIds.Count == 0)
+ throw new ArgumentException("appIds 至少需要一个 APPID。");
+ _configured = true;
+ NoticeText = string.Empty;
+ IsNoticeError = false;
+ }
+ catch (Exception ex)
+ {
+ SetNotice(
+ "工单页配置不完整:" + ex.Message
+ + " 请通过 TicketClientOptions 注入拍单账号和 APPID。",
+ true);
+ }
+
+ RaiseCreateCanExecuteChanged();
+ RaiseLoadEarlierCanExecuteChanged();
+ _retryRealtimeCommand.RaiseCanExecuteChanged();
+ }
+
+ public async Task InitializeAsync()
+ {
+ if (_disposed)
+ return;
+ if (!_configured)
+ {
+ SetNotice(
+ "未配置拍单账号或 APPID,工单页无法连接服务器。",
+ true);
+ return;
+ }
+
+ try
+ {
+ var started = false;
+ if (!_initialized || _realtime.State == TicketRealtimeState.Disconnected)
+ {
+ _realtime.Start(_options);
+ started = true;
+ }
+
+ if (!_initialized || started)
+ {
+ await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
+ _initialized = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ }
+
+ public async Task DeactivateAsync()
+ {
+ SaveWorkOrderList();
+ await _realtime.StopAsync();
+ }
+
+ public void OpenNewTicket(TicketOrderOption order)
+ {
+ if (order == null)
+ return;
+ if (!AvailableOrders.Any(o =>
+ string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal)))
+ {
+ AvailableOrders.Add(order);
+ RaisePropertyChanged(nameof(HasAvailableOrders));
+ }
+ OrderFilterText = string.Empty;
+ RefreshFilteredOrders();
+ SelectedOrder = AvailableOrders.First(o =>
+ string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal));
+ OpenNewDraft();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ SaveWorkOrderList();
+ _disposed = true;
+ _realtime.NotificationReceived -= OnNotificationReceived;
+ _realtime.StateChanged -= OnRealtimeStateChanged;
+ _realtime.Dispose();
+ }
+
+ private void OpenNewDraft()
+ {
+ IsDraft = true;
+ SelectedTicket = null;
+ Messages.Clear();
+ HasMoreMessages = false;
+ _nextBefore = null;
+ if (SelectedOrder == null)
+ SelectedOrder = FilteredOrders.FirstOrDefault();
+ if (AvailableOrders.Count == 0)
+ {
+ SetNotice(
+ "没有可关联的本地订单。请通过 TicketClientOptions.Orders 注入订单后再新建工单。",
+ true);
+ }
+ else if (FilteredOrders.Count == 0)
+ {
+ SetNotice(
+ "当前筛选条件下没有匹配订单,请调整订单号或商品名关键词。",
+ true);
+ }
+ else
+ {
+ ClearNotice();
+ }
+ }
+
+ private async Task SelectTicketAsync(WorkOrderSummaryViewModel ticket)
+ {
+ if (ticket == null || IsBusy)
+ return;
+
+ IsDraft = false;
+ SelectedTicket = ticket;
+ await LoadMessagesAsync(ticket, replace: true);
+ }
+
+ private async Task CreateTicketAsync()
+ {
+ if (!CanCreateTicket())
+ return;
+
+ var description = (DraftText ?? string.Empty).Trim();
+ var fingerprint = SelectedOrder.OrderKey + "|" + description;
+ if (!string.Equals(
+ fingerprint,
+ _pendingCreateFingerprint,
+ StringComparison.Ordinal))
+ {
+ _pendingCreateFingerprint = fingerprint;
+ _pendingCreateRequestId = NewOperationId("create");
+ }
+
+ IsBusy = true;
+ ClearNotice();
+ try
+ {
+ var result = await _api.CreateWorkOrderAsync(
+ _options,
+ SelectedOrder,
+ description,
+ null);
+
+ var targetKey = result.AppId + "|" + result.WechatOrderId;
+ var operation = (result.Operation ?? string.Empty).ToLowerInvariant();
+ var isExisting = operation == "existing";
+ if (isExisting)
+ {
+ ChatText = description;
+ SetNotice(
+ "该订单已有未完结工单,服务端未重复追加描述。已打开原工单,请点击“发送”提交本次补充。",
+ false);
+ }
+ else if (operation == "reopened")
+ {
+ SetNotice("原工单已重新开启,本次问题已加入留言板。", false);
+ }
+ else
+ {
+ SetNotice("工单已提交,等待客服回复。", false);
+ }
+
+ _pendingCreateFingerprint = null;
+ _pendingCreateRequestId = null;
+ DraftText = string.Empty;
+ IsDraft = false;
+ await RefreshAllWorkOrdersAsync(
+ loadSelectedMessages: false,
+ preferredKey: targetKey);
+ var target = Tickets.FirstOrDefault(t =>
+ string.Equals(t.Key, targetKey, StringComparison.Ordinal));
+ if (target != null)
+ {
+ SelectedTicket = target;
+ await LoadMessagesAsync(target, replace: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task SendMessageAsync()
+ {
+ if (!CanSendMessage())
+ return;
+
+ var ticket = SelectedTicket;
+ var text = (ChatText ?? string.Empty).Trim();
+ var fingerprint = ticket.Key + "|" + text;
+ if (!string.Equals(
+ fingerprint,
+ _pendingMessageFingerprint,
+ StringComparison.Ordinal))
+ {
+ _pendingMessageFingerprint = fingerprint;
+ _pendingMessageId = NewOperationId("send");
+ }
+
+ IsBusy = true;
+ ClearNotice();
+ try
+ {
+ var result = await _api.SendMessageAsync(
+ _options,
+ ToSummaryDto(ticket),
+ text,
+ _pendingMessageId);
+
+ if (result.Message != null)
+ AppendMessage(result.Message);
+ ticket.ApplySendResult(result.Status, result.Version);
+ MoveTicketToTop(ticket);
+ ChatText = string.Empty;
+ _pendingMessageFingerprint = null;
+ _pendingMessageId = null;
+ SetNotice(
+ result.Reopened
+ ? "留言已发送,已完结工单已重新开启。"
+ : "留言已发送。",
+ false);
+ await MarkReadBestEffortAsync(ticket);
+ SaveWorkOrderList();
+ MessageScrollVersion++;
+ }
+ catch (TicketApiException ex)
+ when (ex.StatusCode == 409)
+ {
+ SetNotice("工单状态或数据版本发生冲突,正在刷新最新内容。", true);
+ _pendingMessageFingerprint = null;
+ _pendingMessageId = null;
+ await RefreshAllWorkOrdersAsync(
+ loadSelectedMessages: false,
+ preferredKey: ticket.Key);
+ var refreshed = Tickets.FirstOrDefault(t =>
+ string.Equals(t.Key, ticket.Key, StringComparison.Ordinal));
+ if (refreshed != null)
+ {
+ SelectedTicket = refreshed;
+ await LoadMessagesAsync(refreshed, replace: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task LoadEarlierMessagesAsync()
+ {
+ var ticket = SelectedTicket;
+ if (ticket == null || !HasMoreMessages || string.IsNullOrWhiteSpace(_nextBefore))
+ return;
+
+ IsBusy = true;
+ try
+ {
+ var response = await _api.GetMessagesAsync(
+ _options,
+ ticket.AppId,
+ ticket.WechatOrderId,
+ _nextBefore,
+ 50);
+ MergeMessages(response.Items ?? new List());
+ HasMoreMessages = response.HasMore;
+ _nextBefore = response.NextBefore;
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task RestartRealtimeAsync()
+ {
+ if (!_configured)
+ return;
+
+ IsBusy = true;
+ try
+ {
+ await _realtime.StopAsync();
+ _realtime.Start(_options);
+ await RefreshAllWorkOrdersAsync(loadSelectedMessages: false);
+ if (SelectedTicket != null && !IsDraft)
+ await LoadMessagesAsync(SelectedTicket, replace: true);
+ ClearNotice();
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task LoadMessagesAsync(
+ WorkOrderSummaryViewModel ticket,
+ bool replace)
+ {
+ var version = ++_selectionVersion;
+ IsLoadingMessages = true;
+ try
+ {
+ var response = await _api.GetMessagesAsync(
+ _options,
+ ticket.AppId,
+ ticket.WechatOrderId,
+ null,
+ 50);
+ if (version != _selectionVersion
+ || SelectedTicket == null
+ || !string.Equals(ticket.Key, SelectedTicket.Key, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (replace)
+ Messages.Clear();
+ MergeMessages(response.Items ?? new List());
+ HasMoreMessages = response.HasMore;
+ _nextBefore = response.NextBefore;
+ await MarkReadBestEffortAsync(ticket);
+ MessageScrollVersion++;
+ }
+ catch (Exception ex)
+ {
+ if (replace)
+ Messages.Clear();
+ HasMoreMessages = false;
+ _nextBefore = null;
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsLoadingMessages = false;
+ }
+ }
+
+ private async Task RefreshAllWorkOrdersAsync(
+ bool loadSelectedMessages,
+ string preferredKey = null)
+ {
+ IsLoading = true;
+ try
+ {
+ var incoming = new List();
+ string cursor = null;
+ var page = 0;
+ do
+ {
+ var response = await _api.GetWorkOrdersAsync(
+ _options,
+ limit: 100,
+ cursor: cursor);
+ incoming.AddRange(response.Items ?? new List());
+ cursor = response.NextCursor;
+ page++;
+ if (!response.HasMore || string.IsNullOrWhiteSpace(cursor))
+ break;
+ }
+ while (page < 20);
+
+ var currentKey = SelectedTicket?.Key;
+ var existing = Tickets.ToDictionary(t => t.Key, StringComparer.Ordinal);
+ var merged = new List();
+ foreach (var dto in incoming)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.AppId)
+ || string.IsNullOrWhiteSpace(dto.WechatOrderId))
+ {
+ continue;
+ }
+ var key = dto.AppId + "|" + dto.WechatOrderId;
+ var hasNewMessage = HasNewMessage(dto);
+ if (existing.TryGetValue(key, out var item))
+ {
+ if (!item.TryApply(dto))
+ continue;
+ }
+ else
+ {
+ item = new WorkOrderSummaryViewModel(dto);
+ }
+ if (hasNewMessage)
+ item.MarkHasNewMessage();
+ if (merged.All(t => !string.Equals(t.Key, item.Key, StringComparison.Ordinal)))
+ merged.Add(item);
+ }
+
+ Tickets.Clear();
+ foreach (var item in merged.OrderByDescending(t => t.UpdatedAt))
+ Tickets.Add(item);
+
+ var target = FindTicket(preferredKey)
+ ?? FindTicket(currentKey)
+ ?? Tickets.FirstOrDefault();
+ if (!IsDraft)
+ SelectedTicket = target;
+
+ SaveWorkOrderList();
+
+ if (loadSelectedMessages && SelectedTicket != null && !IsDraft)
+ await LoadMessagesAsync(SelectedTicket, replace: true);
+ }
+ catch (Exception ex)
+ {
+ SetNotice(DescribeException(ex), true);
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private async Task RefreshSingleWorkOrderAsync(string appId, string wechatOrderId)
+ {
+ if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId))
+ return;
+
+ var options = CloneOptions(_options);
+ options.AppIds = new List { appId };
+ var response = await _api.GetWorkOrdersAsync(
+ options,
+ wechatOrderId,
+ limit: 1);
+ var dto = response.Items?.FirstOrDefault();
+ if (dto == null)
+ return;
+
+ var key = dto.AppId + "|" + dto.WechatOrderId;
+ var hasNewMessage = HasNewMessage(dto);
+ var existing = FindTicket(key);
+ if (existing == null)
+ {
+ existing = new WorkOrderSummaryViewModel(dto);
+ Tickets.Add(existing);
+ }
+ else if (!existing.TryApply(dto))
+ {
+ return;
+ }
+
+ if (hasNewMessage)
+ existing.MarkHasNewMessage();
+ SaveWorkOrderList();
+ ReorderTickets();
+ if (SelectedTicket != null
+ && string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal)
+ && !IsDraft)
+ {
+ await LoadMessagesAsync(SelectedTicket, replace: true);
+ }
+ }
+
+ private void MergeMessages(IEnumerable items)
+ {
+ var byId = Messages
+ .GroupBy(m => m.MessageId)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
+ foreach (var dto in items)
+ {
+ if (dto == null)
+ continue;
+ var id = dto.MessageId;
+ if (string.IsNullOrWhiteSpace(id))
+ id = "sequence:" + dto.SequenceNo;
+ if (!byId.ContainsKey(id))
+ byId[id] = new ChatMessageViewModel(dto);
+ }
+
+ var ordered = byId.Values
+ .OrderBy(m => m.SequenceNo)
+ .ThenBy(m => m.CreatedAt)
+ .ToList();
+ Messages.Clear();
+ foreach (var message in ordered)
+ Messages.Add(message);
+ }
+
+ private void AppendMessage(TicketMessageDto dto)
+ {
+ if (dto == null)
+ return;
+ var id = string.IsNullOrWhiteSpace(dto.MessageId)
+ ? "sequence:" + dto.SequenceNo
+ : dto.MessageId;
+ if (Messages.Any(m => string.Equals(m.MessageId, id, StringComparison.Ordinal)))
+ return;
+
+ var incoming = new ChatMessageViewModel(dto);
+ var index = 0;
+ while (index < Messages.Count
+ && Messages[index].SequenceNo <= incoming.SequenceNo)
+ {
+ index++;
+ }
+ Messages.Insert(index, incoming);
+ }
+
+ private async Task MarkReadBestEffortAsync(WorkOrderSummaryViewModel ticket)
+ {
+ var maxSequence = Messages.Count == 0
+ ? 0
+ : Messages.Max(m => m.SequenceNo);
+ if (maxSequence <= 0)
+ return;
+ if (_lastReadSequences.TryGetValue(ticket.Key, out var lastRead)
+ && maxSequence <= lastRead)
+ {
+ ticket.MarkRead();
+ return;
+ }
+
+ try
+ {
+ await _api.MarkReadAsync(
+ _options,
+ ticket.AppId,
+ ticket.WechatOrderId,
+ maxSequence);
+ _lastReadSequences[ticket.Key] = maxSequence;
+ ticket.MarkRead();
+ SaveWorkOrderList();
+ }
+ catch
+ {
+ // 已读失败不影响留言展示;下次打开工单会重新尝试。
+ }
+ }
+
+ private void OnNotificationReceived(
+ object sender,
+ TicketRealtimeNotificationEventArgs args)
+ {
+ var notification = args?.Notification;
+ if (notification == null)
+ return;
+
+ _dispatcher.BeginInvoke(new Action(() =>
+ {
+ _ = HandleNotificationAsync(notification);
+ }));
+ }
+
+ private async Task HandleNotificationAsync(TicketRealtimeNotification notification)
+ {
+ if (_disposed || !_configured)
+ return;
+
+ if (!string.IsNullOrWhiteSpace(notification.EventId))
+ {
+ if (!_seenEventIds.Add(notification.EventId))
+ return;
+ if (_seenEventIds.Count > 5000)
+ _seenEventIds.Clear();
+ }
+
+ try
+ {
+ if (notification.IsResyncRequired)
+ {
+ await RefreshAllWorkOrdersAsync(
+ loadSelectedMessages: SelectedTicket != null && !IsDraft);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(notification.AppId)
+ || string.IsNullOrWhiteSpace(notification.WechatOrderId))
+ {
+ return;
+ }
+ await RefreshSingleWorkOrderAsync(
+ notification.AppId,
+ notification.WechatOrderId);
+ }
+ catch (Exception ex)
+ {
+ SetNotice("实时通知已收到,但刷新工单失败:" + DescribeException(ex), true);
+ }
+ }
+
+ private void OnRealtimeStateChanged(
+ object sender,
+ TicketRealtimeStateChangedEventArgs args)
+ {
+ _dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_disposed || args == null)
+ return;
+ SseState = args.State;
+ SseStatusText = args.State switch
+ {
+ TicketRealtimeState.Connected => "实时推送:已连接",
+ TicketRealtimeState.Connecting => "实时推送:连接中…",
+ TicketRealtimeState.Reconnecting => "实时推送:重连中…",
+ _ => "实时推送:已断开"
+ };
+ }));
+ }
+
+ private bool CanCreateTicket()
+ {
+ return !IsBusy
+ && IsDraft
+ && SelectedOrder != null
+ && !string.IsNullOrWhiteSpace(DraftText)
+ && DraftText.Trim().Length <= 2000;
+ }
+
+ private void RefreshFilteredOrders()
+ {
+ var selectedKey = SelectedOrder?.OrderKey;
+ var keyword = (OrderFilterText ?? string.Empty).Trim();
+ var matches = string.IsNullOrWhiteSpace(keyword)
+ ? AvailableOrders
+ : AvailableOrders.Where(order =>
+ ContainsIgnoreCase(order.OrderId, keyword)
+ || ContainsIgnoreCase(order.GoodsName, keyword));
+
+ FilteredOrders.Clear();
+ foreach (var order in matches)
+ FilteredOrders.Add(order);
+
+ SelectedOrder = FilteredOrders.FirstOrDefault(order =>
+ string.Equals(
+ order.OrderKey,
+ selectedKey,
+ StringComparison.Ordinal))
+ ?? FilteredOrders.FirstOrDefault();
+
+ RaisePropertyChanged(nameof(HasFilteredOrders));
+ RaisePropertyChanged(nameof(OrderFilterSummaryText));
+ }
+
+ private static bool ContainsIgnoreCase(string source, string keyword)
+ {
+ return !string.IsNullOrWhiteSpace(source)
+ && source.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ private bool CanSendMessage()
+ {
+ return !IsBusy
+ && SelectedTicket != null
+ && !string.IsNullOrWhiteSpace(ChatText)
+ && ChatText.Trim().Length <= 4000;
+ }
+
+ private void RaiseSendCanExecuteChanged()
+ {
+ _sendMessageCommand.RaiseCanExecuteChanged();
+ }
+
+ private void RaiseCreateCanExecuteChanged()
+ {
+ _createTicketCommand.RaiseCanExecuteChanged();
+ }
+
+ private void RaiseLoadEarlierCanExecuteChanged()
+ {
+ _loadEarlierMessagesCommand.RaiseCanExecuteChanged();
+ }
+
+ private void SetNotice(string text, bool isError)
+ {
+ NoticeText = text ?? string.Empty;
+ IsNoticeError = isError;
+ }
+
+ private void ClearNotice()
+ {
+ NoticeText = string.Empty;
+ IsNoticeError = false;
+ }
+
+ private WorkOrderSummaryViewModel FindTicket(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return null;
+ return Tickets.FirstOrDefault(t =>
+ string.Equals(t.Key, key, StringComparison.Ordinal));
+ }
+
+ private void MoveTicketToTop(WorkOrderSummaryViewModel ticket)
+ {
+ var index = Tickets.IndexOf(ticket);
+ if (index > 0)
+ Tickets.Move(index, 0);
+ }
+
+ private void ReorderTickets()
+ {
+ var ordered = Tickets.OrderByDescending(t => t.UpdatedAt).ToList();
+ for (var target = 0; target < ordered.Count; target++)
+ {
+ var current = Tickets.IndexOf(ordered[target]);
+ if (current != target)
+ Tickets.Move(current, target);
+ }
+ }
+
+ private static WorkOrderSummaryDto ToSummaryDto(WorkOrderSummaryViewModel ticket)
+ {
+ return new WorkOrderSummaryDto
+ {
+ AppId = ticket.AppId,
+ WechatOrderId = ticket.WechatOrderId,
+ Status = ticket.Status,
+ Version = ticket.Version
+ };
+ }
+
+ private void LoadSavedOrderTimes()
+ {
+ _savedWorkOrders.Clear();
+ _hasSavedCache = false;
+ 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))
+ return;
+
+ foreach (var saved in cache.Items)
+ {
+ if (saved == null
+ || string.IsNullOrWhiteSpace(saved.OrderId))
+ {
+ continue;
+ }
+
+ _savedWorkOrders[saved.OrderId] = saved;
+ }
+ _hasSavedCache = true;
+ }
+
+ private void UpdateSavedWorkOrders()
+ {
+ _savedWorkOrders = Tickets
+ .GroupBy(t => t.WechatOrderId, StringComparer.Ordinal)
+ .ToDictionary(
+ group => group.Key,
+ group => ToCacheItem(group.First()),
+ StringComparer.Ordinal);
+
+ var newCount = Tickets.Count(ticket => ticket.HasNewMessage);
+ HasNewData = newCount > 0;
+ NewDataText = newCount > 0
+ ? $"有 {newCount} 个工单有新数据"
+ : string.Empty;
+ }
+
+ private void SaveWorkOrderList()
+ {
+ if (string.IsNullOrWhiteSpace(_options?.UserName))
+ return;
+
+ UpdateSavedWorkOrders();
+ var cache = new WorkOrderListCache
+ {
+ UserName = _options.UserName,
+ Items = _savedWorkOrders.Values
+ .OrderByDescending(item => item.LastMessageAt)
+ .ToList()
+ };
+
+ try
+ {
+ ConfigHelper.SaveConfig(SavedListConfigName, cache);
+ _hasSavedCache = true;
+ }
+ catch
+ {
+ // 本地缓存写入失败不应影响工单接口和页面操作。
+ }
+ }
+
+ private bool HasNewMessage(WorkOrderSummaryDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.WechatOrderId))
+ return false;
+ if (!_savedWorkOrders.TryGetValue(dto.WechatOrderId, out var saved))
+ return _hasSavedCache;
+
+ return ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt;
+ }
+
+ private static WorkOrderListCacheItem ToCacheItem(
+ WorkOrderSummaryViewModel ticket)
+ {
+ return new WorkOrderListCacheItem
+ {
+ OrderId = ticket.WechatOrderId,
+ LastMessageAt = ticket.LastMessageAt
+ };
+ }
+
+ 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)
+ {
+ return prefix
+ + "-"
+ + DateTime.UtcNow.ToString("yyyyMMddHHmmss")
+ + "-"
+ + Guid.NewGuid().ToString("N");
+ }
+
+ private static TicketClientOptions CloneOptions(TicketClientOptions options)
+ {
+ if (options == null)
+ return new TicketClientOptions();
+ return new TicketClientOptions
+ {
+ UserName = options.UserName,
+ AppIds = (options.AppIds ?? Enumerable.Empty())
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Select(a => a.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToList(),
+ Orders = (options.Orders ?? Enumerable.Empty())
+ .Where(o => o != null)
+ .ToList()
+ };
+ }
+
+ private static string DescribeException(Exception ex)
+ {
+ if (ex is TicketApiException apiException)
+ {
+ var hint = apiException.StatusCode switch
+ {
+ 400 => "请求格式错误。",
+ 404 => "该 APPID 和订单下尚无工单。",
+ 409 => "数据版本或幂等编号冲突。",
+ 413 => "请求内容超过大小限制。",
+ 422 => "参数或订单归属校验失败。",
+ 429 => "请求过于频繁,请稍后重试。",
+ 503 => "工单服务暂未开启或数据库繁忙。",
+ _ => null
+ };
+ return string.IsNullOrWhiteSpace(hint)
+ ? apiException.Message
+ : hint + " " + apiException.Message;
+ }
+ if (ex is TimeoutException)
+ return ex.Message;
+ if (ex is ArgumentException)
+ return ex.Message;
+ return "操作失败:" + ex.Message;
+ }
+}
diff --git a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs
new file mode 100644
index 0000000..663bf19
--- /dev/null
+++ b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs
@@ -0,0 +1,289 @@
+using OrderSupportTicket.Models.Api;
+using System;
+using System.Globalization;
+using WPFCommon.Models;
+
+namespace OrderSupportTicket.ViewModels;
+
+///
+/// 历史工单列表中的单个工单摘要。
+///
+public sealed class WorkOrderSummaryViewModel : BindableBaseEx
+{
+ private string _appId;
+ private string _wechatOrderId;
+ private string _status;
+ private string _statusText;
+ private DateTimeOffset _updatedAt;
+ private DateTimeOffset _lastMessageAt;
+ private DateTimeOffset? _lastReplyAt;
+ private string _lastSenderType;
+ private int _unreadCount;
+ private bool _hasNewMessage;
+ private string _version;
+
+ public WorkOrderSummaryViewModel(WorkOrderSummaryDto dto)
+ {
+ if (dto == null)
+ throw new ArgumentNullException(nameof(dto));
+ Apply(dto);
+ }
+
+ ///
+ /// 订单所属店铺 APPID。
+ ///
+ public string AppId
+ {
+ get => _appId;
+ private set => SetValue(ref _appId, value);
+ }
+
+ ///
+ /// 微信订单号,也是客户端使用的工单号。
+ ///
+ public string WechatOrderId
+ {
+ get => _wechatOrderId;
+ private set => SetValue(ref _wechatOrderId, value);
+ }
+
+ ///
+ /// 工单状态编码。
+ ///
+ public string Status
+ {
+ get => _status;
+ private set
+ {
+ if (SetValue(ref _status, value))
+ RaisePropertyChanged(nameof(IsCompleted));
+ }
+ }
+
+ ///
+ /// 工单状态中文文本。
+ ///
+ public string StatusText
+ {
+ get => _statusText;
+ private set => SetValue(ref _statusText, value);
+ }
+
+ ///
+ /// 工单最后更新时间。
+ ///
+ public DateTimeOffset UpdatedAt
+ {
+ get => _updatedAt;
+ private set
+ {
+ if (SetValue(ref _updatedAt, value))
+ RaisePropertyChanged(nameof(UpdatedAtText));
+ }
+ }
+
+ ///
+ /// 最近一条公开留言的时间。
+ ///
+ public DateTimeOffset LastMessageAt
+ {
+ get => _lastMessageAt;
+ private set => SetValue(ref _lastMessageAt, value);
+ }
+
+ ///
+ /// 最近一次客服正式回复时间。
+ ///
+ public DateTimeOffset? LastReplyAt
+ {
+ get => _lastReplyAt;
+ private set => SetValue(ref _lastReplyAt, value);
+ }
+
+ ///
+ /// 最近一条留言的发送方类型。
+ ///
+ public string LastSenderType
+ {
+ get => _lastSenderType;
+ private set
+ {
+ if (SetValue(ref _lastSenderType, value))
+ RaisePropertyChanged(nameof(PreviewText));
+ }
+ }
+
+ ///
+ /// 当前工单的未读留言数量。
+ ///
+ public int UnreadCount
+ {
+ get => _unreadCount;
+ private set => SetValue(ref _unreadCount, value);
+ }
+
+ ///
+ /// 本次服务器刷新是否检测到比本地缓存更新的公开消息。
+ ///
+ public bool HasNewMessage
+ {
+ get => _hasNewMessage;
+ private set
+ {
+ if (SetValue(ref _hasNewMessage, value))
+ RaisePropertyChanged(nameof(PreviewText));
+ }
+ }
+
+ ///
+ /// 工单版本号字符串。
+ ///
+ public string Version
+ {
+ get => _version;
+ private set => SetValue(ref _version, value);
+ }
+
+ ///
+ /// 用于合并工单的 APPID 与订单号组合键。
+ ///
+ public string Key => AppId + "|" + WechatOrderId;
+
+ ///
+ /// 工单是否已完结。
+ ///
+ public bool IsCompleted =>
+ string.Equals(Status, "completed", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// 适合列表展示的本地时间文本。
+ ///
+ public string UpdatedAtText =>
+ UpdatedAt == default
+ ? string.Empty
+ : UpdatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture);
+
+ ///
+ /// 不读取留言正文时使用的最近动态摘要。
+ ///
+ public string PreviewText
+ {
+ get
+ {
+ if (HasNewMessage)
+ return "有新消息,点击查看";
+ if (string.Equals(
+ LastSenderType,
+ "customer",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return "客户最近有新留言";
+ }
+ if (LastReplyAt.HasValue)
+ return "客服最近已回复";
+ return "暂无公开留言";
+ }
+ }
+
+ public bool TryApply(WorkOrderSummaryDto dto)
+ {
+ if (dto == null || !string.Equals(Key, dto.AppId + "|" + dto.WechatOrderId,
+ StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (!IsAtLeastCurrentVersion(dto.Version))
+ return false;
+ Apply(dto);
+ return true;
+ }
+
+ public void ApplySendResult(string status, string version)
+ {
+ if (!string.IsNullOrWhiteSpace(status))
+ {
+ Status = status;
+ StatusText = GetStatusText(status);
+ }
+ if (!string.IsNullOrWhiteSpace(version))
+ Version = version;
+ UpdatedAt = DateTimeOffset.Now;
+ LastMessageAt = UpdatedAt;
+ LastSenderType = "customer";
+ HasNewMessage = false;
+ }
+
+ public void MarkRead()
+ {
+ UnreadCount = 0;
+ HasNewMessage = false;
+ }
+
+ ///
+ /// 标记检测到新消息,并确保列表显示未读角标。
+ ///
+ public void MarkHasNewMessage()
+ {
+ HasNewMessage = true;
+ if (UnreadCount <= 0)
+ UnreadCount = 1;
+ }
+
+ private void Apply(WorkOrderSummaryDto dto)
+ {
+ AppId = dto.AppId;
+ WechatOrderId = dto.WechatOrderId;
+ Status = dto.Status;
+ StatusText = string.IsNullOrWhiteSpace(dto.StatusText)
+ ? GetStatusText(dto.Status)
+ : dto.StatusText;
+ UpdatedAt = ParseTimestamp(dto.UpdatedAt);
+ LastMessageAt = ParseTimestamp(dto.LastMessageAt);
+ LastReplyAt = ParseNullableTimestamp(dto.LastReplyAt);
+ LastSenderType = dto.LastSenderType;
+ UnreadCount = Math.Max(0, dto.UnreadCount);
+ Version = dto.Version;
+ }
+
+ private bool IsAtLeastCurrentVersion(string incomingVersion)
+ {
+ if (string.IsNullOrWhiteSpace(incomingVersion))
+ return true;
+ if (!long.TryParse(incomingVersion, out var incoming))
+ return true;
+ if (!long.TryParse(Version, out var current))
+ return true;
+ return incoming >= current;
+ }
+
+ private static DateTimeOffset ParseTimestamp(string value)
+ {
+ if (DateTimeOffset.TryParse(
+ value,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal,
+ out var parsed))
+ {
+ return parsed;
+ }
+ return default;
+ }
+
+ private static DateTimeOffset? ParseNullableTimestamp(string value)
+ {
+ var parsed = ParseTimestamp(value);
+ return parsed == default ? (DateTimeOffset?)null : parsed;
+ }
+
+ private static string GetStatusText(string status)
+ {
+ if (string.Equals(status, "waiting_reply", StringComparison.OrdinalIgnoreCase))
+ return "待客服回复";
+ if (string.Equals(status, "replied", StringComparison.OrdinalIgnoreCase))
+ return "客服已回复";
+ if (string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase))
+ return "已完结";
+ return string.IsNullOrWhiteSpace(status) ? "状态未知" : status;
+ }
+}
diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml b/OrderSupportTicket/Views/OrderSupportTicketView.xaml
new file mode 100644
index 0000000..8a1b1d6
--- /dev/null
+++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml
@@ -0,0 +1,912 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
new file mode 100644
index 0000000..d1b634b
--- /dev/null
+++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
@@ -0,0 +1,94 @@
+using OrderSupportTicket.Models;
+using OrderSupportTicket.Services;
+using OrderSupportTicket.ViewModels;
+using System.Windows.Controls;
+
+namespace OrderSupportTicket.Views;
+
+///
+/// 工单对话页面。页面生命周期只负责激活/停用 ViewModel,业务交互均在 ViewModel 中。
+///
+public partial class OrderSupportTicketView : UserControl
+{
+ private bool _active;
+
+ public OrderSupportTicketView()
+ : this(
+ new TicketApiService(),
+ new EventSourceTicketRealtimeService(),
+ TicketClientOptions.FromApplicationSettings())
+ {
+ }
+
+ public OrderSupportTicketView(
+ ITicketApiService api,
+ ITicketRealtimeService realtime,
+ TicketClientOptions options = null)
+ {
+ InitializeComponent();
+ options = new TicketClientOptions
+ {
+ UserName="18990991234",
+ AppIds = ["wx316b1291351558aa", "wx7999743c3d64c05c"],
+ Orders = [
+ new TicketOrderOption
+ {
+ Amount=109m,
+ AppId="wx316b1291351558aa",
+ GoodsName="梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤",
+ OrderId="9000000000000000001",
+ OrderedAt=new DateTime(2020, 1, 1),
+ },
+ new TicketOrderOption
+ {
+ Amount=109m,
+ AppId="wx7999743c3d64c05c",
+ GoodsName="洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸",
+ OrderId="9000000000000000002",
+ OrderedAt=new DateTime(2020, 1, 1),
+ }
+ ]
+ };
+ ViewModel = new OrderSupportTicketViewModel(api, realtime, options);
+ DataContext = ViewModel;
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ ///
+ /// 当前页面对应的工单 ViewModel。
+ ///
+ public OrderSupportTicketViewModel ViewModel { get; }
+
+ ///
+ /// 宿主可在页面加载前注入账号、APPID 与本地订单数据。
+ ///
+ public void Configure(TicketClientOptions options)
+ {
+ ViewModel.Configure(options);
+ }
+
+ ///
+ /// 从订单页直接打开新工单草稿。
+ ///
+ public void OpenNewTicket(TicketOrderOption order)
+ {
+ ViewModel.OpenNewTicket(order);
+ }
+
+ private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
+ {
+ if (_active)
+ return;
+ _active = true;
+ await ViewModel.InitializeAsync();
+ }
+
+ private async void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
+ {
+ if (!_active)
+ return;
+ _active = false;
+ await ViewModel.DeactivateAsync();
+ }
+}
diff --git a/OrderSupportTicket/demo.html b/OrderSupportTicket/demo.html
new file mode 100644
index 0000000..8af0622
--- /dev/null
+++ b/OrderSupportTicket/demo.html
@@ -0,0 +1,318 @@
+
+
+
+
+
+工单对话 Demo
+
+
+
+
+
+
+
+
+
+