增加工单模块
This commit is contained in:
1 parent
dc136e449e
commit
f9dcae4610
17 files changed
+4908
No files matched your search
@@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".test", ".test", "{14261B9B
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WXOrder.Test", "test\WXOrder.Test\WXOrder.Test.csproj", "{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WXOrder.Test", "test\WXOrder.Test\WXOrder.Test.csproj", "{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderSupportTicket", "OrderSupportTicket\OrderSupportTicket.csproj", "{22D31944-2087-437A-B374-C0874E9EC53A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
Reference in new issue
Block a user