增加工单模块

This commit is contained in:
chfei committed 2026-10-17 18:18:05 +08:00
1 parent dc136e449e
commit f9dcae4610
17 files changed
+4908

No files matched your search

+18
View File
@@ -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
@@ -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;
/// <summary>
/// 仅承载视图交互:列表点击命令、消息列表滚动到底部。
/// </summary>
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<ScrollViewer>(listBox);
if (scrollViewer != null)
scrollViewer.ScrollToEnd();
}),
DispatcherPriority.Background);
}
private static T FindVisualChild<T>(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<T>(child);
if (nested != null)
return nested;
}
return null;
}
}
@@ -0,0 +1,549 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
namespace OrderSupportTicket.Models.Api;
/// <summary>
/// 创建工单请求。
/// </summary>
[DataContract]
internal sealed class CreateWorkOrderRequest
{
/// <summary>
/// 拍单账号。
/// </summary>
[DataMember(Name = "purchaseAccount")]
public string PurchaseAccount { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 微信订单号,保持为数字字符串。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 首条问题描述,长度 1~2000 字。
/// </summary>
[DataMember(Name = "description")]
public string Description { get; set; }
/// <summary>
/// 可选的创建幂等编号。
/// </summary>
[DataMember(Name = "clientRequestId", EmitDefaultValue = false)]
public string ClientRequestId { get; set; }
}
/// <summary>
/// 工单摘要列表请求。
/// </summary>
[DataContract]
internal sealed class ListWorkOrdersRequest
{
/// <summary>
/// 拍单账号。
/// </summary>
[DataMember(Name = "purchaseAccount")]
public string PurchaseAccount { get; set; }
/// <summary>
/// 要查询的店铺 APPID 数组。
/// </summary>
[DataMember(Name = "appIds")]
public IList<string> AppIds { get; set; }
/// <summary>
/// 可选的微信订单号精确筛选条件。
/// </summary>
[DataMember(Name = "wechatOrderId", EmitDefaultValue = false)]
public string WechatOrderId { get; set; }
/// <summary>
/// 可选的工单状态筛选条件。
/// </summary>
[DataMember(Name = "status", EmitDefaultValue = false)]
public string Status { get; set; }
/// <summary>
/// 本页最大返回数量,范围 1~100。
/// </summary>
[DataMember(Name = "limit")]
public int Limit { get; set; }
/// <summary>
/// 下一页游标,使用服务端返回的原始值。
/// </summary>
[DataMember(Name = "cursor", EmitDefaultValue = false)]
public string Cursor { get; set; }
}
/// <summary>
/// 查询订单留言板请求。
/// </summary>
[DataContract]
internal sealed class GetMessagesRequest
{
/// <summary>
/// 拍单账号。
/// </summary>
[DataMember(Name = "purchaseAccount")]
public string PurchaseAccount { get; set; }
/// <summary>
/// 微信订单号,保持为数字字符串。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 本页最大返回数量,范围 1~100。
/// </summary>
[DataMember(Name = "limit")]
public int Limit { get; set; }
/// <summary>
/// 加载更早留言时使用的 before 游标。
/// </summary>
[DataMember(Name = "before", EmitDefaultValue = false)]
public string Before { get; set; }
}
/// <summary>
/// 提交客户留言请求。
/// </summary>
[DataContract]
internal sealed class SendMessageRequest
{
/// <summary>
/// 拍单账号。
/// </summary>
[DataMember(Name = "purchaseAccount")]
public string PurchaseAccount { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 留言幂等编号。
/// </summary>
[DataMember(Name = "clientMessageId")]
public string ClientMessageId { get; set; }
/// <summary>
/// 留言正文,长度 1~4000 字。
/// </summary>
[DataMember(Name = "text")]
public string Text { get; set; }
}
/// <summary>
/// 标记留言已读请求。
/// </summary>
[DataContract]
internal sealed class MarkReadRequest
{
/// <summary>
/// 拍单账号。
/// </summary>
[DataMember(Name = "purchaseAccount")]
public string PurchaseAccount { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 已读到的最大留言序号。
/// </summary>
[DataMember(Name = "throughSequenceNo")]
public int ThroughSequenceNo { get; set; }
}
/// <summary>
/// 工单摘要分页响应。
/// </summary>
[DataContract]
public sealed class WorkOrderListResponse
{
/// <summary>
/// 请求是否成功。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
/// <summary>
/// 当前页工单摘要。
/// </summary>
[DataMember(Name = "items")]
public List<WorkOrderSummaryDto> Items { get; set; }
/// <summary>
/// 是否还有下一页。
/// </summary>
[DataMember(Name = "hasMore")]
public bool HasMore { get; set; }
/// <summary>
/// 下一页游标。
/// </summary>
[DataMember(Name = "nextCursor")]
public string NextCursor { get; set; }
/// <summary>
/// 接口返回的身份校验标记,当前恒为 false。
/// </summary>
[DataMember(Name = "identityVerified")]
public bool IdentityVerified { get; set; }
}
/// <summary>
/// 单个订单的工单摘要。
/// </summary>
[DataContract]
public sealed class WorkOrderSummaryDto
{
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 微信订单号,保持为数字字符串。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 工单状态编码。
/// </summary>
[DataMember(Name = "status")]
public string Status { get; set; }
/// <summary>
/// 工单状态中文文本。
/// </summary>
[DataMember(Name = "statusText")]
public string StatusText { get; set; }
/// <summary>
/// 工单创建时间,由接口以 ISO 8601 字符串传输。
/// </summary>
[DataMember(Name = "createdAt")]
public string CreatedAt { get; set; }
/// <summary>
/// 工单更新时间,由接口以 ISO 8601 字符串传输。
/// </summary>
[DataMember(Name = "updatedAt")]
public string UpdatedAt { get; set; }
/// <summary>
/// 最近一条公开留言时间,由接口以 ISO 8601 字符串传输。
/// </summary>
[DataMember(Name = "lastMessageAt")]
public string LastMessageAt { get; set; }
/// <summary>
/// 最近一次客服正式回复时间;从未回复时为 null。
/// </summary>
[DataMember(Name = "lastReplyAt")]
public string LastReplyAt { get; set; }
/// <summary>
/// 最近一条公开留言的发送方类型。
/// </summary>
[DataMember(Name = "lastSenderType")]
public string LastSenderType { get; set; }
/// <summary>
/// 未读留言数量。
/// </summary>
[DataMember(Name = "unreadCount")]
public int UnreadCount { get; set; }
/// <summary>
/// 工单版本号,按接口返回的字符串原样保存。
/// </summary>
[DataMember(Name = "version")]
public string Version { get; set; }
}
/// <summary>
/// 订单留言板响应。
/// </summary>
[DataContract]
public sealed class WorkOrderMessagesResponse
{
/// <summary>
/// 请求是否成功。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 微信订单号。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 当前页公开留言,按 sequenceNo 升序排列。
/// </summary>
[DataMember(Name = "items")]
public List<TicketMessageDto> Items { get; set; }
/// <summary>
/// 是否还有更早的留言。
/// </summary>
[DataMember(Name = "hasMore")]
public bool HasMore { get; set; }
/// <summary>
/// 加载更早留言时使用的 before 游标。
/// </summary>
[DataMember(Name = "nextBefore")]
public string NextBefore { get; set; }
/// <summary>
/// 接口返回的身份校验标记,当前恒为 false。
/// </summary>
[DataMember(Name = "identityVerified")]
public bool IdentityVerified { get; set; }
}
/// <summary>
/// 单条公开留言。
/// </summary>
[DataContract]
public sealed class TicketMessageDto
{
/// <summary>
/// 消息唯一 ID,用于去重。
/// </summary>
[DataMember(Name = "messageId")]
public string MessageId { get; set; }
/// <summary>
/// 工单内连续递增的消息序号。
/// </summary>
[DataMember(Name = "sequenceNo")]
public int SequenceNo { get; set; }
/// <summary>
/// 发送方类型,例如 customer。
/// </summary>
[DataMember(Name = "senderType")]
public string SenderType { get; set; }
/// <summary>
/// 发送方显示名称。
/// </summary>
[DataMember(Name = "senderName")]
public string SenderName { get; set; }
/// <summary>
/// 留言正文;消息撤回后为空字符串。
/// </summary>
[DataMember(Name = "text")]
public string Text { get; set; }
/// <summary>
/// 消息是否已撤回。
/// </summary>
[DataMember(Name = "recalled")]
public bool Recalled { get; set; }
/// <summary>
/// 消息发送时间,由接口以 ISO 8601 字符串传输。
/// </summary>
[DataMember(Name = "createdAt")]
public string CreatedAt { get; set; }
}
/// <summary>
/// 创建工单响应。
/// </summary>
[DataContract]
public sealed class CreateWorkOrderResponse
{
/// <summary>
/// 请求是否成功。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
/// <summary>
/// 创建结果,取值 created、existing 或 reopened。
/// </summary>
[DataMember(Name = "operation")]
public string Operation { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 微信订单号。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 操作后的工单状态。
/// </summary>
[DataMember(Name = "status")]
public string Status { get; set; }
/// <summary>
/// 创建或重开时追加的首条消息 ID。
/// </summary>
[DataMember(Name = "messageId")]
public string MessageId { get; set; }
/// <summary>
/// 操作后的工单版本号字符串。
/// </summary>
[DataMember(Name = "version")]
public string Version { get; set; }
/// <summary>
/// 是否为幂等编号对应的历史回执。
/// </summary>
[DataMember(Name = "replayed")]
public bool Replayed { get; set; }
/// <summary>
/// 接口返回的身份校验标记,当前恒为 false。
/// </summary>
[DataMember(Name = "identityVerified")]
public bool IdentityVerified { get; set; }
}
/// <summary>
/// 提交客户留言响应。
/// </summary>
[DataContract]
public sealed class SendMessageResponse
{
/// <summary>
/// 请求是否成功。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 微信订单号。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 服务端正式生成的消息对象。
/// </summary>
[DataMember(Name = "message")]
public TicketMessageDto Message { get; set; }
/// <summary>
/// 留言后的工单状态。
/// </summary>
[DataMember(Name = "status")]
public string Status { get; set; }
/// <summary>
/// 留言后的工单版本号字符串。
/// </summary>
[DataMember(Name = "version")]
public string Version { get; set; }
/// <summary>
/// 本次留言是否重新开启了已完结工单。
/// </summary>
[DataMember(Name = "reopened")]
public bool Reopened { get; set; }
/// <summary>
/// 是否为幂等编号对应的历史回执。
/// </summary>
[DataMember(Name = "replayed")]
public bool Replayed { get; set; }
/// <summary>
/// 接口返回的身份校验标记,当前恒为 false。
/// </summary>
[DataMember(Name = "identityVerified")]
public bool IdentityVerified { get; set; }
}
/// <summary>
/// 通用成功状态响应,仅用于在解析具体业务对象前检查顶层 success。
/// </summary>
[DataContract]
internal sealed class ApiSuccessResponse
{
/// <summary>
/// 请求是否成功。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
}
/// <summary>
/// 工单接口错误响应。
/// </summary>
[DataContract]
internal sealed class ApiErrorResponse
{
/// <summary>
/// 请求是否成功,错误响应通常为 false。
/// </summary>
[DataMember(Name = "success")]
public bool Success { get; set; }
/// <summary>
/// 可展示给用户的错误说明。
/// </summary>
[DataMember(Name = "message")]
public string Message { get; set; }
/// <summary>
/// 兼容旧接口的错误说明字段。
/// </summary>
[DataMember(Name = "error")]
public string Error { get; set; }
/// <summary>
/// 服务端错误编码。
/// </summary>
[DataMember(Name = "code")]
public string Code { get; set; }
}
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Globalization;
using System.Linq;
namespace OrderSupportTicket.Models;
/// <summary>
/// 工单页运行参数。APPID 与拍单账号由宿主程序注入,页面内不硬编码账号。
/// </summary>
public sealed class TicketClientOptions
{
public TicketClientOptions()
{
AppIds = new List<string>();
Orders = new List<TicketOrderOption>();
}
/// <summary>
/// 当前拍单账号,对应接口字段 purchaseAccount。
/// </summary>
public string UserName { get; set; }
/// <summary>
/// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。
/// </summary>
public IList<string> AppIds { get; set; }
/// <summary>
/// 可关联到新工单的本地订单集合。
/// </summary>
public IList<TicketOrderOption> 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()
};
}
}
/// <summary>
/// 新建工单时需要在本地订单列表中关联的订单。
/// 微信订单号始终是字符串,禁止转换为 Number。
/// </summary>
public sealed class TicketOrderOption
{
/// <summary>
/// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。
/// </summary>
public string AppId { get; set; }
/// <summary>
/// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。
/// </summary>
public string OrderId { get; set; }
/// <summary>
/// 商品名称,用于订单筛选和页面展示。
/// </summary>
public string GoodsName { get; set; }
/// <summary>
/// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// 订单下单时间。
/// </summary>
public DateTime OrderedAt { get; set; }
/// <summary>
/// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。
/// </summary>
public string DisplayText
{
get
{
var product = string.IsNullOrWhiteSpace(GoodsName)
? "未命名商品"
: GoodsName;
return $"{OrderId} · {product}";
}
}
/// <summary>
/// 订单详情展示文本,包含商品名、金额和下单时间。
/// </summary>
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}";
}
}
/// <summary>
/// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
/// </summary>
public string OrderKey => $"{AppId}|{OrderId}";
}
@@ -0,0 +1,133 @@
using System.Runtime.Serialization;
namespace OrderSupportTicket.Models;
/// <summary>
/// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。
/// </summary>
[DataContract]
public sealed class TicketRealtimeNotification
{
/// <summary>
/// 服务端事件 ID,用于至少一次投递时的去重。
/// </summary>
[DataMember(Name = "eventId")]
public string EventId { get; set; }
/// <summary>
/// 事件类型,例如 relay.ticket.message_added 或 resync_required。
/// </summary>
[DataMember(Name = "type")]
public string Type { get; set; }
/// <summary>
/// 发生变更的店铺 APPID。
/// </summary>
[DataMember(Name = "appId")]
public string AppId { get; set; }
/// <summary>
/// 发生变更的微信订单号,保持为数字字符串。
/// </summary>
[DataMember(Name = "wechatOrderId")]
public string WechatOrderId { get; set; }
/// <summary>
/// 新增消息的消息 ID。
/// </summary>
[DataMember(Name = "messageId")]
public string MessageId { get; set; }
/// <summary>
/// 工单版本号,按服务端返回的字符串原样保存。
/// </summary>
[DataMember(Name = "version")]
public string Version { get; set; }
/// <summary>
/// 事件发生时间,保留服务端 ISO 8601 字符串。
/// </summary>
[DataMember(Name = "occurredAt")]
public string OccurredAt { get; set; }
/// <summary>
/// 是否要求客户端执行全量重新同步。
/// </summary>
public bool IsResyncRequired =>
string.Equals(Type, "resync_required", System.StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// SSE 连接状态。
/// </summary>
public enum TicketRealtimeState
{
/// <summary>
/// 未连接。
/// </summary>
Disconnected,
/// <summary>
/// 正在建立连接。
/// </summary>
Connecting,
/// <summary>
/// 已连接并接收事件。
/// </summary>
Connected,
/// <summary>
/// 连接中断,正在自动重连。
/// </summary>
Reconnecting
}
/// <summary>
/// SSE 通知事件参数。
/// </summary>
public sealed class TicketRealtimeNotificationEventArgs : System.EventArgs
{
/// <summary>
/// 创建 SSE 通知事件参数。
/// </summary>
/// <param name="notification">收到的 SSE 通知。</param>
public TicketRealtimeNotificationEventArgs(TicketRealtimeNotification notification)
{
Notification = notification;
}
/// <summary>
/// 收到的 SSE 通知。
/// </summary>
public TicketRealtimeNotification Notification { get; }
}
/// <summary>
/// SSE 连接状态变化事件参数。
/// </summary>
public sealed class TicketRealtimeStateChangedEventArgs : System.EventArgs
{
/// <summary>
/// 创建 SSE 连接状态变化事件参数。
/// </summary>
/// <param name="state">变化后的连接状态。</param>
/// <param name="message">可选的连接状态说明。</param>
public TicketRealtimeStateChangedEventArgs(
TicketRealtimeState state,
string message = null)
{
State = state;
Message = message;
}
/// <summary>
/// 变化后的连接状态。
/// </summary>
public TicketRealtimeState State { get; }
/// <summary>
/// 连接状态说明。
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace OrderSupportTicket.Models;
/// <summary>
/// 工单列表本地缓存,仅用于比较服务器列表是否有新数据。
/// </summary>
public sealed class WorkOrderListCache
{
/// <summary>
/// 缓存对应的登录账号。
/// </summary>
public string UserName { get; set; }
/// <summary>
/// 用于判断新消息的订单时间缓存项。
/// </summary>
public List<WorkOrderListCacheItem> Items { get; set; } =
new List<WorkOrderListCacheItem>();
}
/// <summary>
/// 单个订单的新消息比较信息。
/// </summary>
public sealed class WorkOrderListCacheItem
{
/// <summary>
/// 微信订单号。
/// </summary>
public string OrderId { get; set; }
/// <summary>
/// 最后一条公开消息时间,用于判断是否有任意新消息。
/// </summary>
public DateTimeOffset LastMessageAt { get; set; }
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net462</TargetFramework>
<LangVersion>12.0</LangVersion>
<ImplicitUsings>true</ImplicitUsings>
<UseWPF>true</UseWPF>
<Nullable>disable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LaunchDarkly.EventSource" Version="6.0.0" />
<PackageReference Include="WPFCommon" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AllBase\29WxOrderAllBase.csproj" />
<ProjectReference Include="..\WxOrder.Framework\WxOrder.Framework.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Configuration" />
<Reference Include="System.Runtime.Serialization" />
<Reference Include="NaiBa.Util">
<HintPath>..\DllReference\NaiBa.Util.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
/// </summary>
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<TicketRealtimeNotificationEventArgs> NotificationReceived;
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
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);
}
}
@@ -0,0 +1,87 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 客户工单 v2 接口。
/// </summary>
public interface ITicketApiService
{
/// <summary>
/// 查询工单摘要列表。
/// </summary>
Task<WorkOrderListResponse> GetWorkOrdersAsync(
TicketClientOptions options,
string wechatOrderId = null,
string status = null,
int limit = 20,
string cursor = null);
/// <summary>
/// 查询指定订单的公开留言板。
/// </summary>
Task<WorkOrderMessagesResponse> GetMessagesAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string before = null,
int limit = 50);
/// <summary>
/// 提交问题或创建工单。
/// </summary>
Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
TicketClientOptions options,
TicketOrderOption order,
string description,
string clientRequestId = null);
/// <summary>
/// 提交客户留言。
/// </summary>
Task<SendMessageResponse> SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId);
/// <summary>
/// 标记当前订单留言已读。
/// </summary>
Task MarkReadAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
int throughSequenceNo);
}
/// <summary>
/// 工单接口调用失败时返回的异常。
/// </summary>
public sealed class TicketApiException : System.Exception
{
/// <summary>
/// 创建工单接口异常。
/// </summary>
public TicketApiException(
int statusCode,
string message,
string responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
/// <summary>
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
/// </summary>
public int StatusCode { get; }
/// <summary>
/// 服务端返回的原始响应正文。
/// </summary>
public string ResponseBody { get; }
}
@@ -0,0 +1,36 @@
using OrderSupportTicket.Models;
using System;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单 SSE 实时通知服务。
/// </summary>
public interface ITicketRealtimeService : IDisposable
{
/// <summary>
/// 收到工单变更摘要通知时触发。
/// </summary>
event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
/// <summary>
/// SSE 连接状态变化时触发。
/// </summary>
event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
TicketRealtimeState State { get; }
/// <summary>
/// 使用指定工单参数建立 SSE 连接。
/// </summary>
void Start(TicketClientOptions options);
/// <summary>
/// 停止 SSE 连接。
/// </summary>
Task StopAsync();
}
@@ -0,0 +1,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;
/// <summary>
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
/// </summary>
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);
/// <summary>
/// 获取工单列表
/// </summary>
/// <param name="options"></param>
/// <param name="wechatOrderId"></param>
/// <param name="status"></param>
/// <param name="limit"></param>
/// <param name="cursor"></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public async Task<WorkOrderListResponse> 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<WorkOrderListResponse>(
ApiRoot + "/list",
request).ConfigureAwait(false);
}
/// <summary>
/// 获取工单消息
/// </summary>
/// <param name="options"></param>
/// <param name="appId"></param>
/// <param name="wechatOrderId"></param>
/// <param name="before"></param>
/// <param name="limit"></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public async Task<WorkOrderMessagesResponse> 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<WorkOrderMessagesResponse>(path, request)
.ConfigureAwait(false);
}
/// <summary>
/// 创建工单
/// </summary>
/// <param name="options"></param>
/// <param name="order"></param>
/// <param name="description"></param>
/// <param name="clientRequestId"></param>
/// <returns></returns>
public async Task<CreateWorkOrderResponse> 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<CreateWorkOrderResponse>(
ApiRoot,
request);
}
/// <summary>
/// 发送工单消息
/// </summary>
/// <param name="options"></param>
/// <param name="workOrder"></param>
/// <param name="text"></param>
/// <param name="clientMessageId"></param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public async Task<SendMessageResponse> 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<SendMessageResponse>(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<T> PostAsync<T>(
string path,
object request)
{
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
return DeserializeResponse<T>(html);
}
private static async Task<string> 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<ApiSuccessResponse>(html);
}
catch (SerializationException)
{
throw RequestFailed(html);
}
if (response != null && !response.Success)
{
throw RequestFailed(
ExtractErrorMessage(html)
?? "工单接口请求失败。",
html);
}
}
private static T DeserializeResponse<T>(string html)
{
EnsureSuccessBody(html);
try
{
var result = Deserialize<T>(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<ApiErrorResponse>(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<T>(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<ApiErrorResponse>(responseBody);
if (error != null && int.TryParse(error.Code, out var code))
return code;
}
catch (SerializationException)
{
// GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。
}
return 0;
}
private static List<string> NormalizeAppIds(IEnumerable<string> appIds)
{
var normalized = (appIds ?? Enumerable.Empty<string>())
.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);
}
}
}
@@ -0,0 +1,135 @@
using OrderSupportTicket.Models.Api;
using System;
using System.Globalization;
using WPFCommon.Models;
namespace OrderSupportTicket.ViewModels;
/// <summary>
/// 工单留言板中的单条消息。
/// </summary>
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);
}
/// <summary>
/// 消息唯一 ID。
/// </summary>
public string MessageId
{
get => _messageId;
private set => SetValue(ref _messageId, value);
}
/// <summary>
/// 工单内连续递增的消息序号。
/// </summary>
public int SequenceNo
{
get => _sequenceNo;
private set => SetValue(ref _sequenceNo, value);
}
/// <summary>
/// 消息发送方类型。
/// </summary>
public string SenderType
{
get => _senderType;
private set
{
if (SetValue(ref _senderType, value))
RaisePropertyChanged(nameof(IsCustomer));
}
}
/// <summary>
/// 消息发送方显示名称。
/// </summary>
public string SenderDisplayName
{
get => _senderDisplayName;
private set => SetValue(ref _senderDisplayName, value);
}
/// <summary>
/// 消息展示内容;撤回消息显示固定提示文本。
/// </summary>
public string Content
{
get => _content;
private set => SetValue(ref _content, value);
}
/// <summary>
/// 消息是否已撤回。
/// </summary>
public bool IsRecalled
{
get => _isRecalled;
private set => SetValue(ref _isRecalled, value);
}
/// <summary>
/// 消息发送时间。
/// </summary>
public DateTimeOffset CreatedAt
{
get => _createdAt;
private set
{
if (SetValue(ref _createdAt, value))
RaisePropertyChanged(nameof(CreatedAtText));
}
}
/// <summary>
/// 当前消息是否由客户发送。
/// </summary>
public bool IsCustomer =>
string.Equals(SenderType, "customer", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 适合聊天界面展示的本地时间文本。
/// </summary>
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;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,289 @@
using OrderSupportTicket.Models.Api;
using System;
using System.Globalization;
using WPFCommon.Models;
namespace OrderSupportTicket.ViewModels;
/// <summary>
/// 历史工单列表中的单个工单摘要。
/// </summary>
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);
}
/// <summary>
/// 订单所属店铺 APPID。
/// </summary>
public string AppId
{
get => _appId;
private set => SetValue(ref _appId, value);
}
/// <summary>
/// 微信订单号,也是客户端使用的工单号。
/// </summary>
public string WechatOrderId
{
get => _wechatOrderId;
private set => SetValue(ref _wechatOrderId, value);
}
/// <summary>
/// 工单状态编码。
/// </summary>
public string Status
{
get => _status;
private set
{
if (SetValue(ref _status, value))
RaisePropertyChanged(nameof(IsCompleted));
}
}
/// <summary>
/// 工单状态中文文本。
/// </summary>
public string StatusText
{
get => _statusText;
private set => SetValue(ref _statusText, value);
}
/// <summary>
/// 工单最后更新时间。
/// </summary>
public DateTimeOffset UpdatedAt
{
get => _updatedAt;
private set
{
if (SetValue(ref _updatedAt, value))
RaisePropertyChanged(nameof(UpdatedAtText));
}
}
/// <summary>
/// 最近一条公开留言的时间。
/// </summary>
public DateTimeOffset LastMessageAt
{
get => _lastMessageAt;
private set => SetValue(ref _lastMessageAt, value);
}
/// <summary>
/// 最近一次客服正式回复时间。
/// </summary>
public DateTimeOffset? LastReplyAt
{
get => _lastReplyAt;
private set => SetValue(ref _lastReplyAt, value);
}
/// <summary>
/// 最近一条留言的发送方类型。
/// </summary>
public string LastSenderType
{
get => _lastSenderType;
private set
{
if (SetValue(ref _lastSenderType, value))
RaisePropertyChanged(nameof(PreviewText));
}
}
/// <summary>
/// 当前工单的未读留言数量。
/// </summary>
public int UnreadCount
{
get => _unreadCount;
private set => SetValue(ref _unreadCount, value);
}
/// <summary>
/// 本次服务器刷新是否检测到比本地缓存更新的公开消息。
/// </summary>
public bool HasNewMessage
{
get => _hasNewMessage;
private set
{
if (SetValue(ref _hasNewMessage, value))
RaisePropertyChanged(nameof(PreviewText));
}
}
/// <summary>
/// 工单版本号字符串。
/// </summary>
public string Version
{
get => _version;
private set => SetValue(ref _version, value);
}
/// <summary>
/// 用于合并工单的 APPID 与订单号组合键。
/// </summary>
public string Key => AppId + "|" + WechatOrderId;
/// <summary>
/// 工单是否已完结。
/// </summary>
public bool IsCompleted =>
string.Equals(Status, "completed", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 适合列表展示的本地时间文本。
/// </summary>
public string UpdatedAtText =>
UpdatedAt == default
? string.Empty
: UpdatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture);
/// <summary>
/// 不读取留言正文时使用的最近动态摘要。
/// </summary>
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;
}
/// <summary>
/// 标记检测到新消息,并确保列表显示未读角标。
/// </summary>
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;
}
}
@@ -0,0 +1,912 @@
<UserControl
x:Class="OrderSupportTicket.Views.OrderSupportTicketView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:OrderSupportTicket.Behaviors"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:OrderSupportTicket.Models"
xmlns:vm="clr-namespace:OrderSupportTicket.ViewModels"
MinWidth="760"
MinHeight="480"
d:DataContext="{d:DesignInstance Type=vm:OrderSupportTicketViewModel}"
Background="#EEF1F4"
FontFamily="Microsoft YaHei UI"
FontSize="14"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
<SolidColorBrush x:Key="BrandBrush" Color="#98638F" />
<SolidColorBrush x:Key="BrandSoftBrush" Color="#F3EAF2" />
<SolidColorBrush x:Key="LineBrush" Color="#DDE2E8" />
<SolidColorBrush x:Key="TextBrush" Color="#1F2933" />
<SolidColorBrush x:Key="SubTextBrush" Color="#6B7785" />
<SolidColorBrush x:Key="BackgroundBrush" Color="#EEF1F4" />
<SolidColorBrush x:Key="PanelBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="OtherBubbleBrush" Color="#F1F3F6" />
<SolidColorBrush x:Key="ErrorBrush" Color="#B3261E" />
<SolidColorBrush x:Key="WaitingBrush" Color="#B45309" />
<SolidColorBrush x:Key="WaitingBackgroundBrush" Color="#FDF0DA" />
<SolidColorBrush x:Key="RepliedBrush" Color="#1D5FA8" />
<SolidColorBrush x:Key="RepliedBackgroundBrush" Color="#E2EDF9" />
<SolidColorBrush x:Key="DoneBrush" Color="#5B6673" />
<SolidColorBrush x:Key="DoneBackgroundBrush" Color="#E9ECF0" />
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="{StaticResource BrandBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BrandBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Opacity" Value="0.88" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Opacity" Value="0.72" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GhostButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource BrandBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BrandBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,5" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource BrandSoftBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.42" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="StatusTagStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource DoneBackgroundBrush}" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="8,2" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="waiting_reply">
<Setter Property="Background" Value="{StaticResource WaitingBackgroundBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="replied">
<Setter Property="Background" Value="{StaticResource RepliedBackgroundBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="completed">
<Setter Property="Background" Value="{StaticResource DoneBackgroundBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource DoneBrush}" />
<Setter Property="FontSize" Value="12" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="waiting_reply">
<Setter Property="Foreground" Value="{StaticResource WaitingBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="replied">
<Setter Property="Foreground" Value="{StaticResource RepliedBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="TicketListItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border
x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="3,0,0,1">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource BackgroundBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource BrandSoftBrush}" />
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource BrandBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ChatListItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid Margin="16">
<Border
Background="{StaticResource PanelBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Grid.Column="0"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,1,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid
Grid.Row="0"
Height="52"
Margin="14,0">
<StackPanel VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock
FontSize="15"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="历史工单" />
<Border
Margin="8,0,0,0"
Padding="7,2"
VerticalAlignment="Center"
Background="#B3261E"
CornerRadius="9"
Visibility="{Binding HasNewData, Converter={StaticResource BoolToVisibility}}">
<TextBlock
FontSize="11"
Foreground="White"
Text="{Binding NewDataText}" />
</Border>
</StackPanel>
<Button
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding NewTicketCommand}"
Content="新建"
Style="{StaticResource GhostButtonStyle}" />
</Grid>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Padding="11,10,14,10"
Background="{StaticResource BrandSoftBrush}"
BorderBrush="{StaticResource BrandBrush}"
BorderThickness="3,1,0,1"
Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="新工单(未提交)" />
<TextBlock
Margin="0,5,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding SelectedOrder.OrderId, StringFormat=订单 {0}}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Margin="0,4,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="请在右侧描述问题" />
</StackPanel>
</Border>
<ListBox
Grid.Row="1"
behaviors:ListBoxInteractionBehavior.SelectedItemCommand="{Binding SelectTicketCommand}"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource TicketListItemStyle}"
ItemsSource="{Binding Tickets}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
SelectedItem="{Binding SelectedTicket, Mode=OneWay}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:WorkOrderSummaryViewModel}">
<Grid Margin="11,10,14,10" ToolTip="{Binding AppId}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,5">
<StackPanel Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding WechatOrderId}"
TextTrimming="CharacterEllipsis" />
<Border
x:Name="UnreadBadge"
Height="18"
MinWidth="18"
Margin="6,-1,0,0"
Padding="5,0"
Background="{StaticResource BrandBrush}"
CornerRadius="9">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
Foreground="White"
Text="{Binding UnreadCount}" />
</Border>
</StackPanel>
<Border HorizontalAlignment="Right" Style="{StaticResource StatusTagStyle}">
<TextBlock Style="{StaticResource StatusTextStyle}" Text="{Binding StatusText}" />
</Border>
</Grid>
<TextBlock
Grid.Row="1"
Margin="0,0,0,4"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding AppId, StringFormat=店铺 {0}}"
TextTrimming="CharacterEllipsis" />
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding PreviewText}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding UpdatedAtText}" />
</Grid>
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding UnreadCount}" Value="0">
<Setter TargetName="UnreadBadge" Property="Visibility" Value="Collapsed" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Button
Grid.Row="2"
HorizontalContentAlignment="Stretch"
Background="Transparent"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,1,0,0"
Command="{Binding RetryRealtimeCommand}"
Cursor="Hand"
ToolTip="点击重新连接">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter Margin="14,9" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" />
</Border>
</ControlTemplate>
</Button.Template>
<StackPanel Orientation="Horizontal">
<Ellipse
Width="8"
Height="8"
Margin="0,0,8,0"
VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource SubTextBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding SseState}" Value="Connected">
<Setter Property="Fill" Value="#2F7D4F" />
</DataTrigger>
<DataTrigger Binding="{Binding SseState}" Value="Connecting">
<Setter Property="Fill" Value="{StaticResource WaitingBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding SseState}" Value="Reconnecting">
<Setter Property="Fill" Value="{StaticResource WaitingBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock
VerticalAlignment="Center"
d:Text="推送"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding SseStatusText}" />
</StackPanel>
</Button>
</Grid>
</Border>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Margin="14,12,14,0"
Padding="10,7"
BorderThickness="1"
CornerRadius="5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Visible" />
<Setter Property="Background" Value="#FFF7E8" />
<Setter Property="BorderBrush" Value="#E8C98C" />
<Style.Triggers>
<DataTrigger Binding="{Binding NoticeText}" Value="">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
<DataTrigger Binding="{Binding NoticeText}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
<DataTrigger Binding="{Binding IsNoticeError}" Value="True">
<Setter Property="Background" Value="#FDECEA" />
<Setter Property="BorderBrush" Value="#E8AAA4" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
FontSize="12"
Foreground="{StaticResource TextBrush}"
Text="{Binding NoticeText}"
TextWrapping="Wrap" />
</Border>
<Grid Grid.Row="1">
<Grid Visibility="{Binding IsChatVisible, Converter={StaticResource BoolToVisibility}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Padding="18,11"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal">
<TextBlock
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="{Binding SelectedTicket.WechatOrderId}" />
<Border
Margin="9,0,0,0"
VerticalAlignment="Center"
DataContext="{Binding SelectedTicket}"
Style="{StaticResource StatusTagStyle}">
<TextBlock Style="{StaticResource StatusTextStyle}" Text="{Binding StatusText}" />
</Border>
</StackPanel>
<TextBlock
Margin="0,2,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding SelectedTicket.AppId, StringFormat=店铺 {0}}" />
</StackPanel>
<Button
Grid.Column="1"
Command="{Binding LoadEarlierMessagesCommand}"
Content="加载更早留言">
<Button.Style>
<Style BasedOn="{StaticResource GhostButtonStyle}" TargetType="Button">
<Style.Triggers>
<DataTrigger Binding="{Binding HasMoreMessages}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</Grid>
</Border>
<Grid Grid.Row="1">
<ListBox
x:Name="MessagesListBox"
Margin="18,10"
behaviors:ListBoxInteractionBehavior.ScrollVersion="{Binding MessageScrollVersion}"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource ChatListItemStyle}"
ItemsSource="{Binding Messages}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ChatMessageViewModel}">
<Grid Margin="0,5">
<StackPanel
x:Name="BubbleColumn"
MaxWidth="560"
HorizontalAlignment="Left">
<TextBlock
x:Name="SenderText"
Margin="1,0,0,3"
FontSize="12"
Foreground="{StaticResource SubTextBrush}">
<Run Text="{Binding SenderDisplayName, Mode=OneWay}" />
<Run Text=" " />
<Run Text="{Binding CreatedAtText, Mode=OneWay}" />
</TextBlock>
<Border
x:Name="Bubble"
Padding="13,9"
Background="{StaticResource OtherBubbleBrush}"
CornerRadius="8,8,8,2">
<TextBlock
x:Name="BubbleText"
FontSize="14"
Foreground="{StaticResource TextBrush}"
LineHeight="22"
Text="{Binding Content}"
TextWrapping="Wrap" />
</Border>
</StackPanel>
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsCustomer}" Value="True">
<Setter TargetName="BubbleColumn" Property="HorizontalAlignment" Value="Right" />
<Setter TargetName="Bubble" Property="Background" Value="{StaticResource BrandBrush}" />
<Setter TargetName="Bubble" Property="CornerRadius" Value="8,8,2,8" />
<Setter TargetName="BubbleText" Property="Foreground" Value="White" />
<Setter TargetName="SenderText" Property="HorizontalAlignment" Value="Right" />
</DataTrigger>
<DataTrigger Binding="{Binding IsRecalled}" Value="True">
<Setter TargetName="Bubble" Property="Opacity" Value="0.62" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock
Margin="18"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="13"
Foreground="{StaticResource SubTextBrush}"
Text="暂无公开留言">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Messages.Count}" Value="0">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<Border
Grid.Row="2"
Padding="18,12"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,1,0,0">
<Border
Padding="12,10"
Background="{StaticResource PanelBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox
Grid.Row="0"
MinHeight="64"
MaxHeight="110"
AcceptsReturn="True"
Background="Transparent"
BorderThickness="0"
Foreground="{StaticResource TextBrush}"
MaxLength="4000"
Text="{Binding ChatText, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap"
ToolTip="{Binding ComposerPlaceholder}"
VerticalScrollBarVisibility="Auto">
<TextBox.InputBindings>
<KeyBinding
Key="Enter"
Command="{Binding SendMessageCommand}"
Modifiers="Control" />
</TextBox.InputBindings>
</TextBox>
<Grid Grid.Row="1" Margin="0,8,0,0">
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="Ctrl+Enter 发送" />
<TextBlock
Margin="0,0,82,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding ChatCharacterCountText}" />
<Button
Width="72"
Height="32"
HorizontalAlignment="Right"
Command="{Binding SendMessageCommand}"
Content="发送"
Style="{StaticResource PrimaryButtonStyle}" />
</Grid>
</Grid>
</Border>
</Border>
</Grid>
<Grid Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Padding="18,11"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="新建工单" />
<TextBlock
Margin="0,2,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="提交后进入等待客服回复;已完结订单再次提交会重开原工单。" />
</StackPanel>
</Border>
<Grid Grid.Row="1">
<Border
Margin="18"
Padding="14,12"
VerticalAlignment="Top"
Background="#FAFBFC"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="6">
<StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
VerticalAlignment="Center"
Foreground="{StaticResource SubTextBrush}"
Text="关联订单:" />
<Grid Grid.Row="0" Grid.Column="1">
<TextBox
Padding="8,5"
Background="White"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
Foreground="{StaticResource TextBrush}"
Text="{Binding OrderFilterText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource SubTextBrush}"
IsHitTestVisible="False"
Text="输入订单号或商品名筛选">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding OrderFilterText}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<Button
Grid.Row="0"
Grid.Column="2"
Margin="8,0,0,0"
Padding="10,4"
Command="{Binding ClearOrderFilterCommand}"
Content="清除"
Style="{StaticResource GhostButtonStyle}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
ItemsSource="{Binding FilteredOrders}"
SelectedItem="{Binding SelectedOrder}"
ToolTip="{Binding SelectedOrder.DisplayText}">
<ComboBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:TicketOrderOption}">
<Grid MaxWidth="480">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Margin="0,0,8,0"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="{Binding OrderId}" />
<TextBlock
Grid.Column="1"
MaxWidth="280"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding GoodsName}"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding GoodsName}" />
</Grid>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0,7,0,0"
FontSize="12"
Foreground="{StaticResource ErrorBrush}"
Text="未找到匹配订单,请检查订单号,或换一个商品名关键词。">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding HasAvailableOrders}" Value="True" />
<Condition Binding="{Binding HasFilteredOrders}" Value="False" />
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible" />
</MultiDataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0,7,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding OrderFilterSummaryText}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFilteredOrders}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<TextBlock
Margin="0,10,0,0"
Foreground="{StaticResource TextBrush}"
LineHeight="23"
Text="{Binding SelectedOrderDescription}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
<Border
Grid.Row="2"
Padding="18,12"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,1,0,0">
<Border
Padding="12,10"
Background="{StaticResource PanelBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox
Grid.Row="0"
MinHeight="64"
MaxHeight="110"
AcceptsReturn="True"
Background="Transparent"
BorderThickness="0"
Foreground="{StaticResource TextBrush}"
MaxLength="2000"
Text="{Binding DraftText, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap"
ToolTip="请描述您遇到的问题"
VerticalScrollBarVisibility="Auto">
<TextBox.InputBindings>
<KeyBinding
Key="Enter"
Command="{Binding CreateTicketCommand}"
Modifiers="Control" />
</TextBox.InputBindings>
</TextBox>
<Grid Grid.Row="1" Margin="0,8,0,0">
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="Ctrl+Enter 提交" />
<TextBlock
Margin="0,0,96,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding DraftCharacterCountText}" />
<Button
Width="86"
Height="32"
HorizontalAlignment="Right"
Command="{Binding CreateTicketCommand}"
Content="提交工单"
Style="{StaticResource PrimaryButtonStyle}" />
</Grid>
</Grid>
</Border>
</Border>
</Grid>
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding IsEmptyVisible, Converter={StaticResource BoolToVisibility}}">
<TextBlock
HorizontalAlignment="Center"
FontSize="14"
Foreground="{StaticResource SubTextBrush}"
Text="在左侧选择工单,或点击“新建”提交新工单" />
<TextBlock
Margin="0,6,0,0"
HorizontalAlignment="Center"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="实时通知已订阅当前拍单账号下的全部店铺" />
</StackPanel>
</Grid>
<ProgressBar
Grid.RowSpan="2"
Height="3"
VerticalAlignment="Top"
Foreground="{StaticResource BrandBrush}"
IsIndeterminate="True"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Grid>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,94 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Services;
using OrderSupportTicket.ViewModels;
using System.Windows.Controls;
namespace OrderSupportTicket.Views;
/// <summary>
/// 工单对话页面。页面生命周期只负责激活/停用 ViewModel,业务交互均在 ViewModel 中。
/// </summary>
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;
}
/// <summary>
/// 当前页面对应的工单 ViewModel。
/// </summary>
public OrderSupportTicketViewModel ViewModel { get; }
/// <summary>
/// 宿主可在页面加载前注入账号、APPID 与本地订单数据。
/// </summary>
public void Configure(TicketClientOptions options)
{
ViewModel.Configure(options);
}
/// <summary>
/// 从订单页直接打开新工单草稿。
/// </summary>
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();
}
}
+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>工单对话 Demo</title>
<style>
:root{
--bg:#eef1f4; --panel:#ffffff; --line:#dde2e8; --text:#1f2933; --sub:#6b7785;
--brand:#98638f; --brand-soft:#f3eaf2; --me:#98638f; --me-text:#fff; --other:#f1f3f6;
--ok:#2f7d4f; --err:#b3261e;
--waiting:#b45309; --waiting-bg:#fdf0da;
--replied:#1d5fa8; --replied-bg:#e2edf9;
--done:#5b6673; --done-bg:#e9ecf0;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#15191d; --panel:#1d2227; --line:#2f363d; --text:#e6eaee; --sub:#93a0ad;
--brand:#c792bd; --brand-soft:#3a2838; --me:#98638f; --me-text:#fff; --other:#272d33;
--ok:#6cc08b; --err:#f08a82;
--waiting:#f0b35a; --waiting-bg:#3a2d17;
--replied:#7db4ee; --replied-bg:#1b2d44;
--done:#a3adb8; --done-bg:#2b3138;
}
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:"Microsoft YaHei UI","PingFang SC","Segoe UI",sans-serif;font-size:14px;color:var(--text);background:var(--bg);display:flex;flex-direction:column;
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
button{font:inherit;cursor:pointer}
button:focus-visible,textarea:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
main{flex:1;min-height:0;padding:16px}
.page{display:none;height:100%}
.page.on{display:block}
.card{background:var(--panel);border:1px solid var(--line);border-radius:6px}
.btn{border:1px solid var(--brand);background:var(--brand);color:#fff;border-radius:4px;padding:6px 14px}
.btn.ghost{background:none;color:var(--brand)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;white-space:nowrap}
.s-waiting_reply{color:var(--waiting);background:var(--waiting-bg)}
.s-replied{color:var(--replied);background:var(--replied-bg)}
.s-completed{color:var(--done);background:var(--done-bg)}
/* 工单页 */
.tk{display:grid;grid-template-columns:300px 1fr;height:100%;overflow:hidden}
.left{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.left-h{padding:12px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);font-weight:600}
.list{overflow:auto;flex:1}
.item{padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer;border-left:3px solid transparent}
.item:hover{background:var(--bg)}
.item.on{background:var(--brand-soft);border-left-color:var(--brand)}
.item .r1,.item .r3{display:flex;justify-content:space-between;gap:8px;align-items:center}
.item .r1{margin-bottom:6px}
.item .r2{color:var(--sub);font-size:12px;margin-bottom:4px}
.item .r3{color:var(--sub);font-size:12px}
.item .pv{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.right{display:flex;flex-direction:column;min-height:0;min-width:0}
.right-h{padding:12px 18px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}
.right-h .t{font-weight:600}
.right-h .d{color:var(--sub);font-size:12px;margin-top:2px}
.msgs{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px;min-height:0}
.row{display:flex;flex-direction:column;max-width:70%}
.row.me{align-self:flex-end;align-items:flex-end}
.row.cs{align-self:flex-start}
.who{font-size:12px;color:var(--sub);margin-bottom:3px}
.bub{padding:9px 13px;border-radius:8px;line-height:1.55;white-space:pre-wrap;word-break:break-word}
.me .bub{background:var(--me);color:var(--me-text);border-top-right-radius:2px}
.cs .bub{background:var(--other);border-top-left-radius:2px}
.sys{align-self:center;font-size:12px;color:var(--sub);background:var(--done-bg);padding:3px 12px;border-radius:10px}
.input{border-top:1px solid var(--line);padding:12px 18px}
.composer{display:flex;flex-direction:column;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;background:var(--panel);transition:border-color .15s}
.composer:focus-within{border-color:var(--brand)}
.composer textarea{width:100%;height:64px;resize:none;border:0;outline:0;padding:0;font:inherit;line-height:1.5;background:transparent;color:var(--text)}
.composer .foot{display:flex;align-items:center;gap:12px;font-size:12px;color:var(--sub)}
.composer .cnt{margin-left:auto}
.composer .btn{height:32px;padding:0 20px;border-radius:6px}
.empty{margin:auto;color:var(--sub);text-align:center}
.order-box{margin:18px;padding:12px 14px;border:1px dashed var(--line);border-radius:6px;color:var(--sub);line-height:1.7}
.order-box b{color:var(--text)}
.order-box select{font:inherit;margin-left:4px;padding:3px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--text)}
/* 未读角标 + SSE 状态 */
.badge{background:var(--brand);color:#fff;border-radius:9px;min-width:18px;height:18px;padding:0 5px;font-size:11px;display:inline-flex;align-items:center;justify-content:center;margin-left:6px;vertical-align:1px}
.item.unread .pv{color:var(--text);font-weight:600}
.sse{border-top:1px solid var(--line);padding:9px 14px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--sub);cursor:pointer;user-select:none}
.dot{width:8px;height:8px;border-radius:50%;background:var(--sub);flex:none}
.dot.connected{background:var(--ok)}
.dot.connecting,.dot.reconnecting{background:var(--waiting);animation:blink 1s infinite}
.dot.disconnected{background:var(--err)}
@keyframes blink{50%{opacity:.3}}
@media (prefers-reduced-motion:reduce){.dot{animation:none!important}}
@media (max-width:760px){.tk{grid-template-columns:1fr;grid-template-rows:200px 1fr}.left{border-right:0;border-bottom:1px solid var(--line)}}
</style>
</head>
<body>
<main>
<section class="page on card" id="page-tickets"><div class="tk">
<aside class="left">
<div class="left-h"><span>历史工单</span><button class="btn ghost" onclick="newDraft()">新建</button></div>
<div class="list" id="tkList"></div>
<div class="sse" id="sse" onclick="onSseClick()"></div>
</aside>
<div class="right" id="tkRight"></div>
</div></section>
</main>
<script>
/* ========== 状态定义:对应 WPF 里的枚举 / 转换器 ========== */
const STATUS = {
waiting_reply:{text:'待客服回复'},
replied:{text:'客服已回复'},
completed:{text:'已完结'}
};
/* ========== 数据(WPF 里对应 ViewModel / 服务层) ========== */
const orders = [
{id:'SO20261012001',name:'无线降噪耳机 Pro',amount:'¥ 899.00',time:'2026-10-12 14:20'},
{id:'SO20261009017',name:'机械键盘 87键',amount:'¥ 459.00',time:'2026-10-09 09:41'},
{id:'SO20261003088',name:'27寸 4K 显示器',amount:'¥ 2,199.00',time:'2026-10-03 20:05'},
{id:'SO20260928052',name:'人体工学椅',amount:'¥ 1,580.00',time:'2026-09-28 11:12'}
];
let seq = 3;
const tickets = [
{id:'T0002',orderId:'SO20261009017',status:'replied',updated:'10-16 10:32',messages:[
{role:'me',text:'键盘空格键卡键,想问下能否换新?',time:'10-16 09:50'},
{role:'cs',text:'您好,已为您登记换货,请提供一下故障视频。',time:'10-16 10:32'}]},
{id:'T0003',orderId:'SO20261003088',status:'waiting_reply',updated:'10-17 09:12',messages:[
{role:'me',text:'显示器左下角有一块亮斑,请问怎么处理?',time:'10-17 09:12'}]},
{id:'T0001',orderId:'SO20260928052',status:'completed',updated:'10-05 16:08',messages:[
{role:'me',text:'椅子扶手有松动,怎么处理?',time:'10-05 15:20'},
{role:'cs',text:'已安排上门维修,师傅会在 2 天内联系您。',time:'10-05 15:48'},
{role:'sys',text:'客服已完结该工单',time:'10-05 16:08'}]}
].sort((a,b)=>b.id.localeCompare(a.id));
let selectedId=null, draftOrderId=null;
/* ========== 工具 ========== */
const $=id=>document.getElementById(id);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const now=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`};
const order=id=>orders.find(o=>o.id===id);
const tag=s=>`<span class="tag s-${s}">${STATUS[s].text}</span>`;
/* ========== 工单页 ========== */
function renderTickets(){renderList();renderRight();}
function renderList(){
const draft = draftOrderId && !selectedId
? `<div class="item on"><div class="r1"><b>新工单(未提交)</b></div><div class="r2">订单 ${draftOrderId}</div><div class="r3">请在右侧描述问题</div></div>` : '';
$('tkList').innerHTML = draft + tickets.map(t=>{
const last=t.messages[t.messages.length-1];
return `<div class="item ${t.id===selectedId?'on':''} ${t.unread?'unread':''}" onclick="selectTicket('${t.id}')">
<div class="r1"><span><b>${t.id}</b>${t.unread?`<span class="badge">${t.unread}</span>`:''}</span>${tag(t.status)}</div>
<div class="r2">订单 ${t.orderId}</div>
<div class="r3"><span class="pv">${esc(last.text)}</span><span>${t.updated}</span></div></div>`;
}).join('');
if(!tickets.length&&!draft)$('tkList').innerHTML='<div class="empty" style="padding:24px">暂无工单</div>';}
function renderRight(){
const t=tickets.find(x=>x.id===selectedId);
if(t) renderChat(t);
else if(draftOrderId) renderDraft();
else $('tkRight').innerHTML='<div class="empty">在左侧选择工单,或点击“新建”提交新工单</div>';
}
/* 输入框:字数计数 + Ctrl+Enter 发送 */
function bindInput(ta,onSubmit){
const cnt=ta.parentNode.querySelector('.cnt');
const upd=()=>cnt.textContent=ta.value.length+' / 500';
ta.addEventListener('input',upd);
ta.addEventListener('keydown',e=>{if(e.key==='Enter'&&e.ctrlKey)onSubmit();});
upd();
}
function selectTicket(id){selectedId=id; draftOrderId=null; const t=tickets.find(x=>x.id===id); if(t)t.unread=0; renderTickets();}
/* 外部入口:订单页点“提交工单”时调用 openNewTicket(订单号),即可选中该订单 */
function openNewTicket(orderId){draftOrderId=orderId||orders[0].id; selectedId=null; renderTickets(); $('draftText').focus();}
function newDraft(){openNewTicket(draftOrderId);}
function pickOrder(id){ // 切换订单时保留已输入的问题
const v=$('draftText').value; draftOrderId=id; renderTickets();
$('draftText').value=v; $('draftText').dispatchEvent(new Event('input')); $('draftText').focus();
}
function renderDraft(){
const o=order(draftOrderId);
$('tkRight').innerHTML=`
<div class="right-h"><div><div class="t">新建工单</div><div class="d">提交后将生成工单并等待客服回复</div></div></div>
<div class="msgs">
<div class="order-box">关联订单:<select id="orderSel" onchange="pickOrder(this.value)">${orders.map(x=>`<option value="${x.id}" ${x.id===o.id?'selected':''}>${x.id}</option>`).join('')}</select><br>商品:${esc(o.name)}<br>金额:${o.amount} 下单时间:${o.time}</div>
</div>
<div class="input">
<div class="composer"><textarea id="draftText" maxlength="500" placeholder="请描述您遇到的问题"></textarea>
<div class="foot"><span class="hint">Ctrl+Enter 发送</span><span class="cnt">0 / 500</span><button class="btn" onclick="createTicket()">提交工单</button></div></div>
</div>`;
bindInput($('draftText'),createTicket);
}
function createTicket(){
const v=$('draftText').value.trim();
if(!v){$('draftText').focus();return;}
const t={id:'T'+String(++seq).padStart(4,'0'),orderId:draftOrderId,status:'waiting_reply',updated:now(),
messages:[{role:'me',text:v,time:now()}]};
tickets.unshift(t); // 新工单放在列表最上面
selectedId=t.id; draftOrderId=null; mockServerRespond(t.id);
renderTickets();
}
function renderChat(t){
const prev=$('chatText'), keep=(prev&&prev.dataset.tid===t.id)?prev.value:''; // 推送刷新时保留输入
const o=order(t.orderId);
$('tkRight').innerHTML=`
<div class="right-h"><div><div class="t">${t.id} ${tag(t.status)}</div>
<div class="d">订单 ${o.id} · ${esc(o.name)}</div></div></div>
<div class="msgs" id="msgs">${t.messages.map(m=>
m.role==='sys'?`<div class="sys">${esc(m.text)} · ${m.time}</div>`:
`<div class="row ${m.role}"><div class="who">${m.role==='me'?'我':'客服'} ${m.time}</div><div class="bub">${esc(m.text)}</div></div>`).join('')}</div>
<div class="input">
<div class="composer"><textarea id="chatText" maxlength="500" data-tid="${t.id}" placeholder="${t.status==='completed'?'工单已完结,继续留言将重新开启':'输入留言'}"></textarea>
<div class="foot"><span class="hint">Ctrl+Enter 发送</span><span class="cnt">0 / 500</span><button class="btn" onclick="sendMsg()">发送</button></div></div>
</div>`;
$('chatText').value=keep;
bindInput($('chatText'),sendMsg);
const m=$('msgs'); m.scrollTop=m.scrollHeight;
}
/* ========== 状态流转(对应状态表) ========== */
function sendMsg(){ // 客户留言(含完结后追问)→ waiting_reply
const t=tickets.find(x=>x.id===selectedId), v=$('chatText').value.trim();
if(!v)return;
t.messages.push({role:'me',text:v,time:now()});
$('chatText').value=''; t.status='waiting_reply'; touch(t); mockServerRespond(t.id);
}
function touch(t){
t.updated=now();
tickets.splice(tickets.indexOf(t),1); tickets.unshift(t); // 有新动态的工单移到最上
renderTickets();
}
/* ========== SSE 推送 ========== */
const SSE_URL='/api/tickets/stream'; // 真实 SSE 地址
const USE_MOCK=true; // 接真实服务器时改为 false
const SSE_TEXT={connecting:'连接中…',connected:'已连接',reconnecting:'连接断开,重连中…',disconnected:'已断开,点击重试'};
let sse=null, sseState='connecting', retries=0, retryTimer=null;
function setSse(state){
sseState=state;
const el=$('sse');
el.title=state==='disconnected'?'点击重试':(USE_MOCK&&state==='connected'?'Demo:点击模拟断线':'');
el.innerHTML=`<span class="dot ${state}"></span><span>实时推送:${SSE_TEXT[state]}</span>`;
}
function connectSSE(){
clearTimeout(retryTimer);
if(sse){sse.onerror=null;sse.close();}
setSse(retries?'reconnecting':'connecting');
sse=USE_MOCK?new MockEventSource(SSE_URL):new EventSource(SSE_URL);
sse.onopen=()=>{retries=0;setSse('connected');};
sse.onmessage=e=>{try{handlePush(JSON.parse(e.data));}catch(err){console.error(err);}};
sse.onerror=()=>{
sse.close();
if(++retries>5){setSse('disconnected');return;} // 连续失败 5 次后停止自动重连
setSse('reconnecting');
retryTimer=setTimeout(connectSSE,Math.min(1000*retries,5000));
};
}
function onSseClick(){
if(sseState==='disconnected'){retries=0;connectSSE();}
else if(USE_MOCK&&sseState==='connected')sse.drop(); // 仅 Demo:模拟断线
}
/* 推送数据格式:{ticketId, status?, message?:{role:'cs'|'sys', text, time}} */
function handlePush(d){
const t=tickets.find(x=>x.id===d.ticketId); if(!t)return;
if(d.message)t.messages.push(d.message);
if(d.status)t.status=d.status;
t.updated=d.message?d.message.time:now();
const viewing=t.id===selectedId;
if(!viewing&&d.message)t.unread=(t.unread||0)+1; // 非当前查看的工单 → 未读 +1
tickets.splice(tickets.indexOf(t),1); tickets.unshift(t);
renderList();
if(viewing)renderChat(t); // 新建界面不刷新右侧,避免丢输入
}
/* ----- 以下为 Demo 用的模拟服务器,接真实 SSE 后整段删除 ----- */
class MockEventSource{
static pending=[]; static current=null; static offline=false;
constructor(url){
this.url=url; this.closed=false; this.connected=false; MockEventSource.current=this;
setTimeout(()=>{
if(this.closed)return;
if(MockEventSource.offline){this.onerror&&this.onerror();return;}
this.connected=true; this.onopen&&this.onopen(); MockEventSource.flush();
},800);
}
close(){this.closed=true; this.connected=false;}
drop(){ // 断线 4 秒
MockEventSource.offline=true; setTimeout(()=>MockEventSource.offline=false,4000);
this.onerror&&this.onerror();
}
static push(data){MockEventSource.pending.push(data); MockEventSource.flush();} // 断线期间的消息,重连后补发
static flush(){
const c=MockEventSource.current; if(!c||!c.connected)return;
while(MockEventSource.pending.length)c.onmessage&&c.onmessage({data:JSON.stringify(MockEventSource.pending.shift())});
}
}
function mockServerRespond(id){
if(!USE_MOCK)return;
setTimeout(()=>MockEventSource.push({ticketId:id,status:'replied',
message:{role:'cs',text:'您好,已收到您的反馈,我们会尽快处理。',time:now()}}),2500);
}
selectedId=tickets[0].id;
renderTickets();
connectSSE();
if(USE_MOCK)setTimeout(()=>MockEventSource.push({ticketId:'T0002',status:'replied',
message:{role:'cs',text:'已收到您的故障视频,正在为您安排换货。',time:now()}}),6000);
</script>
</body>
</html>