修改工单流程页面,订单列表增加工单按钮

This commit is contained in:
chfei committed 2026-10-18 20:35:47 +08:00
1 parent f9dcae4610
commit 10bb0803d9
44 files changed
+5920 -1291

No files matched your search

+2 -2
View File
@@ -143,8 +143,8 @@ namespace _29WxOrderWpf
}
#endregion
//new MainWindow().Show();
new OrderSupportTicketWindow().Show();
new MainWindow().Show();
//new OrderSupportTicketWindow().Show();
}
//static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
@@ -30,7 +30,7 @@ public partial class OrderListCtrl
{
if (_statisticsDialog == null)
{
_statisticsDialog =new ShopRevenueStatisticsView();
_statisticsDialog = new ShopRevenueStatisticsView();
_statisticsDialog.Closed += (s, e) => _statisticsDialog = null;
}
_statisticsDialog.Show();
@@ -310,7 +310,7 @@ public partial class OrderListCtrl
private void ShowPDDInvoiceDialog()
{
var view = new PDDInvoiceView();
DialogService.Show(view);
DialogService.ShowOrActivateWindow(view);
}
#endregion
}
@@ -1,86 +1,86 @@
<UserControl
x:Class="_29WxOrderWpf.Ctrls.OrderCtrls.OrderListRowHeaderCtrl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:uc="clr-namespace:_29WxOrderWpf.UserControls"
d:DesignHeight="50"
d:DesignWidth="800"
mc:Ignorable="d">
x:Class="_29WxOrderWpf.Ctrls.OrderCtrls.OrderListRowHeaderCtrl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:uc="clr-namespace:_29WxOrderWpf.UserControls"
d:DesignHeight="50"
d:DesignWidth="800"
mc:Ignorable="d">
<Border Background="{StaticResource ListRowBorderColorBrush}" CornerRadius="12,12,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="20" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Margin="8,8,-4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
FontSize="20"
FontWeight="ExtraBold"
Foreground="{Binding OrderBuyTypeColor}"
IsReadOnly="True"
Text="{Binding OrderBuyType}"
Visibility="{Binding OrderBuyType, Converter={StaticResource HasTextVisibility}}" />
<TextBox
Grid.Column="1"
Margin="8,8,-4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
FontSize="14"
IsReadOnly="True"
Text="店铺单号:" />
<uc:UcWaterMarkTextBox1
x:Name="txtOrderId"
Grid.Column="2"
Margin="0,8,4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
FontSize="14"
Foreground="{StaticResource PageLinkBrush}"
IsReadOnly="True"
Text="{Binding order_id}"
WaterMark="订单号" />
<Border
Grid.Column="3"
Width="12"
Height="12"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="Path_MouseLeftButtonDown"
Tag="{Binding order_id}">
<Path
Margin="0,0,0,0"
Data="M80 432a128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128h-384a128 128 0 0 1-128-128v-384z m128-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64h-384z M816 144h-384a64 64 0 0 0-64 64 32 32 0 0 1-64 0 128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128 32 32 0 0 1 0-64 64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64z"
Fill="{StaticResource PageLinkBrush}"
MouseLeftButtonDown="Path_MouseLeftButtonDown"
Stretch="Uniform"
Tag="{Binding order_id}" />
</Border>
<Border Background="{StaticResource ListRowBorderColorBrush}" CornerRadius="12,12,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="20" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Margin="8,8,-4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
FontSize="20"
FontWeight="ExtraBold"
Foreground="{Binding OrderBuyTypeColor}"
IsReadOnly="True"
Text="{Binding OrderBuyType}"
Visibility="{Binding OrderBuyType, Converter={StaticResource HasTextVisibility}}" />
<TextBox
Grid.Column="1"
Margin="8,8,-4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
FontSize="14"
IsReadOnly="True"
Text="店铺单号:" />
<uc:UcWaterMarkTextBox1
x:Name="txtOrderId"
Grid.Column="2"
Margin="0,8,4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
FontSize="14"
Foreground="{StaticResource PageLinkBrush}"
IsReadOnly="True"
Text="{Binding order_id}"
WaterMark="订单号" />
<Border
Grid.Column="3"
Width="12"
Height="12"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="Path_MouseLeftButtonDown"
Tag="{Binding order_id}">
<Path
Margin="0,0,0,0"
Data="M80 432a128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128h-384a128 128 0 0 1-128-128v-384z m128-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64h-384z M816 144h-384a64 64 0 0 0-64 64 32 32 0 0 1-64 0 128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128 32 32 0 0 1 0-64 64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64z"
Fill="{StaticResource PageLinkBrush}"
MouseLeftButtonDown="Path_MouseLeftButtonDown"
Stretch="Uniform"
Tag="{Binding order_id}" />
</Border>
<!--<Border x:Name="borderDeadline" Grid.Column="3" Margin="8,0,0,0" Background="#e8fbf9" BorderBrush="#d0fbea" BorderThickness="1" MinWidth="30" Height="24" CornerRadius="2">
<!--<Border x:Name="borderDeadline" Grid.Column="3" Margin="8,0,0,0" Background="#e8fbf9" BorderBrush="#d0fbea" BorderThickness="1" MinWidth="30" Height="24" CornerRadius="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
@@ -94,163 +94,169 @@
Text="{Binding DeliveryDeadlineStr}" BorderThickness="0" />
</Grid>
</Border>-->
<Border
x:Name="borderDeadline"
Grid.Column="4"
Height="24"
MinWidth="30"
Margin="8,0,0,0"
Background="{Binding StatusBoxBackColor}"
BorderBrush="{Binding StatusBoxBorderColor}"
BorderThickness="1"
CornerRadius="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border
x:Name="borderDeadlineHead"
Background="{Binding StatusBoxForeColor}"
CornerRadius="2,0,10,2">
<TextBox
Grid.Column="0"
MinWidth="20"
Margin="2,0,2,1"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="White"
IsReadOnly="True"
Text="{Binding DeliveryDeadlineStatusStr}" />
</Border>
<TextBox
x:Name="txtDeadline"
Grid.Column="1"
MinWidth="20"
Margin="2,0,2,1"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="{Binding StatusBoxForeColor}"
IsReadOnly="True"
Text="{Binding DeliveryDeadlineStr}" />
</Grid>
</Border>
<Border
x:Name="borderDeadline"
Grid.Column="4"
Height="24"
MinWidth="30"
Margin="8,0,0,0"
Background="{Binding StatusBoxBackColor}"
BorderBrush="{Binding StatusBoxBorderColor}"
BorderThickness="1"
CornerRadius="2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border
x:Name="borderDeadlineHead"
Background="{Binding StatusBoxForeColor}"
CornerRadius="2,0,10,2">
<TextBox
Grid.Column="0"
MinWidth="20"
Margin="2,0,2,1"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="White"
IsReadOnly="True"
Text="{Binding DeliveryDeadlineStatusStr}" />
</Border>
<TextBox
x:Name="txtDeadline"
Grid.Column="1"
MinWidth="20"
Margin="2,0,2,1"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="{Binding StatusBoxForeColor}"
IsReadOnly="True"
Text="{Binding DeliveryDeadlineStr}" />
</Grid>
</Border>
<Image
Grid.Column="5"
Width="24"
Height="24"
Margin="8,8,0,8"
Source="{StaticResource imgOrderWeishopIcon}" />
<uc:UcWaterMarkTextBox1
x:Name="txtShopLoginStatus"
Grid.Column="5"
Margin="0,12,-6,0"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Background="{StaticResource ListHeadBackColorBrush}"
BorderThickness="0"
FontSize="7"
FontWeight="Bold"
Foreground="{Binding Shop.LoginStatusStr, Converter={StaticResource statusToColorBrushConverter}}"
IsReadOnly="True"
Text="{Binding Shop.LoginStatusStr}"
WaterMark="店铺状态" />
<uc:UcWaterMarkTextBox1
x:Name="txtShopName"
Grid.Column="6"
Margin="4,8,4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
FontSize="14"
Foreground="{StaticResource PageLinkBrush}"
IsReadOnly="True"
Text="{Binding shop_name}"
WaterMark="店铺名称" />
<Image
Grid.Column="5"
Width="24"
Height="24"
Margin="8,8,0,8"
Source="{StaticResource imgOrderWeishopIcon}" />
<uc:UcWaterMarkTextBox1
x:Name="txtShopLoginStatus"
Grid.Column="5"
Margin="0,12,-6,0"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Background="{StaticResource ListHeadBackColorBrush}"
BorderThickness="0"
FontSize="7"
FontWeight="Bold"
Foreground="{Binding Shop.LoginStatusStr, Converter={StaticResource statusToColorBrushConverter}}"
IsReadOnly="True"
Text="{Binding Shop.LoginStatusStr}"
WaterMark="店铺状态" />
<uc:UcWaterMarkTextBox1
x:Name="txtShopName"
Grid.Column="6"
Margin="4,8,4,8"
VerticalAlignment="Center"
Background="Transparent"
BorderThickness="0"
Cursor="Hand"
FontSize="14"
Foreground="{StaticResource PageLinkBrush}"
IsReadOnly="True"
Text="{Binding shop_name}"
WaterMark="店铺名称" />
<Border
Grid.Column="7"
Width="12"
Height="12"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="pathShop_MouseLeftButtonDown"
Tag="{Binding order_id}">
<Path
Margin="0,0,0,0"
Data="M80 432a128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128h-384a128 128 0 0 1-128-128v-384z m128-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64h-384z M816 144h-384a64 64 0 0 0-64 64 32 32 0 0 1-64 0 128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128 32 32 0 0 1 0-64 64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64z"
Fill="{StaticResource PageLinkBrush}"
MouseLeftButtonDown="pathShop_MouseLeftButtonDown"
Stretch="Uniform"
Tag="{Binding order_id}" />
</Border>
<Border
Grid.Column="7"
Width="12"
Height="12"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="pathShop_MouseLeftButtonDown"
Tag="{Binding order_id}">
<Path
Margin="0,0,0,0"
Data="M80 432a128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128h-384a128 128 0 0 1-128-128v-384z m128-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64h-384z M816 144h-384a64 64 0 0 0-64 64 32 32 0 0 1-64 0 128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128 32 32 0 0 1 0-64 64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64z"
Fill="{StaticResource PageLinkBrush}"
MouseLeftButtonDown="pathShop_MouseLeftButtonDown"
Stretch="Uniform"
Tag="{Binding order_id}" />
</Border>
<uc:UcWaterMarkTextBox1
x:Name="txtCount"
Grid.Column="9"
Margin="8,8,0,8"
VerticalAlignment="Center"
d:Text="该订单共有 x 条不同商品记录"
Background="Transparent"
BorderThickness="0"
FontSize="14"
Foreground="Gray"
IsReadOnly="True"
WaterMark="该订单共有 1 条不同商品记录" />
<uc:UcWaterMarkTextBox1
x:Name="txtCount"
Grid.Column="9"
Margin="8,8,0,8"
VerticalAlignment="Center"
d:Text="该订单共有 x 条不同商品记录"
Background="Transparent"
BorderThickness="0"
FontSize="14"
Foreground="Gray"
IsReadOnly="True"
WaterMark="该订单共有 1 条不同商品记录" />
<TextBlock
Grid.Column="11"
Margin="10"
VerticalAlignment="Center"
d:Text="仅退款/退货退款"
Foreground="Gray"
Text="{Binding RefundTypeStr}" />
<TextBlock
Grid.Column="11"
Margin="10"
VerticalAlignment="Center"
d:Text="仅退款/退货退款"
Foreground="Gray"
Text="{Binding RefundTypeStr}" />
<Border
x:Name="borderFlag"
Grid.Column="12"
Width="auto"
Height="15"
Margin="2"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="borderFlag_MouseLeftButtonDown">
<!--<Path Margin="0,0,0,0" Fill="{StaticResource FlagGrayBrush}" Data="M 0,0 L 1,0 L 1,1 L 10,1 L 7,4 L 10,7 L 1,7 L 1,12 L 0,12 Z" Stretch="Uniform" />-->
<WrapPanel VerticalAlignment="Center">
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="Gray"
Text="隐藏标记" />
<Border
x:Name="borderFlag"
Grid.Column="12"
Width="auto"
Margin="2"
VerticalAlignment="Center"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonDown="borderFlag_MouseLeftButtonDown">
<!--<Path Margin="0,0,0,0" Fill="{StaticResource FlagGrayBrush}" Data="M 0,0 L 1,0 L 1,1 L 10,1 L 7,4 L 10,7 L 1,7 L 1,12 L 0,12 Z" Stretch="Uniform" />-->
<WrapPanel VerticalAlignment="Center">
<Button
Width="60"
Height="22"
Margin="0,0,10,0"
Click="btn_createOrderSupportTicket"
Content="创建工单"
Style="{StaticResource BasePageButtonStyle}" />
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="Gray"
Text="隐藏标记" />
<Path
Width="15"
Height="15"
Margin="2,0,0,0"
d:Fill="SkyBlue"
Data="M 0,0 L 1,0 L 1,1 L 10,1 L 7,4 L 10,7 L 1,7 L 1,12 L 0,12 Z"
Fill="{Binding ColourFlagBrush}"
Stretch="Uniform" />
<!--ColourFlagBrush-->
<!--Fill="{Binding colour_flag, Converter={StaticResource flagColorConverter}}"-->
Width="15"
Height="15"
Margin="2,0,0,0"
d:Fill="SkyBlue"
Data="M 0,0 L 1,0 L 1,1 L 10,1 L 7,4 L 10,7 L 1,7 L 1,12 L 0,12 Z"
Fill="{Binding ColourFlagBrush}"
Stretch="Uniform" />
<!-- ColourFlagBrush -->
<!-- Fill="{Binding colour_flag, Converter={StaticResource flagColorConverter}}" -->
</WrapPanel>
</Border>
</Border>
<!--<Border Grid.Column="11" Width="12" Background="Transparent" Height="12" VerticalAlignment="Center" Cursor="Hand">
<!--<Border Grid.Column="11" Width="12" Background="Transparent" Height="12" VerticalAlignment="Center" Cursor="Hand">
<Path Margin="0,0,0,0" Fill="{StaticResource PageLinkBrush}" MouseLeftButtonDown="pathShop_MouseLeftButtonDown" Data="M80 432a128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128h-384a128 128 0 0 1-128-128v-384z m128-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h384a64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64h-384z M816 144h-384a64 64 0 0 0-64 64 32 32 0 0 1-64 0 128 128 0 0 1 128-128h384a128 128 0 0 1 128 128v384a128 128 0 0 1-128 128 32 32 0 0 1 0-64 64 64 0 0 0 64-64v-384a64 64 0 0 0-64-64z" Stretch="Uniform" />
</Border>-->
</Grid>
</Border>
</Grid>
</Border>
</UserControl>
@@ -3,6 +3,7 @@ using AllBase;
using AllBase.Helper;
using AllBase.Model.DBModel;
using NaiBa.Util.Extension;
using OrderSupportTicket.Models;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -25,43 +26,43 @@ using Work._29Tools.Forms.ShopForms;
namespace _29WxOrderWpf.Ctrls.OrderCtrls
{
/// <summary>
/// OrderListRowHeaderCtrl.xaml 的交互逻辑
/// </summary>
public partial class OrderListRowHeaderCtrl : UserControl
{
public OrderListRowHeaderCtrl()
{
InitializeComponent();
this.DataContextChanged += OrderListRowHeaderCtrl_DataContextChanged;
txtOrderId.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtOrderId_MouseLeftButtonUp), true);
txtShopName.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtShopName_MouseLeftButtonUp), true);
}
/// <summary>
/// OrderListRowHeaderCtrl.xaml 的交互逻辑
/// </summary>
public partial class OrderListRowHeaderCtrl : UserControl
{
public OrderListRowHeaderCtrl()
{
InitializeComponent();
this.DataContextChanged += OrderListRowHeaderCtrl_DataContextChanged;
txtOrderId.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtOrderId_MouseLeftButtonUp), true);
txtShopName.AddHandler(TextBox.MouseUpEvent, new MouseButtonEventHandler(txtShopName_MouseLeftButtonUp), true);
}
private void txtShopName_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
ShopBLL.CheckShopLogin(shop);
if (shop.LoginStatus == "登录成功")
{
// 在线直接进店
shopLoginForm.openType = EnumOpenType.manage;
shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/home";
shopLoginForm.Show();
}
else
{
// 掉线登录
shopLoginForm.openType = EnumOpenType.login;
shopLoginForm.ShowDialog();
private void txtShopName_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
ShopBLL.CheckShopLogin(shop);
if (shop.LoginStatus == "登录成功")
{
// 在线直接进店
shopLoginForm.openType = EnumOpenType.manage;
shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/home";
shopLoginForm.Show();
}
else
{
// 掉线登录
shopLoginForm.openType = EnumOpenType.login;
shopLoginForm.ShowDialog();
if (shop.LoginStatus == "登录成功")
if (shop.LoginStatus == "登录成功")
{
shop.Is29Supplier = false;
for (int i = 0; i < 100; i++)
@@ -82,87 +83,87 @@ namespace _29WxOrderWpf.Ctrls.OrderCtrls
if (i * 20 >= totalCount)
break;
}
foreach (var ord in GlobalConfig.lstFilterOrder.Where(o => o.shop_id.ParseToString() == shop.ErpShopId).ToList())
{
// 更新代发管理情况
foreach (var orderItem in ord.orderItems)
Dispatcher.Invoke(new Action(() => { orderItem.UpdateOrderItemBuyButton(); }));
}
}
}
}
}
}
private void txtOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
shopLoginForm.openType = EnumOpenType.manage;
shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
{
shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/order/detail?orderid=" + order.order_id;
}
shopLoginForm.Show();
}
}
}
foreach (var ord in GlobalConfig.lstFilterOrder.Where(o => o.shop_id.ParseToString() == shop.ErpShopId).ToList())
{
// 更新代发管理情况
foreach (var orderItem in ord.orderItems)
Dispatcher.Invoke(new Action(() => { orderItem.UpdateOrderItemBuyButton(); }));
}
}
}
}
}
}
private void OrderListRowHeaderCtrl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
if (order.orderItems.Count > 5)
{
txtCount.Text = "该订单共计 " + order.orderItems.Count + " 件商品";
}
}
}
private void txtOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
WXShopLoginForm shopLoginForm = new WXShopLoginForm(shop);
shopLoginForm.openType = EnumOpenType.manage;
shopLoginForm.WindowState = System.Windows.Forms.FormWindowState.Maximized;
private void Path_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
//var orderId = ((sender as Border)?.Tag).ParseToString();
if (this.DataContext is oms_wei_order_info order)
{
{
shopLoginForm.InitUrl = "https://store.weixin.qq.com/shop/order/detail?orderid=" + order.order_id;
}
shopLoginForm.Show();
}
}
}
try
{
//Clipboard.SetText(order.order_id); // 复制成功,但会报错,改为SetDataObject
Clipboard.SetDataObject(order.order_id);
BaseMsgWindow.ShowAnimateMessage("复制成功");
}
catch (Exception ex)
{
BaseMsgWindow.ShowError("复制失败 " + ex.Message);
}
}
}
private void OrderListRowHeaderCtrl_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
if (order.orderItems.Count > 5)
{
txtCount.Text = "该订单共计 " + order.orderItems.Count + " 件商品";
}
}
}
private void pathShop_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
try
{
Clipboard.SetDataObject(shop.ShopAccount);
BaseMsgWindow.ShowAnimateMessage("复制成功");
}
catch (Exception ex)
{
BaseMsgWindow.ShowError("复制失败 " + ex.Message);
}
}
}
}
private void Path_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
//var orderId = ((sender as Border)?.Tag).ParseToString();
if (this.DataContext is oms_wei_order_info order)
{
try
{
//Clipboard.SetText(order.order_id); // 复制成功,但会报错,改为SetDataObject
Clipboard.SetDataObject(order.order_id);
BaseMsgWindow.ShowAnimateMessage("复制成功");
}
catch (Exception ex)
{
BaseMsgWindow.ShowError("复制失败 " + ex.Message);
}
}
}
private void pathShop_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (this.DataContext is oms_wei_order_info order)
{
var shop = GlobalConfig.ShopLoginList.FirstOrDefault(s => s.ErpShopId == order.shop_id.ParseToString());
if (shop != null)
{
try
{
Clipboard.SetDataObject(shop.ShopAccount);
BaseMsgWindow.ShowAnimateMessage("复制成功");
}
catch (Exception ex)
{
BaseMsgWindow.ShowError("复制失败 " + ex.Message);
}
}
}
}
private void borderFlag_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
@@ -195,9 +196,44 @@ namespace _29WxOrderWpf.Ctrls.OrderCtrls
order.colour_flag = flag;
Thread.Sleep(1000);
Dispatcher.Invoke(new Action(() => { MainWindow.Instance.orderListCtrl.FilterOneOrder(order); }));
});
}
}
/// <summary>
/// 创建工单
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private async void btn_createOrderSupportTicket(object sender, RoutedEventArgs e)
{
if (!(this.DataContext is oms_wei_order_info order))
return;
var shop = order.Shop
?? GlobalConfig.ShopLoginList.FirstOrDefault(
s => s.ErpShopId == order.shop_id.ParseToString());
var appId = shop?.ShopId?.Trim();
if (string.IsNullOrWhiteSpace(appId)
|| string.IsNullOrWhiteSpace(order.order_id))
{
BaseMsgWindow.ShowAnimateMessage(
"订单缺少店铺 APPID 或订单号,无法创建工单",
"Error",
5);
return;
}
var ticketOrder = TicketOrderOption.FromWechatOrder(order, appId);
try
{
await MainWindow.Instance.CreateOrderSupportTicketAsync(ticketOrder);
}
catch (Exception ex)
{
BaseMsgWindow.ShowError("打开工单窗口失败:" + ex.Message);
}
}
}
}
+261 -221
View File
@@ -1,233 +1,273 @@
<basewindows:BaseWindow
x:Class="_29WxOrderWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows"
xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls"
xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls"
x:Name="mainWindow"
Title="MainWindow"
Width="1600"
Height="900"
Closing="BaseWindow_Closing"
Loaded="Window_Loaded"
Style="{StaticResource BaseNoneBorderWindowStyle}"
mc:Ignorable="d">
x:Class="_29WxOrderWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows"
xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls"
xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls"
x:Name="mainWindow"
Title="MainWindow"
Width="1600"
Height="900"
Closing="BaseWindow_Closing"
Loaded="Window_Loaded"
Style="{StaticResource BaseNoneBorderWindowStyle}"
mc:Ignorable="d">
<Grid
x:Name="mainGrid"
Margin="{Binding ElementName=mainWindow, Path=Padding}"
Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="60" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid
x:Name="gridTitle"
Background="Transparent"
MouseLeftButtonDown="gridTitle_MouseLeftButtonDown">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Image
Width="50"
Height="50"
Margin="4"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Source="{StaticResource main29WeixinIcon}" />
<Label
x:Name="lblAppName"
Grid.Column="1"
Margin="0,0,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Content="拍单软件"
FontSize="20"
Foreground="{StaticResource mainColorLightBrush}" />
<Grid
x:Name="mainGrid"
Margin="{Binding ElementName=mainWindow, Path=Padding}"
Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="60" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid
x:Name="gridTitle"
Background="Transparent"
MouseLeftButtonDown="gridTitle_MouseLeftButtonDown">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Image
Width="50"
Height="50"
Margin="4"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Source="{StaticResource main29WeixinIcon}" />
<Label
x:Name="lblAppName"
Grid.Column="1"
Margin="0,0,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
VerticalContentAlignment="Center"
Content="拍单软件"
FontSize="20"
Foreground="{StaticResource mainColorLightBrush}" />
<WrapPanel
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<Button Click="bt_setting" Style="{StaticResource IconButtonStyle}">
<TextBlock
FontSize="22"
Foreground="{StaticResource mainColorLightBrush}"
Style="{StaticResource TextBlockIcon}"
Text="{StaticResource txt-settings}" />
</Button>
<Border
Width="1"
Height="20"
Margin="10,0"
Background="#b893b2" />
</WrapPanel>
<WrapPanel
Grid.Column="3"
HorizontalAlignment="Right"
VerticalAlignment="Top">
<UniformGrid Rows="2">
<WrapPanel>
<Label
x:Name="lblDebug"
Margin="2,4,0,0"
Content="[release]"
FontSize="12"
Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" />
<Label
x:Name="lblVersion"
Margin="2,4,0,0"
Content="版本信息"
FontSize="12"
Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" />
</WrapPanel>
<Label
x:Name="lblBalance"
Grid.Column="4"
HorizontalAlignment="Right"
VerticalAlignment="Top"
VerticalContentAlignment="Center"
d:Content="快递余额:999.99"
Content="{Binding Balance}"
Cursor="Hand"
FontSize="12"
Foreground="#60ff66"
MouseLeftButtonUp="lblBalance_MouseLeftButtonUp"
Opacity="0.7" />
</UniformGrid>
</WrapPanel>
<userctrls:UcWindowBoxes
x:Name="ucWindowBox"
Grid.Column="4"
Margin="4"
HorizontalAlignment="Right"
VerticalAlignment="Top"
MaxBoxVisibility="Visible" />
</Grid>
<Grid
x:Name="gridContainer"
Grid.Row="1"
Margin="2,0,2,2"
Background="White">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<orderctrls:OrderListCtrl
x:Name="orderListCtrl"
Grid.Column="1"
d:Visibility="Visible"
Visibility="Collapsed" />
<Grid
x:Name="gridTbBuyerOrderReadCtrl"
Grid.ColumnSpan="2"
d:Visibility="Visible"
Background="#7F000000"
MouseLeftButtonUp="gridPddBuyerOrderReadCtrl_MouseLeftButtonUp"
Visibility="Hidden">
<WrapPanel
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<Button
x:Name="btOrderSupport"
Click="bt_showOrderSupport"
Style="{StaticResource IconButtonStyle}"
ToolTip="客户工单">
<Grid Width="30" Height="30">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="22"
Foreground="{StaticResource mainColorLightBrush}"
Style="{StaticResource TextBlockIcon}"
Text="{StaticResource txt-alert}" />
<Border
x:Name="borderOrderSupportUnread"
Height="16"
MinWidth="16"
Padding="4,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
d:Visibility="Visible"
Background="#E53935"
CornerRadius="8"
Visibility="Collapsed">
<TextBlock
x:Name="tbOrderSupportUnread"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="0" />
</Border>
</Grid>
</Button>
<Border
Height="auto"
Margin="0,50,0,0"
HorizontalAlignment="Right"
Background="White"
CornerRadius="10">
Width="1"
Height="20"
Margin="10,0"
Background="#b893b2" />
<Button Click="bt_setting" Style="{StaticResource IconButtonStyle}">
<TextBlock
FontSize="22"
Foreground="{StaticResource mainColorLightBrush}"
Style="{StaticResource TextBlockIcon}"
Text="{StaticResource txt-settings}" />
</Button>
<Border
Width="1"
Height="20"
Margin="10,0"
Background="#b893b2" />
</WrapPanel>
<WrapPanel
Grid.Column="3"
HorizontalAlignment="Right"
VerticalAlignment="Top">
<UniformGrid Rows="2">
<WrapPanel>
<Label
x:Name="lblDebug"
Margin="2,4,0,0"
Content="[release]"
FontSize="12"
Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" />
<Label
x:Name="lblVersion"
Margin="2,4,0,0"
Content="版本信息"
FontSize="12"
Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" />
</WrapPanel>
<Label
x:Name="lblBalance"
Grid.Column="4"
HorizontalAlignment="Right"
VerticalAlignment="Top"
VerticalContentAlignment="Center"
d:Content="快递余额:999.99"
Content="{Binding Balance}"
Cursor="Hand"
FontSize="12"
Foreground="#60ff66"
MouseLeftButtonUp="lblBalance_MouseLeftButtonUp"
Opacity="0.7" />
</UniformGrid>
</WrapPanel>
<userctrls:UcWindowBoxes
x:Name="ucWindowBox"
Grid.Column="4"
Margin="4"
HorizontalAlignment="Right"
VerticalAlignment="Top"
MaxBoxVisibility="Visible" />
</Grid>
<Grid
x:Name="gridContainer"
Grid.Row="1"
Margin="2,0,2,2"
Background="White">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<orderctrls:OrderListCtrl
x:Name="orderListCtrl"
Grid.Column="1"
d:Visibility="Visible"
Visibility="Collapsed" />
<Grid
x:Name="gridTbBuyerOrderReadCtrl"
Grid.ColumnSpan="2"
d:Visibility="Visible"
Background="#7F000000"
MouseLeftButtonUp="gridPddBuyerOrderReadCtrl_MouseLeftButtonUp"
Visibility="Hidden">
<Border
Height="auto"
Margin="0,50,0,0"
HorizontalAlignment="Right"
Background="White"
CornerRadius="10">
<Grid>
<buyerctrls:PDDBuyerOrderReadCtrl
x:Name="pddBuyerOrderReadCtrl"
Grid.Column="1"
Width="1200"
HorizontalAlignment="Right"
Panel.ZIndex="-1"
d:Visibility="Visible"
Visibility="Collapsed" />
x:Name="pddBuyerOrderReadCtrl"
Grid.Column="1"
Width="1200"
HorizontalAlignment="Right"
Panel.ZIndex="-1"
d:Visibility="Visible"
Visibility="Collapsed" />
<buyerctrls:TbBuyerOrderReadCtrl
x:Name="tbBuyerOrderReadCtrl"
Grid.Column="1"
Width="1200"
HorizontalAlignment="Right"
Panel.ZIndex="-1"
d:Visibility="Collapsed"
Visibility="Collapsed" />
x:Name="tbBuyerOrderReadCtrl"
Grid.Column="1"
Width="1200"
HorizontalAlignment="Right"
Panel.ZIndex="-1"
d:Visibility="Collapsed"
Visibility="Collapsed" />
</Grid>
</Border>
</Grid>
<buyerctrls:PDDBuyerSelectCtrl
x:Name="pddBuyerSelectCtrl"
Grid.Column="1"
Margin="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
d:Visibility="Visible"
Visibility="Collapsed" />
<Grid
x:Name="gridUser"
Grid.ColumnSpan="2"
Background="#7F000000"
Visibility="Hidden">
<Border
Width="500"
Height="600"
Background="White"
CornerRadius="4">
<shopctrls:ShopListCtrl
x:Name="shopListCtrl"
Grid.ColumnSpan="2"
Margin="2"
Visibility="{Binding ElementName=gridUser, Mode=TwoWay, Path=Visibility}" />
</Border>
</Grid>
</Grid>
</Grid>
<buyerctrls:PDDBuyerSelectCtrl
x:Name="pddBuyerSelectCtrl"
Grid.Column="1"
Margin="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
d:Visibility="Visible"
Visibility="Collapsed" />
<Grid
x:Name="gridUser"
Grid.ColumnSpan="2"
Background="#7F000000"
Visibility="Hidden">
<Border
Width="500"
Height="600"
Background="White"
CornerRadius="4">
<shopctrls:ShopListCtrl
x:Name="shopListCtrl"
Grid.ColumnSpan="2"
Margin="2"
Visibility="{Binding ElementName=gridUser, Mode=TwoWay, Path=Visibility}" />
</Border>
</Grid>
</Grid>
<Border
x:Name="borderMessage"
Grid.Row="1"
Width="auto"
Height="auto"
MinWidth="120"
MinHeight="2"
MaxWidth="1200"
Margin="10,50"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Panel.ZIndex="1000000"
Background="White"
BorderBrush="{StaticResource pageColorBrush}"
BorderThickness="1"
CornerRadius="6"
IsHitTestVisible="False"
Opacity="0">
<Border.Effect>
<DropShadowEffect
BlurRadius="40"
Opacity="0.2"
ShadowDepth="5"
Color="#FF98638f" />
</Border.Effect>
<TextBlock
x:Name="tbMessage"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="这里是默认提示信息"
TextWrapping="Wrap" />
</Border>
<!--<Image x:Name="imageBig" HorizontalAlignment="Left" Height="800" Width="800" VerticalAlignment="Center" Grid.Row="1" Margin="200,0,0,0"/>-->
</Grid>
<Border
x:Name="borderMessage"
Grid.Row="1"
Width="auto"
Height="auto"
MinWidth="120"
MinHeight="2"
MaxWidth="1200"
Margin="10,50"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Panel.ZIndex="1000000"
Background="White"
BorderBrush="{StaticResource pageColorBrush}"
BorderThickness="1"
CornerRadius="6"
IsHitTestVisible="False"
Opacity="0">
<Border.Effect>
<DropShadowEffect
BlurRadius="40"
Opacity="0.2"
ShadowDepth="5"
Color="#FF98638f" />
</Border.Effect>
<TextBlock
x:Name="tbMessage"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="这里是默认提示信息"
TextWrapping="Wrap" />
</Border>
<!--<Image x:Name="imageBig" HorizontalAlignment="Left" Height="800" Width="800" VerticalAlignment="Center" Grid.Row="1" Margin="200,0,0,0"/>-->
</Grid>
</basewindows:BaseWindow>
+468 -300
View File
@@ -14,6 +14,9 @@ using CefSharp;
using NaiBa.Util;
using NaiBa.Util.Extension;
using Newtonsoft.Json.Linq;
using OrderSupportTicket.Models;
using OrderSupportTicket.Services;
using OrderSupportTicket.Views;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -41,249 +44,261 @@ using System.Xml.Linq;
using Work;
using Work._29Tools.BLL;
using Work._29Tools.Forms.UserForms;
using WxOrder.Framework.Commons;
using WxOrder.Framework.Commons.Dialogs;
using static System.Windows.Forms.VisualStyles.VisualStyleElement;
using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;
namespace _29WxOrderWpf
{
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : BaseWindow, INotifyPropertyChanged
{
public static MainWindow Instance { get; private set; }
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : BaseWindow, INotifyPropertyChanged
{
public static MainWindow Instance { get; private set; }
public event PropertyChangedEventHandler PropertyChanged;
public string Balance { get { return "快递余额:" + BaseClass.Balance.ToString("0.00"); } set { OnPropertyChanged("Balance"); } }
private readonly TicketRealtimeMonitor _ticketMonitor = TicketRealtimeMonitor.Current;
private TicketClientOptions _ticketOptions;
private OrderSupportTicketView _orderSupportView;
public MainWindow()
{
InitializeComponent();
this.DataContext = this;
Instance = this;
public event PropertyChangedEventHandler PropertyChanged;
public string Balance { get { return "快递余额:" + BaseClass.Balance.ToString("0.00"); } set { OnPropertyChanged("Balance"); } }
gridUser.Visibility = Visibility.Collapsed;
}
public MainWindow()
{
InitializeComponent();
this.DataContext = this;
Instance = this;
private void Window_Loaded(object sender, RoutedEventArgs e)
{
GlobalConfig.Dispatcher = this.Dispatcher;
lblDebug.Visibility = Visibility.Collapsed;
gridUser.Visibility = Visibility.Collapsed;
ucWindowBox.SetWindowMax();
this.Title = BaseClass.ToolName;
_ticketMonitor.NewMessageReceived += OnTicketNewMessageReceived;
_ticketMonitor.UnreadCountChanged += OnTicketUnreadCountChanged;
Closed += MainWindow_Closed;
}
new Thread(AutoAntiGrabbingMechanism).Start(); // 反抓包
private void Window_Loaded(object sender, RoutedEventArgs e)
{
GlobalConfig.Dispatcher = this.Dispatcher;
lblDebug.Visibility = Visibility.Collapsed;
#region 弹出登录窗口
LoginWindow loginWindow = new LoginWindow(App.args);
loginWindow.Owner = this;
loginWindow.ShowDialog();
#endregion
ucWindowBox.SetWindowMax();
this.Title = BaseClass.ToolName;
if (loginWindow.IsLogin)
{
lblVersion.Content = BaseClass.ToolName + " V" + BaseClass.Varsion + " - " + BaseClass.NickName;
new Thread(AutoAntiGrabbingMechanism).Start(); // 反抓包
//shopListCtrl.Visibility = Visibility.Visible;
orderListCtrl.Visibility = Visibility.Visible;
#region 弹出登录窗口
LoginWindow loginWindow = new LoginWindow(App.args);
loginWindow.Owner = this;
loginWindow.ShowDialog();
#endregion
if (loginWindow.IsLogin)
{
lblVersion.Content = BaseClass.ToolName + " V" + BaseClass.Varsion + " - " + BaseClass.NickName;
//shopListCtrl.Visibility = Visibility.Visible;
orderListCtrl.Visibility = Visibility.Visible;
pddBuyerOrderReadCtrl.Visibility = Visibility.Visible;
tbBuyerOrderReadCtrl.Visibility = Visibility.Visible;
pddBuyerSelectCtrl.Visibility = Visibility.Visible;
Task.Run(() =>
{
GetServerShop();
Task.Run(() =>
{
GetServerShop();
StartTicketRealtimeMonitor();//启动工单监控
GetShopSupplier(GlobalConfig.ShopLoginList.ToList());
});
Task.Run(() =>
{
GetPddBuyer();
Task.Run(() =>
{
GetPddBuyer();
GetTaobaoBuyer();
});
}
}
protected virtual void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)//有改变
this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
protected virtual void OnPropertyChanged(string propertyName)
{
if (this.PropertyChanged != null)//有改变
this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
#region 反抓包功能
private void AutoAntiGrabbingMechanism()
{
while (true)
{
#region 反抓包功能
private void AutoAntiGrabbingMechanism()
{
while (true)
{
#if DEBUG
#else
AntiGrabbingMechanism();
#endif
try { Thread.Sleep(3000); } catch { }
}
}
try { Thread.Sleep(3000); } catch { }
}
}
[DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);
private List<string> _AntiProcesses = "WINRAR,WIRESHARK,FIDDLER,HTTPDEBUGGERPRO,IEHTTPANALYZER,CHARLES,HTTPWATCH,SMARTSNIFF,MINISNIFFER,PROXIFIER,HTTP ANALYZER,SNIFFER,WSOCKEXPERT,IPANALYSE,ANALYZER,HTTP ANALYZER STAND-ALONE EDITION".Split(',').ToList();
private void AntiGrabbingMechanism()
{
//Gate 1 - Anti Proxy
if (IsUseSystemProxy())
{
LockUserAndKillProcess();
}
[DllImport("kernel32.dll", SetLastError = true, CallingConvention = CallingConvention.Winapi)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool IsWow64Process([In] IntPtr process, [Out] out bool wow64Process);
private List<string> _AntiProcesses = "WINRAR,WIRESHARK,FIDDLER,HTTPDEBUGGERPRO,IEHTTPANALYZER,CHARLES,HTTPWATCH,SMARTSNIFF,MINISNIFFER,PROXIFIER,HTTP ANALYZER,SNIFFER,WSOCKEXPERT,IPANALYSE,ANALYZER,HTTP ANALYZER STAND-ALONE EDITION".Split(',').ToList();
private void AntiGrabbingMechanism()
{
//Gate 1 - Anti Proxy
if (IsUseSystemProxy())
{
LockUserAndKillProcess();
}
//Gate 2 - Anti Special Processes
System.Diagnostics.Process[] processList = System.Diagnostics.Process.GetProcesses();
foreach (System.Diagnostics.Process process in processList)
{
try
{
if (_AntiProcesses.Contains(process.ProcessName.ToUpper()))
{
process.Kill();
LockUserAndKillProcess();
}
//Gate 2 - Anti Special Processes
System.Diagnostics.Process[] processList = System.Diagnostics.Process.GetProcesses();
foreach (System.Diagnostics.Process process in processList)
{
try
{
if (_AntiProcesses.Contains(process.ProcessName.ToUpper()))
{
process.Kill();
LockUserAndKillProcess();
}
if (process.MainWindowTitle.Contains("正在捕获"))
{
process.Kill();
LockUserAndKillProcess();
}
if (process.MainWindowTitle.Contains("正在捕获"))
{
process.Kill();
LockUserAndKillProcess();
}
if (!IsWin23App(process))
{
continue;
}
if (process.MainModule?.FileVersionInfo.FileDescription != null)
{
foreach (string antiProcess in _AntiProcesses)
{
if (process.MainModule.FileVersionInfo.FileDescription.ToUpper().Contains(antiProcess))
{
process.Kill();
LockUserAndKillProcess();
}
}
}
}
catch (Exception e)
{
}
}
}
if (!IsWin23App(process))
{
continue;
}
if (process.MainModule?.FileVersionInfo.FileDescription != null)
{
foreach (string antiProcess in _AntiProcesses)
{
if (process.MainModule.FileVersionInfo.FileDescription.ToUpper().Contains(antiProcess))
{
process.Kill();
LockUserAndKillProcess();
}
}
}
}
catch (Exception e)
{
}
}
}
private void LockUserAndKillProcess()
{
string url = BaseClass.BuildObjString("user&act=add_user_lock");
string data = UserLogin.GetSignaturex("", "");
string html = GetHtml.PostUtf8(url, data);
Process.GetCurrentProcess().Kill();
}
public static bool IsUseSystemProxy()
{
BindingFlags bindingAttr = BindingFlags.Instance | BindingFlags.NonPublic;
IWebProxy systemWebProxy = WebRequest.GetSystemWebProxy();
FieldInfo field1 = systemWebProxy.GetType().GetField("webProxy", bindingAttr);
if (field1 != (FieldInfo)null)
{
WebProxy webProxy = field1.GetValue((object)systemWebProxy) as WebProxy;
FieldInfo field2 = webProxy.GetType().GetField("_ProxyHostAddresses", bindingAttr);
if (field2 != (FieldInfo)null)
{
Hashtable hashtable = field2.GetValue((object)webProxy) as Hashtable;
if (hashtable != null && hashtable.Count > 0)
return true;
}
}
return false;
}
private void LockUserAndKillProcess()
{
string url = BaseClass.BuildObjString("user&act=add_user_lock");
string data = UserLogin.GetSignaturex("", "");
string html = GetHtml.PostUtf8(url, data);
Process.GetCurrentProcess().Kill();
}
public static bool IsUseSystemProxy()
{
BindingFlags bindingAttr = BindingFlags.Instance | BindingFlags.NonPublic;
IWebProxy systemWebProxy = WebRequest.GetSystemWebProxy();
FieldInfo field1 = systemWebProxy.GetType().GetField("webProxy", bindingAttr);
if (field1 != (FieldInfo)null)
{
WebProxy webProxy = field1.GetValue((object)systemWebProxy) as WebProxy;
FieldInfo field2 = webProxy.GetType().GetField("_ProxyHostAddresses", bindingAttr);
if (field2 != (FieldInfo)null)
{
Hashtable hashtable = field2.GetValue((object)webProxy) as Hashtable;
if (hashtable != null && hashtable.Count > 0)
return true;
}
}
return false;
}
public bool IsWin23App(Process process)
{
if (Environment.Is64BitOperatingSystem)
{
IntPtr processHandle;
bool retVal;
public bool IsWin23App(Process process)
{
if (Environment.Is64BitOperatingSystem)
{
IntPtr processHandle;
bool retVal;
try
{
processHandle = Process.GetProcessById(process.Id).Handle;
}
catch
{
return false;
}
return IsWow64Process(processHandle, out retVal) && retVal;
}
return true;
}
#endregion
try
{
processHandle = Process.GetProcessById(process.Id).Handle;
}
catch
{
return false;
}
return IsWow64Process(processHandle, out retVal) && retVal;
}
return true;
}
#endregion
private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
Process.GetCurrentProcess().Kill();
else
e.Cancel = true;
}
private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
Process.GetCurrentProcess().Kill();
else
e.Cancel = true;
}
#region 读取微信店铺
public bool isReadShop { get; set; } = false;
public bool hasReadShop { get; set; } = false;
public void GetServerShop()
{
if (isReadShop)
return;
#region 读取微信店铺
public bool isReadShop { get; set; } = false;
public bool hasReadShop { get; set; } = false;
public void GetServerShop()
{
if (isReadShop)
return;
var tList = new ConcurrentList<Task>();
List<ShopLoginInfo> list = new List<ShopLoginInfo>();
Dispatcher.Invoke(new Action(() =>
{
}));
isReadShop = true;
int page = 0;
while (true)
{
page++;
var url = BaseClass.Build29WxObjString("shop/get_shop_list");
var data = UserLogin.GetSignaturex() + "&page=" + page;
data += "&is_get_erp_shop_id=true";
var html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html)
|| !html.Contains("shop_id") || !html.Contains("shop_account") || !html.Contains("login_pass"))
{
break;
}
var tList = new ConcurrentList<Task>();
List<ShopLoginInfo> list = new List<ShopLoginInfo>();
Dispatcher.Invoke(new Action(() =>
{
}));
isReadShop = true;
int page = 0;
while (true)
{
page++;
var url = BaseClass.Build29WxObjString("shop/get_shop_list");
var data = UserLogin.GetSignaturex() + "&page=" + page;
data += "&is_get_erp_shop_id=true";
var html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
html = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(html)
|| !html.Contains("shop_id") || !html.Contains("shop_account") || !html.Contains("login_pass"))
{
break;
}
var jArr = JObject.Parse(html)["data"];
if (jArr.Count() == 0)
break;
var jArr = JObject.Parse(html)["data"];
if (jArr.Count() == 0)
break;
foreach (JObject jObj in jArr)
{
var shop = ShopBLL.DeserializationShop(jObj);
foreach (JObject jObj in jArr)
{
var shop = ShopBLL.DeserializationShop(jObj);
var shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
while (shopExist != null)
{
shop.Is29Supplier = shopExist.Is29Supplier;
shop.IsSelect = shopExist.IsSelect;
shop.IsFilter = shopExist.IsFilter;
list.Remove(shopExist);
var shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
while (shopExist != null)
{
shop.Is29Supplier = shopExist.Is29Supplier;
shop.IsSelect = shopExist.IsSelect;
shop.IsFilter = shopExist.IsFilter;
list.Remove(shopExist);
shopExist = list.LastOrDefault(l => l.ShopId == shop.ShopId);
}
list.Add(shop);
list.Add(shop);
if (!GlobalConfig.ShopLoginList.Any(s => s.ShopId == shop.ShopId && !shop.is_point_shop_expire))
{
Dispatcher.Invoke(new Action(() =>
{
GlobalConfig.ShopLoginList.Add(shop);
}));
if (!GlobalConfig.ShopLoginList.Any(s => s.ShopId == shop.ShopId && !shop.is_point_shop_expire))
{
Dispatcher.Invoke(new Action(() =>
{
GlobalConfig.ShopLoginList.Add(shop);
}));
Task.Run(() =>
{
@@ -332,27 +347,27 @@ namespace _29WxOrderWpf
}));
});
}
}
}
Dispatcher.Invoke(new Action(() =>
{
foreach (var shop in GlobalConfig.ShopLoginList.Where(s => !list.Any(ss => ss.ShopId == s.ShopId)).ToList())
{
GlobalConfig.ShopLoginList.Remove(shop);
}
foreach (var shop in GlobalConfig.ShopLoginList.Where(s => s.is_point_shop_expire).ToList())
{
GlobalConfig.ShopLoginList.Remove(shop);
}
}));
isReadShop = false;
hasReadShop = true;
}
}
Dispatcher.Invoke(new Action(() =>
{
foreach (var shop in GlobalConfig.ShopLoginList.Where(s => !list.Any(ss => ss.ShopId == s.ShopId)).ToList())
{
GlobalConfig.ShopLoginList.Remove(shop);
}
foreach (var shop in GlobalConfig.ShopLoginList.Where(s => s.is_point_shop_expire).ToList())
{
GlobalConfig.ShopLoginList.Remove(shop);
}
}));
isReadShop = false;
hasReadShop = true;
while (tList.Where(ta => !ta.IsFaulted && !ta.IsCompleted).Count() >= 1)
{
tList.RemoveAll(ta => ta.IsFaulted || ta.IsCompleted || ta.IsCanceled); Thread.Sleep(100);
}
}
}
public void GetShopSupplier(List<ShopLoginInfo> shops)
{
@@ -369,7 +384,7 @@ namespace _29WxOrderWpf
var jArr = JsonHelper.JObjectParse(rst)["list"] as JArray;
// appid "wx7809625286c28f2f" name "百川日用";
if (jArr.Any(aa => aa["appid"].ParseToString() == "wx7809625286c28f2f" && aa["status"].ParseToInt() == 1))
//if (rst.Contains("wx7809625286c28f2f") || rst.Contains("百川日用"))
//if (rst.Contains("wx7809625286c28f2f") || rst.Contains("百川日用"))
continue;
rst = new WxApiBLL().SetShopSupplier(shop, "wx7809625286c28f2f", "4431685489938137094");
@@ -380,7 +395,7 @@ namespace _29WxOrderWpf
}
catch
{ }
}
}
}
#endregion
@@ -392,83 +407,83 @@ namespace _29WxOrderWpf
/// 读取拼多多买手
/// </summary>
public void GetPddBuyer()
{
if (isGetPddBuyer)
return;
{
if (isGetPddBuyer)
return;
isGetPddBuyer = true;
//var ids = dgvBuyer.Rows.Cast<DataGridViewRow>().Where(r => r.Cells["colSelect"].Value.ParseToBool() == true).Select(s => s.Cells["colBuyerId"].Value.ParseToString()).ToList();
//Invoke(new Action(() => { pbLoading.Visible = true; }));
isGetPddBuyer = true;
//var ids = dgvBuyer.Rows.Cast<DataGridViewRow>().Where(r => r.Cells["colSelect"].Value.ParseToBool() == true).Select(s => s.Cells["colBuyerId"].Value.ParseToString()).ToList();
//Invoke(new Action(() => { pbLoading.Visible = true; }));
try
{
var url = BaseClass.Build29PDDObjString("user/get_buyer");
var data = UserLogin.GetSignaturex();
data += "&page_size=200";
try
{
var url = BaseClass.Build29PDDObjString("user/get_buyer");
var data = UserLogin.GetSignaturex();
data += "&page_size=200";
var result = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(result) || !result.Contains("\"code\":200"))
result = GetHtml.PostUtf8(url, data);
var result = GetHtml.PostUtf8(url, data);
if (string.IsNullOrEmpty(result) || !result.Contains("\"code\":200"))
result = GetHtml.PostUtf8(url, data);
if (result.Contains("\"code\":200"))
{
List<BuyerLoginInfo> loginInfoLst = JsonHelper.DeserializeJsonToList<BuyerLoginInfo>(JsonHelper.JObjectParse(result)["data"].ParseToString());
foreach (var buyer in GlobalConfig.PDDBuyerLoginList.Where(bu => !loginInfoLst.Any(b => b.buyer_id == bu.buyer_id)).ToList())
{
Dispatcher.Invoke(new Action(() => { GlobalConfig.PDDBuyerLoginList.Remove(buyer); }));
}
if (result.Contains("\"code\":200"))
{
List<BuyerLoginInfo> loginInfoLst = JsonHelper.DeserializeJsonToList<BuyerLoginInfo>(JsonHelper.JObjectParse(result)["data"].ParseToString());
foreach (var b in loginInfoLst)
{
Dispatcher.Invoke(new Action(() =>
{
var buyer = GlobalConfig.PDDBuyerLoginList.FirstOrDefault(bu => bu.buyer_id == b.buyer_id);
if (buyer == null)
GlobalConfig.PDDBuyerLoginList.Add(b);
else
{
buyer.add_time = b.add_time;
buyer.buyer_id = b.buyer_id;
buyer.buyer_name = b.buyer_name;
buyer.buyer_nick = b.buyer_nick;
buyer.cookie = b.cookie;
buyer.mdy_time = b.mdy_time;
buyer.notes = b.notes;
buyer.status = b.status;
buyer.user_name = b.user_name;
}
}));
}
foreach (var buyer in GlobalConfig.PDDBuyerLoginList.Where(bu => !loginInfoLst.Any(b => b.buyer_id == bu.buyer_id)).ToList())
{
Dispatcher.Invoke(new Action(() => { GlobalConfig.PDDBuyerLoginList.Remove(buyer); }));
}
//Invoke(new Action(() =>
//{
//dgvBuyer.Rows.Clear();
//cmbBuyer.Items.Clear();
//}));
foreach (var b in loginInfoLst)
{
Dispatcher.Invoke(new Action(() =>
{
var buyer = GlobalConfig.PDDBuyerLoginList.FirstOrDefault(bu => bu.buyer_id == b.buyer_id);
if (buyer == null)
GlobalConfig.PDDBuyerLoginList.Add(b);
else
{
buyer.add_time = b.add_time;
buyer.buyer_id = b.buyer_id;
buyer.buyer_name = b.buyer_name;
buyer.buyer_nick = b.buyer_nick;
buyer.cookie = b.cookie;
buyer.mdy_time = b.mdy_time;
buyer.notes = b.notes;
buyer.status = b.status;
buyer.user_name = b.user_name;
}
}));
}
foreach (var buyer in GlobalConfig.PDDBuyerLoginList)
{
//Invoke(new Action(() =>
//{
//BindBuyer(buyer, "", ids);
//cmbBuyer.Items.Add(buyer.buyer_nick);
//}));
}
}
}
catch
{
}
//Invoke(new Action(() =>
//{
//dgvBuyer.Rows.Clear();
//cmbBuyer.Items.Clear();
//}));
//Invoke(new Action(() =>
//{
// pbLoading.Visible = false;
//}));
foreach (var buyer in GlobalConfig.PDDBuyerLoginList)
{
//Invoke(new Action(() =>
//{
//BindBuyer(buyer, "", ids);
//cmbBuyer.Items.Add(buyer.buyer_nick);
//}));
}
}
}
catch
{
}
isGetPddBuyer = false;
hasGetPddBuyer = true;
}
//Invoke(new Action(() =>
//{
// pbLoading.Visible = false;
//}));
isGetPddBuyer = false;
hasGetPddBuyer = true;
}
#endregion
#region 读取拼多多买手号
@@ -537,18 +552,18 @@ namespace _29WxOrderWpf
#endregion
private void lblBalance_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
WXGetBalanceQrCodeForm balanceQrCodeForm = new WXGetBalanceQrCodeForm(StartUp.UpdateBalance);
balanceQrCodeForm.ShowDialog();
}
{
WXGetBalanceQrCodeForm balanceQrCodeForm = new WXGetBalanceQrCodeForm(StartUp.UpdateBalance);
balanceQrCodeForm.ShowDialog();
}
private void gridPddBuyerOrderReadCtrl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
//if (Panel.GetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl) < 1)
// Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, 1);
//else
// Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, -1);
}
private void gridPddBuyerOrderReadCtrl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
//if (Panel.GetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl) < 1)
// Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, 1);
//else
// Panel.SetZIndex(MainWindow.Instance.gridPddBuyerOrderReadCtrl, -1);
}
private void gridTitle_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
@@ -571,5 +586,158 @@ namespace _29WxOrderWpf
view.ShowDialog();
}
}
private void bt_showOrderSupport(object sender, RoutedEventArgs e)
{
OpenOrderSupportTicket(_ticketMonitor.LatestNotification);
_ticketMonitor.MarkAllRead();
}
private void StartTicketRealtimeMonitor()
{
try
{
_ticketMonitor.Start(GetTicketOptions().Clone());
UpdateOrderSupportBadge();
}
catch (Exception ex)
{
LogSave.Add("TicketRealtimeStartError:" + ex);
}
}
private TicketClientOptions GetTicketOptions()
{
if (_ticketOptions != null)
return _ticketOptions;
_ticketOptions = new TicketClientOptions
{
UserName = BaseClass.UserName,
AppIds = GlobalConfig.ShopLoginList.Select(a=>a.ShopId).ToList(),
Orders = []
};
//OrderSupportTicketView
// .CreateDefaultOptions(BaseClass.UserName)
// .Clone();
return _ticketOptions;
}
private void OpenOrderSupportTicket(TicketRealtimeNotification notification)
{
var view = EnsureOrderSupportView();
view.ViewModel.ShowHistory();
if (notification != null)
{
view.OpenTicket(
notification.AppId,
notification.WechatOrderId);
}
// DialogService 会复用同类型的已打开窗口,避免重复创建。
DialogService.ShowOrActivateWindow(view);
}
/// <summary>
/// 从订单行打开工单页面。若左侧列表已有相同订单号的工单,直接定位到该工单;
/// 否则在同一个工单页面中进入新建工单状态。
/// </summary>
public async Task CreateOrderSupportTicketAsync(TicketOrderOption order)
{
if (order == null)
return;
var view = EnsureOrderSupportView();
await view.ViewModel.InitializeAsync();
var existing = view.ViewModel.FindTicketByOrderId(order.OrderId);
if (existing != null)
{
view.ViewModel.ApplyOrderInfo(order);
view.OpenTicket(existing.AppId, existing.WechatOrderId);
}
else
{
view.OpenNewTicket(order);
}
DialogService.ShowOrActivateWindow(view);
}
private OrderSupportTicketView EnsureOrderSupportView()
{
if (_orderSupportView == null)
{
_orderSupportView = new OrderSupportTicketView(
new TicketApiService(),
_ticketMonitor.RealtimeService,
GetTicketOptions().Clone(),
ownsRealtime: false);
}
return _orderSupportView;
}
private void OnTicketNewMessageReceived(
object sender,
TicketInboxMessageEventArgs e)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(
() => OnTicketNewMessageReceived(sender, e)));
return;
}
var notification = e?.Notification;
if (notification == null)
return;
UpdateOrderSupportBadge();
}
private void OnTicketUnreadCountChanged(
object sender,
TicketUnreadCountChangedEventArgs e)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(
() => OnTicketUnreadCountChanged(sender, e)));
return;
}
UpdateOrderSupportBadge(e?.UnreadCount ?? 0);
}
private void UpdateOrderSupportBadge()
{
UpdateOrderSupportBadge(_ticketMonitor.UnreadCount);
}
private void UpdateOrderSupportBadge(int unreadCount)
{
if (borderOrderSupportUnread == null)
return;
borderOrderSupportUnread.Visibility = unreadCount > 0
? Visibility.Visible
: Visibility.Collapsed;
tbOrderSupportUnread.Text = unreadCount > 99
? "99+"
: unreadCount.ToString();
btOrderSupport.ToolTip = unreadCount > 0
? $"客户工单({unreadCount} 条未读消息)"
: "客户工单";
}
private void MainWindow_Closed(object sender, EventArgs e)
{
_ticketMonitor.NewMessageReceived -= OnTicketNewMessageReceived;
_ticketMonitor.UnreadCountChanged -= OnTicketUnreadCountChanged;
}
}
}
@@ -1,14 +0,0 @@
<Window
x:Class="_29WxOrderWpf.OrderSupportTicketWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ost="clr-namespace:OrderSupportTicket.Views;assembly=OrderSupportTicket"
Title="OrderSupportTicketWindow"
Width="800"
Height="650"
mc:Ignorable="d">
<ost:OrderSupportTicketView />
</Window>
@@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace _29WxOrderWpf;
/// <summary>
/// OrderSupportTicketWindow.xaml 的交互逻辑
/// </summary>
public partial class OrderSupportTicketWindow : Window
{
public OrderSupportTicketWindow()
{
InitializeComponent();
}
}
@@ -0,0 +1,115 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LaunchDarkly.EventSource
{
internal static class AsyncHelpers
{
// General-purpose timeout logic that uses a timed CancellationToken instead of
// Task.Delay. The cancellationToken that is passed in is for explicitly cancelling
// the operation from elsewhere; DoWithTimeout wraps this to create a token that can
// also be cancelled by a timeout, and passes that token to taskFn.
//
// This method will only work if taskFn actually responds to the cancellation token
// by throwing an OperationCanceledException. If taskFn ignores the token, timeouts
// will not happen.
internal static async Task<T> DoWithTimeout<T>(
TimeSpan timeout,
CancellationToken cancellationToken,
Func<CancellationToken, Task<T>> taskFn
)
{
using (var timeoutCancellation = new CancellationTokenSource(timeout))
{
using (var combinedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCancellation.Token))
{
Task<T> task = null;
try
{
task = taskFn(combinedCancellation.Token);
var result = await task;
return result;
}
catch (AggregateException e) when (e.InnerException is OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
throw e.InnerException;
}
throw new ReadTimeoutException();
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
throw;
}
throw new ReadTimeoutException();
}
}
}
// We never need to call SuppressExceptions(task) in this method, because the act
// of awaiting the task - whether it completes normally or throws an exception -
// makes it "observed". The timeout isn't happening in a different task, it's
// causing this task to throw an OperationCanceledException, after which point it
// can't generate any other exceptions.
}
// Take a task that can't be terminated early with a CancellationToken, and wrap it in a task
// that can. If you cancel the wrapped task, the inner task will still continue, but we're
// assuming that in these situations we'll eventually be closing the underlying socket which
// will terminate any pending I/O.
internal static Task<T> AllowCancellation<T>(Task<T> originalTask, CancellationToken cancellationToken)
{
var cancellableTask = originalTask.ContinueWith(
completedTask => completedTask.GetAwaiter().GetResult(),
cancellationToken,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default
);
// Now, if originalTask either completes successfully or fails with an exception, anyone who
// was waiting on cancellableTask will get the result or the exception.
//
// But if cancellationToken gets cancelled, cancellableTask will stop and throw an
// OperationCancelledException, just as cancellable I/O operation normally would. The
// caller is no longer blocked - even though originalTask may still be executing.
//
// Normally it would be a problem that originalTask could still be hanging around, but
// in our case if things are getting cancelled it means the connection is going to be
// closed soon, causing any lingering I/O tasks that use that connection to fail. We
// just need to use SuppressExceptions to make sure those failures are discarded rather
// than causing an "unobserved exception" situation.
cancellableTask.ContinueWith(
completedTask =>
{
SuppressExceptions(originalTask);
},
TaskContinuationOptions.OnlyOnCanceled
);
return cancellableTask;
}
/// <summary>
/// Adds a continuation to a task so that if the task throws an uncaught exception, the exception
/// is not "unobserved" (which can be a fatal error in some versions of .NET). We must do this
/// whenever we're going to discard a task without awaiting it, if there's any possibility that
/// it could throw an exception.
/// </summary>
/// <param name="task">a task we are not going to await</param>
internal static void SuppressExceptions(Task task)
{
task.ContinueWith(
t =>
{
// Simply accessing the Exception property makes this exception observed.
var e = t.Exception;
},
TaskContinuationOptions.OnlyOnFaulted
);
}
}
}
@@ -0,0 +1,179 @@
using System;
using System.IO;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Internal implementation of a buffered text line parser for UTF-8 data.
/// </summary>
/// <remarks>
/// <para>
/// This is used as follows. 1. The caller puts some data into the byte buffer. 2. The caller
/// calls ScanToEndOfLine; if it returns true, the <c>lineOut</c> parameter is set to point
/// to the content of the line (not including the line ending character(s)). If it returns
/// false, put more data into the buffer and try again.
/// </para>
/// <para>
/// Since in UTF-8 all multi-byte characters use values greater than 127 for all of their
/// bytes, this logic doesn't need to do any UTF-8 decoding or even know how many bytes are
/// in a character; it just looks for the line-ending sequences CR, LF, or CR+LF.
/// </para>
/// </remarks>
internal struct ByteArrayLineScanner
{
private readonly byte[] _buffer;
private readonly int _capacity;
private int _count;
private int _startPos;
public byte[] Buffer => _buffer;
public int Capacity => _capacity;
public int Count => _count;
public int Available => _capacity - _count;
private MemoryStream _partialLine;
public ByteArrayLineScanner(int capacity)
{
_buffer = new byte[capacity];
_capacity = capacity;
_count = 0;
_partialLine = null;
_startPos = 0;
}
/// <summary>
/// The caller calls this method after having already added <c>count</c> more bytes at the
/// end of the buffer. We do it this way instead of having an <c>AddBytes(byte[], int)</c>
/// method because we don't want to allocate a second buffer just to be the destination for
/// a read operation.
/// </summary>
/// <param name="count">number of bytes added</param>
public void AddedBytes(int count)
{
_count += count;
}
/// <summary>
/// Searches for the next line ending and, if successful, provides the line data.
/// </summary>
/// <param name="lineOut">if successful, this is set to point to the bytes for the line
/// <i>not</i> including any CR/LF; whenever possible this is a reference to the underlying
/// buffer, not a copy, so the caller should read/copy it before doing anything else to the
/// buffer</param>
/// <returns>true if a full line was read, false if we need more data first</returns>
public bool ScanToEndOfLine(out Utf8ByteSpan lineOut)
{
if (_startPos == _count)
{
_startPos = _count = 0;
lineOut = new Utf8ByteSpan();
return false;
}
if (_startPos == 0 && _partialLine != null && _partialLine.Position > 0
&& _partialLine.GetBuffer()[_partialLine.Position - 1] == '\r')
{
// This is an edge case where the very last byte we previously saw was a CR, and we didn't know
// whether the next byte would be LF or not, but we had to dump the buffer into _partialLine
// because it was completely full. So, now we can return the line that's already in _partialLine,
// but if the first byte in the buffer is LF we should skip past it.
if (_buffer[_startPos] == '\n')
{
_startPos++;
}
lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0,
(int)_partialLine.Position - 1); // don't include the CR
_partialLine = null;
return true;
}
int startedAt = _startPos, pos = _startPos;
while (pos < _count)
{
var b = _buffer[pos];
if (b == '\n') // LF by itself terminates a line
{
_startPos = pos + 1; // next line will start after the LF
break;
}
if (b == '\r')
{
if (pos < (_count - 1))
{
_startPos = pos + 1; // next line will start after the CR--
if (_buffer[pos + 1] == '\n') // --unless there was an LF right after that
{
_startPos++;
}
break;
}
else
{
// CR by itself and CR+LF are both valid line endings in SSE, so if the very
// last character we saw was a CR, we can't know when the line is fully read
// until we've gotten more data. So we'll need to treat this as an incomplete
// line.
pos++;
break;
}
}
pos++;
}
if (pos == _count) // we didn't find a line terminator
{
lineOut = new Utf8ByteSpan();
if (_count < _capacity)
{
// There's still room in the buffer, so we'll re-scan the line once they add more bytes
return false;
}
// We need to dump the incomplete line into _partialLine so we can make room in the buffer
var partialCount = pos - _startPos;
if (_partialLine is null)
{
_partialLine = new MemoryStream(partialCount);
}
_partialLine.Write(_buffer, _startPos, partialCount);
// Clear the main buffer
_startPos = _count = 0;
return false;
}
if (_partialLine != null && _partialLine.Position > 0)
{
_partialLine.Write(_buffer, startedAt, pos - startedAt);
lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0, (int)_partialLine.Position);
_partialLine = null;
// If there are still bytes in the main buffer, move them over to make more room. It's
// safe for us to do this before the caller has looked at lineOut, because lineOut is now
// a reference to the separate _partialLine buffer, not to the main buffer.
if (_startPos < _count)
{
System.Buffer.BlockCopy(_buffer, _startPos, _buffer, 0, _count - _startPos);
}
_count -= _startPos;
_startPos = 0;
}
else
{
lineOut = new Utf8ByteSpan(_buffer, startedAt, pos - startedAt);
if (_startPos == _count)
{
// If we've scanned all the data in the buffer, reset _startPos and _count to indicate
// that the entire buffer is available for the next read. It's safe for us to do this
// before the caller has looked at lineOut, because we're not actually modifying any
// bytes in the buffer. It's the caller's responsibility not to modify the buffer
// until it has already done whatever needs to be done with the lineOut data.
_startPos = _count = 0;
}
}
return true;
}
}
}
@@ -0,0 +1,28 @@
using System;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Provides data recieved in the EventSource <see cref="EventSource.CommentReceived"/> event.
/// </summary>
/// <seealso cref="System.EventArgs" />
public class CommentReceivedEventArgs : EventArgs
{
/// <summary>
/// Gets the comment received in the Server Sent Event.
/// </summary>
/// <value>
/// The comment.
/// </value>
public string Comment { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CommentReceivedEventArgs"/> class.
/// </summary>
/// <param name="comment">The comment received in the Server Sent Event.</param>
public CommentReceivedEventArgs(string comment)
{
Comment = comment;
}
}
}
@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// An immutable class containing configuration properties for <see cref="EventSource"/>.
/// </summary>
/// <seealso cref="EventSource.EventSource(Configuration)"/>
/// <seealso cref="ConfigurationBuilder"/>
public sealed class Configuration
{
#region Constants
/// <summary>
/// The default value for <see cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/>:
/// one second.
/// </summary>
public static readonly TimeSpan DefaultInitialRetryDelay = TimeSpan.FromSeconds(1);
/// <summary>
/// The default value for <see cref="ConfigurationBuilder.MaxRetryDelay(TimeSpan)"/>:
/// 30 seconds.
/// </summary>
public static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30);
/// <summary>
/// The default value for <see cref="ConfigurationBuilder.ResponseStartTimeout(TimeSpan)"/>:
/// 10 seconds.
/// </summary>
public static readonly TimeSpan DefaultResponseStartTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Obsolete name for <see cref="DefaultResponseStartTimeout"/>.
/// </summary>
[Obsolete("Use DefaultResponseStartTimeout")]
public static readonly TimeSpan DefaultConnectionTimeout = DefaultResponseStartTimeout;
/// <summary>
/// The default value for <see cref="ConfigurationBuilder.ReadTimeout(TimeSpan)"/>:
/// 5 minutes.
/// </summary>
public static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// The default value for <see cref="ConfigurationBuilder.BackoffResetThreshold(TimeSpan)"/>:
/// one minute.
/// </summary>
public static readonly TimeSpan DefaultBackoffResetThreshold = TimeSpan.FromMinutes(1);
/// <summary>
/// The logger name that will be used if you specified a logging implementation but did not
/// provide a specific logger instance.
/// </summary>
/// <seealso cref="ConfigurationBuilder.LogAdapter(ILogAdapter)"/>
public const string DefaultLoggerName = "EventSource";
#endregion
#region Public Properties
/// <summary>
/// The amount of time a connection must stay open before the EventSource resets its backoff delay.
/// </summary>
/// <remarks>
/// A connection that stays open for at least this long also discards any temporary bounds set
/// by <see cref="IEventSource.SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/>.
/// </remarks>
/// <seealso cref="ConfigurationBuilder.BackoffResetThreshold(TimeSpan)"/>
public TimeSpan BackoffResetThreshold { get; }
/// <summary>
/// Obsolete name for <see cref="ResponseStartTimeout"/>.
/// </summary>
[Obsolete("Use ResponseStartTimeout")]
public TimeSpan ConnectionTimeout => ResponseStartTimeout;
/// <summary>
/// The HttpClient that will be used as the HTTP client, or null for a new HttpClient.
/// </summary>
/// <seealso cref="ConfigurationBuilder.HttpClient(HttpClient)"/>
public HttpClient HttpClient { get; }
/// <summary>
/// The HttpMessageHandler that will be used for the HTTP client, or null for the default handler.
/// </summary>
/// <seealso cref="ConfigurationBuilder.HttpMessageHandler(HttpMessageHandler)"/>
public HttpMessageHandler HttpMessageHandler { get; }
/// <summary>
/// Delegate hook invoked before an HTTP request has been performed.
/// </summary>
/// <seealso cref="ConfigurationBuilder.HttpRequestModifier(Action{HttpRequestMessage})"/>
public Action<HttpRequestMessage> HttpRequestModifier { get; }
/// <summary>
/// The initial amount of time to wait before attempting to reconnect to the EventSource API.
/// </summary>
/// <seealso cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/>
public TimeSpan InitialRetryDelay { get; }
/// <summary>
/// Gets the last event identifier.
/// </summary>
/// <seealso cref="ConfigurationBuilder.LastEventId(string)"/>
public string LastEventId { get; }
/// <summary>
/// The logger to be used for all EventSource log output.
/// </summary>
/// <remarks>
/// This is never null; if logging is not configured, it will be <c>LaunchDarkly.Logging.Logs.None</c>.
/// </remarks>
/// <seealso cref="ConfigurationBuilder.Logger(Logger)"/>
/// <summary>
/// The maximum amount of time to wait before attempting to reconnect.
/// </summary>
/// <seealso cref="ConfigurationBuilder.MaxRetryDelay(TimeSpan)"/>
public TimeSpan MaxRetryDelay { get; }
/// <summary>
/// The HTTP method that will be used when connecting to the EventSource API.
/// </summary>
/// <seealso cref="ConfigurationBuilder.Method(HttpMethod)"/>
public HttpMethod Method { get; }
/// <summary>
/// Whether to use UTF-8 byte arrays internally if possible when reading the stream.
/// </summary>
/// <seealso cref="ConfigurationBuilder.PreferDataAsUtf8Bytes(bool)"/>
public bool PreferDataAsUtf8Bytes { get; }
/// <summary>
/// The timeout when reading from the EventSource API.
/// </summary>
/// <seealso cref="ConfigurationBuilder.ReadTimeout(TimeSpan)"/>
public TimeSpan ReadTimeout { get; }
/// <summary>
/// A factory for HTTP request body content, if the HTTP method is one that allows a request body.
/// is one that allows a request body. This is in the form of a factory function because the request
/// may need to be sent more than once.
/// </summary>
/// <seealso cref="ConfigurationBuilder.RequestBodyFactory(Func{HttpContent})"/>
public Func<HttpContent> RequestBodyFactory { get; }
/// <summary>
/// The request headers to be sent with each EventSource HTTP request.
/// </summary>
/// <seealso cref="ConfigurationBuilder.RequestHeader(string, string)"/>
/// <seealso cref="ConfigurationBuilder.RequestHeaders(IDictionary{string, string})"/>
public IDictionary<string, string> RequestHeaders { get; }
/// <summary>
/// The maximum amount of time to wait between starting an HTTP request and receiving the response
/// headers.
/// </summary>
/// <seealso cref="ConfigurationBuilder.ResponseStartTimeout(TimeSpan)"/>
public TimeSpan ResponseStartTimeout { get; }
/// <summary>
/// Gets the <see cref="System.Uri"/> used when connecting to an EventSource API.
/// </summary>
public Uri Uri { get; }
#endregion
#region Internal Constructor
internal Configuration(ConfigurationBuilder builder)
{
Uri = builder._uri;
BackoffResetThreshold = builder._backoffResetThreshold;
HttpClient = builder._httpClient;
HttpMessageHandler = (builder._httpClient != null) ? null : builder._httpMessageHandler;
InitialRetryDelay = builder._initialRetryDelay;
LastEventId = builder._lastEventId;
MaxRetryDelay = builder._maxRetryDelay;
Method = builder._method;
PreferDataAsUtf8Bytes = builder._preferDataAsUtf8Bytes;
ReadTimeout = builder._readTimeout;
RequestHeaders = new Dictionary<string, string>(builder._requestHeaders);
ResponseStartTimeout = builder._responseStartTimeout;
RequestBodyFactory = builder._requestBodyFactory;
HttpRequestModifier = builder._httpRequestModifier;
}
#endregion
#region Public Methods
/// <summary>
/// Provides a new <see cref="ConfigurationBuilder"/> for constructing a configuration.
/// </summary>
/// <param name="uri">the EventSource URI</param>
/// <returns>a new builder instance</returns>
/// <exception cref="ArgumentNullException">if the URI is null</exception>
public static ConfigurationBuilder Builder(Uri uri) =>
new ConfigurationBuilder(uri);
#endregion
}
}
@@ -0,0 +1,448 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// A standard Builder pattern for constructing a <see cref="Configuration"/> instance.
/// </summary>
/// <remarks>
/// <para>
/// Initialize a builder by calling <c>new ConfigurationBuilder(uri)</c> or
/// <c>Configuration.Builder(uri)</c>. The URI is always required; all other properties
/// are set to defaults. Use the builder's setter methods to modify any desired properties;
/// setter methods can be chained. Then call <c>Build()</c> to construct the final immutable
/// <c>Configuration</c>.
/// </para>
/// <para>
/// All setter methods will throw <c>ArgumentException</c> if called with an invalid value,
/// so it is never possible for <c>Build()</c> to fail.
/// </para>
/// </remarks>
public class ConfigurationBuilder
{
#region Private Fields
internal readonly Uri _uri;
internal TimeSpan _initialRetryDelay = Configuration.DefaultInitialRetryDelay;
internal TimeSpan _backoffResetThreshold = Configuration.DefaultBackoffResetThreshold;
internal string _lastEventId;
internal IDictionary<string, string> _requestHeaders = new Dictionary<string, string>();
internal HttpMessageHandler _httpMessageHandler;
internal HttpClient _httpClient;
internal TimeSpan _maxRetryDelay = Configuration.DefaultMaxRetryDelay;
internal HttpMethod _method = HttpMethod.Get;
internal bool _preferDataAsUtf8Bytes = false;
internal TimeSpan _readTimeout = Configuration.DefaultReadTimeout;
internal Func<HttpContent> _requestBodyFactory;
internal TimeSpan _responseStartTimeout = Configuration.DefaultResponseStartTimeout;
internal Action<HttpRequestMessage> _httpRequestModifier;
#endregion
#region Constructor
internal ConfigurationBuilder(Uri uri)
{
if (uri == null)
{
throw new ArgumentNullException(nameof(uri));
}
this._uri = uri;
}
#endregion
#region Public Methods
/// <summary>
/// Constructs a <see cref="Configuration"/> instance based on the current builder properies.
/// </summary>
/// <returns>the configuration</returns>
public Configuration Build() =>
new Configuration(this);
/// <summary>
/// Obsolete name for <see cref="ResponseStartTimeout(TimeSpan)"/>.
/// </summary>
/// <param name="responseStartTimeout">the timeout</param>
/// <returns>the builder</returns>
[Obsolete("Use ResponseStartTimeout")]
public ConfigurationBuilder ConnectionTimeout(TimeSpan responseStartTimeout) =>
ResponseStartTimeout(responseStartTimeout);
/// <summary>
/// Sets a delegate hook invoked before an HTTP request is performed. This may be useful if you
/// want to modify some properties of the request that EventSource doesn't already have an option for.
/// </summary>
/// <param name="httpRequestModifier">code that will be called with the request before it is sent</param>
/// <returns>the builder</returns>
public ConfigurationBuilder HttpRequestModifier(Action<HttpRequestMessage> httpRequestModifier)
{
this._httpRequestModifier = httpRequestModifier;
return this;
}
/// <summary>
/// Sets the initial amount of time to wait before attempting to reconnect to the EventSource API.
/// </summary>
/// <remarks>
/// <para>
/// If the connection fails more than once, the retry delay will increase from this value using
/// a backoff algorithm.
/// </para>
/// <para>
/// A server-directed reconnection time received via the SSE <c>retry:</c> field supersedes
/// this value, as do bounds installed by
/// <see cref="IEventSource.SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/>.
/// </para>
/// <para>
/// The default value is <see cref="Configuration.DefaultInitialRetryDelay"/>. Negative values
/// are changed to zero.
/// </para>
/// <para>
/// The actual duration of each delay will vary slightly because there is a random jitter
/// factor to avoid clients all reconnecting at once.
/// </para>
/// </remarks>
/// <param name="initialRetryDelay">the initial retry delay</param>
/// <returns>the builder</returns>
/// <seealso cref="MaxRetryDelay(TimeSpan)"/>
public ConfigurationBuilder InitialRetryDelay(TimeSpan initialRetryDelay)
{
_initialRetryDelay = FiniteTimeSpan(initialRetryDelay);
return this;
}
/// <summary>
/// Sets the maximum amount of time to wait before attempting to reconnect.
/// </summary>
/// <remarks>
/// <para>
/// <c>EventSource</c> uses an exponential backoff algorithm (with random jitter) so that
/// the delay between reconnections starts at <see cref="InitialRetryDelay(TimeSpan)"/> but
/// increases with each subsequent attempt. <c>MaxRetryDelay</c> sets a limit on how long
/// the delay can be.
/// </para>
/// <para>
/// The default value is <see cref="Configuration.DefaultMaxRetryDelay"/>. Negative values
/// are changed to zero.
/// </para>
/// </remarks>
/// <param name="maxRetryDelay">the maximum retry delay</param>
/// <returns>the builder</returns>
/// <seealso cref="InitialRetryDelay(TimeSpan)"/>
public ConfigurationBuilder MaxRetryDelay(TimeSpan maxRetryDelay)
{
_maxRetryDelay = FiniteTimeSpan(maxRetryDelay);
return this;
}
/// <summary>
/// Sets the amount of time a connection must stay open before the EventSource resets its backoff delay.
/// </summary>
/// <remarks>
/// <para>
/// If a connection fails before the threshold has elapsed, the delay before reconnecting will be greater
/// than the last delay; if it fails after the threshold, the delay will start over at the initial minimum
/// value. This prevents long delays from occurring on connections that are only rarely restarted.
/// </para>
/// <para>
/// A connection that stays open for at least this long also discards any bounds set by
/// <see cref="IEventSource.SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/>, restoring
/// <see cref="InitialRetryDelay(TimeSpan)"/> and <see cref="MaxRetryDelay(TimeSpan)"/>. A
/// server-directed reconnection time received via the SSE <c>retry:</c> field is not
/// discarded.
/// </para>
/// <para>
/// The default value is <see cref="Configuration.DefaultBackoffResetThreshold"/>. Negative
/// values are changed to zero.
/// </para>
/// </remarks>
/// <param name="backoffResetThreshold">the threshold time</param>
/// <returns>the builder</returns>
public ConfigurationBuilder BackoffResetThreshold(TimeSpan backoffResetThreshold)
{
_backoffResetThreshold = FiniteTimeSpan(backoffResetThreshold);
return this;
}
/// <summary>
/// Sets the maximum amount of time EventSource will wait between starting an HTTP request and
/// receiving the response headers.
/// </summary>
/// <remarks>
/// <para>
/// This is the same as the <c>Timeout</c> property in .NET's <c>HttpClient</c>. The default value is
/// <see cref="Configuration.DefaultConnectionTimeout"/>.
/// </para>
/// <para>
/// It is <i>not</i> the same as a TCP connection timeout. A connection timeout would include only the
/// time of establishing the connection, not the time it takes for the server to prepare the beginning
/// of the response. .NET does not consistently support a connection timeout, but if you are using .NET
/// Core or .NET 5+ you can implement it by using <c>SocketsHttpHandler</c> as your
/// <see cref="HttpMessageHandler(System.Net.Http.HttpMessageHandler)"/> and setting the
/// <c>ConnectTimeout</c> property there.
/// </para>
/// </remarks>
/// <param name="responseStartTimeout">the timeout</param>
/// <returns></returns>
public ConfigurationBuilder ResponseStartTimeout(TimeSpan responseStartTimeout)
{
_responseStartTimeout = TimeSpanCanBeInfinite(responseStartTimeout);
return this;
}
/// <summary>
/// Sets the timeout when reading from the EventSource API.
/// </summary>
/// <remarks>
/// <para>
/// The connection will be automatically dropped and restarted if the server sends no data within
/// this interval. This prevents keeping a stale connection that may no longer be working. It is common
/// for SSE servers to send a simple comment line (":") as a heartbeat to prevent timeouts.
/// </para>
/// <para>
/// The default value is <see cref="Configuration.DefaultReadTimeout"/>.
/// </para>
/// </remarks>
public ConfigurationBuilder ReadTimeout(TimeSpan readTimeout)
{
_readTimeout = TimeSpanCanBeInfinite(readTimeout);
return this;
}
/// <summary>
/// Sets the last event identifier.
/// </summary>
/// <remarks>
/// Setting this value will cause EventSource to add a "Last-Event-ID" header in its HTTP request.
/// This normally corresponds to the <see cref="MessageEvent.LastEventId"/> field of a previously
/// received event.
/// </remarks>
/// <param name="lastEventId">the event identifier</param>
/// <returns>the builder</returns>
public ConfigurationBuilder LastEventId(string lastEventId)
{
_lastEventId = lastEventId;
return this;
}
/// <summary>
/// Sets the logging implementation to be used for all EventSource log output.
/// </summary>
/// <remarks>
/// <para>
/// This uses the <c>ILogAdapter</c> abstraction from the <c>LaunchDarkly.Logging</c> library,
/// which provides several basic implementations such as <c>Logs.ToConsole</c> and an integration
/// with the .NET Core logging framework. For more about this and about adapters to other logging
/// frameworks, see <a href="https://github.com/launchdarkly/dotnet-logging"><c>LaunchDarkly.Logging</c></a>.
/// </para>
/// <para>
/// <c>LaunchDarkly.Logging</c> defines logging levels of Debug, Info, Warn, and Error. If you do not
/// want detailed Debug-level logging, use the <c>Level()</c> modifier to set a minimum level of Info
/// or above, as shown in the code example (unless you are using an adapter to another logging
/// framework that has its own way of doing log filtering).
/// </para>
/// <para>
/// Log messages will use <see cref="Configuration.DefaultLoggerName"/> as the logger name. If you
/// want to specify a different logger name, use <see cref="Logger(Logging.Logger)"/>.
/// </para>
/// <para>
/// If you don't specify <see cref="LogAdapter(ILogAdapter)"/> or <see cref="Logger(Logging.Logger)"/>,
/// EventSource will not do any logging.
/// </para>
/// </remarks>
/// <example>
/// using LaunchDarkly.Logging;
///
/// // Send log output to the console (standard error), suppressing Debug messages
/// var config = new ConfigurationBuilder(uri).
/// LogAdapter(Logs.ToConsole.Level(LogLevel.Info)).
/// Build();
/// </example>
/// <param name="logAdapter">a <c>LaunchDarkly.Logging.ILogAdapter</c></param>
/// <returns>the builder</returns>
public ConfigurationBuilder LogAdapter()
{
return this;
}
/// <summary>
/// Sets a custom logger to be used for all EventSource log output.
/// </summary>
/// <remarks>
/// <para>
/// This uses the <c>Logger</c> type from the <c>LaunchDarkly.Logging</c> library,
/// which provides several basic implementations such as <c>Logs.ToConsole</c> and an integration
/// with the .NET Core logging framework. For more about this and about adapters to other logging
/// frameworks, see <a href="https://github.com/launchdarkly/dotnet-logging"><c>LaunchDarkly.Logging</c></a>.
/// </para>
/// <para>
/// If you don't specify <see cref="LogAdapter(ILogAdapter)"/> or <see cref="Logger(Logging.Logger)"/>,
/// EventSource will not do any logging.
/// </para>
/// </remarks>
/// <example>
/// using LaunchDarkly.Logging;
///
/// // Define a logger that sends output to the console (standard output), suppressing
/// // Debug messages, and using a logger name of "EventStream"
/// var logger = Logs.ToConsole.Level(LogLevel.Info).Logger("EventStream");
/// var config = new ConfigurationBuilder(uri).
/// Logger(logger).
/// Build();
/// </example>
/// <param name="logger">a <c>LaunchDarkly.Logging.Logger</c> instance</param>
/// <returns>the builder</returns>
public ConfigurationBuilder Logger()
{
return this;
}
/// <summary>
/// Specifies whether to use UTF-8 byte arrays internally if possible when
/// reading the stream.
/// </summary>
/// <remarks>
/// As described in <see cref="MessageEvent"/>, in some applications it may be
/// preferable to store and process event data as UTF-8 byte arrays rather than
/// strings. By default, <c>EventSource</c> will use the <c>string</c> type when
/// processing the event stream; if you then use <see cref="MessageEvent.DataUtf8Bytes"/>
/// to get the data, it will be converted to a byte array as needed. However, if
/// you set <c>PreferDataAsUtf8Bytes</c> to <see langword="true"/>, the event data
/// will be stored internally as a UTF-8 byte array so that if you read
/// <see cref="MessageEvent.DataUtf8Bytes"/>, you will get the same array with no
/// extra copying or conversion. Therefore, for greatest efficiency you should set
/// this to <see langword="true"/> if you intend to process the data as UTF-8. Note
/// that Server-Sent Event streams always use UTF-8 encoding, as required by the
/// SSE specification.
/// </remarks>
/// <param name="preferDataAsUtf8Bytes">true if you intend to request the event
/// data as UTF-8 bytes</param>
/// <returns>the builder</returns>
public ConfigurationBuilder PreferDataAsUtf8Bytes(bool preferDataAsUtf8Bytes)
{
_preferDataAsUtf8Bytes = preferDataAsUtf8Bytes;
return this;
}
/// <summary>
/// Sets the request headers to be sent with each EventSource HTTP request.
/// </summary>
/// <param name="headers">the headers (null is equivalent to an empty dictionary)</param>
/// <returns>the builder</returns>
public ConfigurationBuilder RequestHeaders(IDictionary<string, string> headers)
{
_requestHeaders = headers is null ? new Dictionary<string, string>() :
new Dictionary<string, string>(headers);
return this;
}
/// <summary>
/// Adds a request header to be sent with each EventSource HTTP request.
/// </summary>
/// <param name="name">the header name</param>
/// <param name="value">the header value </param>
/// <returns>the builder</returns>
public ConfigurationBuilder RequestHeader(string name, string value)
{
if (name != null)
{
_requestHeaders[name] = value;
}
return this;
}
/// <summary>
/// Sets the <c>HttpMessageHandler</c> that will be used for the HTTP client, or null for the default handler.
/// </summary>
/// <remarks>
/// If you have specified a custom HTTP client instance with <see cref="HttpClient"/>, then
/// <see cref="HttpMessageHandler(HttpMessageHandler)"/> is ignored.
/// </remarks>
/// <param name="handler">the message handler implementation</param>
/// <returns>the builder</returns>
public ConfigurationBuilder HttpMessageHandler(HttpMessageHandler handler)
{
this._httpMessageHandler = handler;
return this;
}
/// <summary>
/// Specifies that EventSource should use a specific HttpClient instance for HTTP requests.
/// </summary>
/// <remarks>
/// <para>
/// Normally, EventSource creates its own HttpClient and disposes of it when you dispose of the
/// EventSource. If you provide your own HttpClient using this method, you are responsible for
/// managing the HttpClient's lifecycle-- EventSource will not dispose of it.
/// </para>
/// <para>
/// EventSource will not modify this client's properties, so if you call <see cref="HttpMessageHandler"/>
/// or <see cref="ConnectionTimeout"/>, those methods will be ignored.
/// </para>
/// </remarks>
/// <param name="client">an HttpClient instance, or null to use the default behavior</param>
/// <returns>the builder</returns>
public ConfigurationBuilder HttpClient(HttpClient client)
{
this._httpClient = client;
return this;
}
/// <summary>
/// Sets the HTTP method that will be used when connecting to the EventSource API.
/// </summary>
/// <remarks>
/// By default, this is <see cref="HttpMethod.Get"/>.
/// </remarks>
public ConfigurationBuilder Method(HttpMethod method)
{
this._method = method ?? throw new ArgumentNullException(nameof(method));
return this;
}
/// <summary>
/// Sets a factory for HTTP request body content, if the HTTP method is one that allows a request body.
/// </summary>
/// <remarks>
/// This is in the form of a factory function because the request may need to be sent more than once.
/// </remarks>
/// <param name="factory">the factory function, or null for none</param>
/// <returns>the builder</returns>
public ConfigurationBuilder RequestBodyFactory(Func<HttpContent> factory)
{
this._requestBodyFactory = factory;
return this;
}
/// <summary>
/// Equivalent <see cref="RequestBodyFactory(Func{HttpContent})"/>, but for content
/// that is a simple string.
/// </summary>
/// <param name="bodyString">the content</param>
/// <param name="contentType">the Content-Type header</param>
/// <returns>the builder</returns>
public ConfigurationBuilder RequestBody(string bodyString, string contentType)
{
return RequestBodyFactory(() => new StringContent(bodyString, Encoding.UTF8, contentType));
}
#endregion
#region Private methods
// Canonicalizes the value so all negative numbers become InfiniteTimeSpan
private static TimeSpan TimeSpanCanBeInfinite(TimeSpan t) =>
t < TimeSpan.Zero ? Timeout.InfiniteTimeSpan : t;
// Replaces all negative times with zero
private static TimeSpan FiniteTimeSpan(TimeSpan t) =>
t < TimeSpan.Zero ? TimeSpan.Zero : t;
#endregion
}
}
@@ -0,0 +1,53 @@
namespace LaunchDarkly.EventSource
{
/// <summary>
/// An internal class used to hold static values used when processing Server Sent Events.
/// </summary>
internal static class Constants
{
/// <summary>
/// The HTTP header name for Accept.
/// </summary>
internal static string AcceptHttpHeader = "Accept";
/// <summary>
/// The HTTP header name for the last event identifier.
/// </summary>
internal static string LastEventIdHttpHeader = "Last-Event-ID";
/// <summary>
/// The HTTP header value for the Content Type.
/// </summary>
internal static string EventStreamContentType = "text/event-stream";
/// <summary>
/// The event type name for a Retry in a Server Sent Event.
/// </summary>
internal static string RetryField = "retry";
/// <summary>
/// The largest reconnection time honored from a Server Sent Event retry field.
/// </summary>
internal const long MaxServerDirectedRetryDelayMillis = 3_600_000;
/// <summary>
/// The identifier field name in a Server Sent Event.
/// </summary>
internal static string IdField = "id";
/// <summary>
/// The event type field name in a Server Sent Event.
/// </summary>
internal static string EventField = "event";
/// <summary>
/// The data field name in a Server Sent Event.
/// </summary>
internal static string DataField = "data";
/// <summary>
/// The message field name in a Server Sent Event.
/// </summary>
internal static string MessageField = "message";
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// An internal class containing helper methods to parse Server Sent Event data.
/// </summary>
internal static class EventParser
{
/// <summary>
/// The result returned by <see cref="ParseLineString(string)"/> or <see cref="ParseLineUtf8Bytes(Utf8ByteSpan)"/>.
/// </summary>
internal struct Result
{
internal string FieldName { get; set; } // null means it's a comment
internal string ValueString { get; set; }
internal Utf8ByteSpan ValueBytes { get; set; }
internal string GetValueAsString() => ValueString is null ? ValueBytes.GetString() : ValueString;
internal bool IsComment => FieldName is null;
internal bool IsDataField => FieldName == "data";
internal bool IsEventField => FieldName == "event";
internal bool IsIdField => FieldName == "id";
internal bool IsRetryField => FieldName == "retry";
}
/// <summary>
/// Attempts to parse a single non-empty line of SSE content that was read as a string. Empty lines
/// should not be not passed to this method.
/// </summary>
/// <param name="line">a line that was read from the stream, not including any trailing CR/LF</param>
/// <returns>a <see cref="Result"/> containing the parsed field or comment; <c>ValueString</c> will
/// be set rather than <c>ValueBytes</c></returns>
internal static Result ParseLineString(string line)
{
var colonPos = line.IndexOf(':');
if (colonPos == 0) // comment
{
return new Result { ValueString = line };
}
if (colonPos < 0) // field name without a value - assume empty value
{
return new Result { FieldName = line, ValueString = "" };
}
int valuePos = colonPos + 1;
if (valuePos < line.Length && line[valuePos] == ' ')
{
valuePos++; // trim a single leading space from the value, if present
}
return new Result
{
FieldName = line.Substring(0, colonPos),
ValueString = line.Substring(valuePos)
};
}
/// <summary>
/// Attempts to parse a single non-empty line of SSE content that was read as UTF-8 bytes. Empty lines
/// should not be not passed to this method.
/// </summary>
/// <param name="line">a line that was read from the stream, not including any trailing CR/LF</param>
/// <returns>a <see cref="Result"/> containing the parsed field or comment; <c>ValueBytes</c>
/// will be set rather than <c>ValueString</c></returns>
public static Result ParseLineUtf8Bytes(Utf8ByteSpan line)
{
if (line.Length > 0 && line.Data[line.Offset] == ':') // comment
{
return new Result { ValueBytes = line };
}
int colonPos = 0;
for (; colonPos < line.Length && line.Data[line.Offset + colonPos] != ':'; colonPos++) { }
string fieldName = Encoding.UTF8.GetString(line.Data, line.Offset, colonPos);
if (colonPos == line.Length) // field name without a value - assume empty value
{
return new Result {
FieldName = fieldName,
ValueBytes = new Utf8ByteSpan()
};
}
int valuePos = colonPos + 1;
if (valuePos < line.Length && line.Data[line.Offset + valuePos] == ' ')
{
valuePos++; // trim a single leading space from the value, if present
}
return new Result
{
FieldName = fieldName,
ValueBytes = new Utf8ByteSpan(line.Data, line.Offset + valuePos, line.Length - valuePos)
};
}
}
}
@@ -0,0 +1,508 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Provides an EventSource client for consuming Server-Sent Events. Additional details on the Server-Sent Events spec
/// can be found at https://html.spec.whatwg.org/multipage/server-sent-events.html
/// </summary>
public class EventSource : IEventSource, IDisposable
{
#region Private Fields
private readonly Configuration _configuration;
private readonly HttpClient _httpClient;
private List<string> _eventDataStringBuffer;
private MemoryStream _eventDataUtf8ByteBuffer;
private string _eventName;
private string _lastEventId;
private readonly ExponentialBackoffWithDecorrelation _backOff;
private readonly TimeSpan _configuredInitialRetryDelay;
private readonly TimeSpan _configuredMaxRetryDelay;
private CancellationTokenSource _currentRequestToken;
private readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource();
private static readonly TimeSpan MaxSleepTime = TimeSpan.FromMilliseconds(int.MaxValue);
private readonly Stopwatch _connectionTimer = new Stopwatch();
private ReadyState _readyState;
#endregion
#region Public Events
/// <inheritdoc/>
public event EventHandler<StateChangedEventArgs> Opened;
/// <inheritdoc/>
public event EventHandler<StateChangedEventArgs> Closed;
/// <inheritdoc/>
public event EventHandler<MessageReceivedEventArgs> MessageReceived;
/// <inheritdoc/>
public event EventHandler<CommentReceivedEventArgs> CommentReceived;
/// <inheritdoc/>
public event EventHandler<ExceptionEventArgs> Error;
#endregion Public Events
#region Public Properties
/// <inheritdoc/>
public ReadyState ReadyState
{
get
{
lock (this)
{
return _readyState;
}
}
private set
{
lock (this)
{
_readyState = value;
}
}
}
internal TimeSpan BackOffDelay
{
get;
private set;
}
// Exposed for tests, so that retry-bounds behavior can be asserted directly
internal ExponentialBackoffWithDecorrelation BackOff => _backOff;
#endregion
#region Public Constructors
/// <summary>
/// Initializes a new instance of the <see cref="EventSource" /> class.
/// </summary>
/// <param name="configuration">the configuration</param>
public EventSource(Configuration configuration)
{
_readyState = ReadyState.Raw;
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_configuredInitialRetryDelay = _configuration.InitialRetryDelay;
_configuredMaxRetryDelay = _configuration.MaxRetryDelay;
_backOff = new ExponentialBackoffWithDecorrelation(_configuredInitialRetryDelay,
_configuredMaxRetryDelay);
_httpClient = _configuration.HttpClient ?? CreateHttpClient();
}
/// <summary>
/// Shortcut for initializing an <see cref="EventSource"/> with only a stream URI
/// and no custom properties.
/// </summary>
/// <param name="uri">the stream URI</param>
/// <exception cref="ArgumentNullException">if the URI is null</exception>
public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {}
#endregion
#region Public Methods
/// <inheritdoc/>
public async Task StartAsync()
{
bool firstTime = true;
while (ReadyState != ReadyState.Shutdown)
{
if (!firstTime)
{
if (_connectionTimer.IsRunning)
{
if (_connectionTimer.Elapsed >= _configuration.BackoffResetThreshold)
{
// Sustained healthy operation reverts any temporary bounds and resets
// n. Reverting takes precedence over bounds that were
// set during the preceding fault window.
ClearTemporaryRetryDelayBounds();
_backOff.ResetBackoffN();
}
_connectionTimer.Reset();
}
await MaybeWaitWithBackOff();
}
firstTime = false;
CancellationTokenSource newRequestTokenSource = null;
CancellationToken cancellationToken;
lock (this)
{
if (_readyState == ReadyState.Shutdown)
{
// in case Close() was called in between the previous ReadyState check and the creation of the new token
return;
}
newRequestTokenSource = new CancellationTokenSource();
_currentRequestToken?.Dispose();
_currentRequestToken = newRequestTokenSource;
}
if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open)
{
throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState));
}
SetReadyState(ReadyState.Connecting);
cancellationToken = newRequestTokenSource.Token;
try
{
await ConnectToEventSourceAsync(cancellationToken);
// ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled.
Close(ReadyState.Closed);
}
catch (Exception e)
{
CancelCurrentRequest();
// If the user called Close(), ReadyState = Shutdown, so errors are irrelevant.
if (ReadyState != ReadyState.Shutdown)
{
Exception realException = e;
if (realException is AggregateException ae && ae.InnerExceptions.Count == 1)
{
realException = ae.InnerException;
}
if (realException is OperationCanceledException oe)
{
// This exception could either be the result of us explicitly cancelling a request, in which case we don't
// need to do anything else, or it could be that the request timed out.
if (oe.CancellationToken.IsCancellationRequested)
{
realException = null;
}
else
{
realException = new TimeoutException();
}
}
if (realException != null)
{
OnError(new ExceptionEventArgs(realException));
}
Close(ReadyState.Closed);
}
}
}
}
private async Task MaybeWaitWithBackOff() {
TimeSpan sleepTime = _backOff.GetNextBackOff();
if (sleepTime > MaxSleepTime)
{
// Task.Delay throws for anything above the platform timer ceiling, and this method
// runs outside the reconnect loop's exception handling, so an unclamped value
// would fault StartAsync and stop the stream permanently.
sleepTime = MaxSleepTime;
}
if (sleepTime > TimeSpan.Zero)
{
BackOffDelay = sleepTime;
try
{
await Task.Delay(sleepTime, _shutdownTokenSource.Token);
}
catch (OperationCanceledException)
{
// Cancellation happened during the wait, likely intentional close
}
}
}
/// <inheritdoc/>
public void Restart(bool resetBackoffDelay)
{
lock (this)
{
if (_readyState != ReadyState.Open)
{
return;
}
if (resetBackoffDelay)
{
_backOff.ResetBackoffN();
}
}
CancelCurrentRequest();
}
/// <inheritdoc/>
public void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay)
{
_backOff.SetBounds(initialDelay, maxDelay);
}
/// <inheritdoc/>
public void ClearTemporaryRetryDelayBounds()
{
_backOff.SetBounds(_configuredInitialRetryDelay, _configuredMaxRetryDelay);
}
/// <summary>
/// Closes the connection to the SSE server. The <c>EventSource</c> cannot be reopened after this.
/// </summary>
public void Close()
{
if (ReadyState != ReadyState.Shutdown)
{
Close(ReadyState.Shutdown);
}
CancelCurrentRequest();
_shutdownTokenSource.Cancel();
// do not dispose httpClient if it is user provided
if (_configuration.HttpClient == null)
{
_httpClient.Dispose();
}
}
/// <summary>
/// Equivalent to calling <see cref="Close()"/>.
/// </summary>
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (disposing)
{
Close();
}
}
#endregion
#region Private Methods
private HttpClient CreateHttpClient()
{
var client =_configuration.HttpMessageHandler is null ?
new HttpClient() :
new HttpClient(_configuration.HttpMessageHandler, false);
client.Timeout = _configuration.ResponseStartTimeout;
return client;
}
private void CancelCurrentRequest()
{
CancellationTokenSource requestTokenSource = null;
lock (this)
{
requestTokenSource = _currentRequestToken;
_currentRequestToken = null;
}
if (requestTokenSource != null)
{
requestTokenSource.Cancel();
requestTokenSource.Dispose();
}
}
internal virtual EventSourceService GetEventSourceService(Configuration configuration)
{
return new EventSourceService(configuration, _httpClient);
}
private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken)
{
_eventDataStringBuffer = null;
_eventDataUtf8ByteBuffer = null;
var svc = GetEventSourceService(_configuration);
svc.ConnectionOpened += (o, e) => {
_connectionTimer.Restart();
SetReadyState(ReadyState.Open, OnOpened, e.Headers);
};
svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); };
await svc.GetDataAsync(
ProcessResponseLineString,
ProcessResponseLineUtf8,
_lastEventId,
cancellationToken
);
}
private void Close(ReadyState state)
{
SetReadyState(state, OnClosed);
}
private void ProcessResponseLineString(string content)
{
if (content == null)
{
// StreamReader may emit a null if the stream has been closed; there's nothing to
// be done at this level in that case
return;
}
if (content.Length == 0)
{
DispatchEvent();
}
else
{
HandleParsedLine(EventParser.ParseLineString(content));
}
}
private void ProcessResponseLineUtf8(Utf8ByteSpan content)
{
if (content.Length == 0)
{
DispatchEvent();
}
else
{
HandleParsedLine(EventParser.ParseLineUtf8Bytes(content));
}
}
private void SetReadyState(ReadyState state, Action<StateChangedEventArgs> action = null,
IEnumerable<KeyValuePair<string, IEnumerable<string>>> headers = null)
{
lock (this)
{
if (_readyState == state || _readyState == ReadyState.Shutdown)
{
return;
}
_readyState = state;
}
if (action != null)
{
action(new StateChangedEventArgs(state, headers));
}
}
private void HandleParsedLine(EventParser.Result result)
{
if (result.IsComment)
{
OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString()));
}
else if (result.IsDataField)
{
if (result.ValueString != null)
{
if (_eventDataStringBuffer is null)
{
_eventDataStringBuffer = new List<string>(2);
}
_eventDataStringBuffer.Add(result.ValueString);
_eventDataStringBuffer.Add("\n");
}
else
{
if (_eventDataUtf8ByteBuffer is null)
{
_eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1);
}
_eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length);
_eventDataUtf8ByteBuffer.WriteByte((byte)'\n');
}
}
else if (result.IsEventField)
{
_eventName = result.GetValueAsString();
}
else if (result.IsIdField)
{
_lastEventId = result.GetValueAsString();
}
else if (result.IsRetryField)
{
if (long.TryParse(result.GetValueAsString(), out var retry))
{
// Clamp while the value is still an integer.
var millis = Math.Max(0,
Math.Min(retry, Constants.MaxServerDirectedRetryDelayMillis));
_backOff.SetServerDirectedMinDelay(TimeSpan.FromMilliseconds(millis));
}
}
}
private void DispatchEvent()
{
var name = _eventName ?? Constants.MessageField;
_eventName = null;
MessageEvent message;
if (_eventDataStringBuffer != null)
{
if (_eventDataStringBuffer.Count == 0)
{
return;
}
// remove last item which is always a trailing newline
_eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1);
var dataString = string.Concat(_eventDataStringBuffer);
message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri);
_eventDataStringBuffer.Clear();
}
else
{
if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0)
{
return;
}
var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0,
(int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline
message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri);
// We've now taken ownership of the original buffer; null out the previous
// reference to it so a new one will be created next time
_eventDataUtf8ByteBuffer = null;
}
OnMessageReceived(new MessageReceivedEventArgs(message));
}
private void OnOpened(StateChangedEventArgs e) =>
Opened?.Invoke(this, e);
private void OnClosed(StateChangedEventArgs e) =>
Closed?.Invoke(this, e);
private void OnMessageReceived(MessageReceivedEventArgs e) =>
MessageReceived?.Invoke(this, e);
private void OnCommentReceived(CommentReceivedEventArgs e) =>
CommentReceived?.Invoke(this, e);
private void OnError(ExceptionEventArgs e) =>
Error?.Invoke(this, e);
#endregion
}
}
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Event arguments for the EventSourceService opened event.
/// </summary>
internal class EventSourceOpenedEventArgs: EventArgs
{
/// <summary>
/// Construct new event arguments.
/// </summary>
/// <param name="headers">headers from the HTTP response</param>
public EventSourceOpenedEventArgs(IEnumerable<KeyValuePair<string, IEnumerable<string>>> headers)
{
Headers = headers;
}
/// <summary>
/// Response headers from the underlying HTTP request.
/// </summary>
public IEnumerable<KeyValuePair<string,IEnumerable<string>>> Headers { get; }
}
}
@@ -0,0 +1,281 @@
using System;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using static LaunchDarkly.EventSource.AsyncHelpers;
namespace LaunchDarkly.EventSource
{
internal class EventSourceService
{
#region Private Fields
// Must be >= 4096 to bypass Xamarin.Android.Net.AndroidMessageHandler's
// BufferedStream-4096 small-count code path, which stalls SSE reads for
// ~60 s waiting for the next server keepalive. See SDK-2755.
private const int ReadBufferSize = 8192;
private readonly Configuration _configuration;
private readonly HttpClient _httpClient;
private const string UserAgentProduct = "DotNetClient";
internal static readonly string UserAgentVersion = ((AssemblyInformationalVersionAttribute)typeof(EventSource)
.GetTypeInfo()
.Assembly
.GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute)))
.InformationalVersion;
#endregion
#region Public Events
/// <summary>
/// Occurs when the connection to the EventSource API has been opened.
/// </summary>
public event EventHandler<EventSourceOpenedEventArgs> ConnectionOpened;
/// <summary>
/// Occurs when the connection to the EventSource API has been closed.
/// </summary>
public event EventHandler<EventArgs> ConnectionClosed;
#endregion
#region Constructors
internal EventSourceService(Configuration configuration, HttpClient httpClient)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
#endregion
#region Public Methods
/// <summary>
/// Initiates the request to the EventSource API and parses Server Sent Events received by the API.
/// </summary>
/// <returns>A <see cref="System.Threading.Tasks.Task"/> A task that represents the work queued to execute in the ThreadPool.</returns>
public async Task GetDataAsync(
Action<string> processResponseLineString,
Action<Utf8ByteSpan> processResponseLineUTF8,
string lastEventId,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
await ConnectToEventSourceApi(processResponseLineString, processResponseLineUTF8, lastEventId, cancellationToken);
}
#endregion
#region Private Methods
private async Task ConnectToEventSourceApi(
Action<string> processResponseLineString,
Action<Utf8ByteSpan> processResponseLineUTF8,
string lastEventId,
CancellationToken cancellationToken
)
{
var request = CreateHttpRequestMessage(_configuration.Uri, lastEventId);
this._configuration.HttpRequestModifier?.Invoke(request);
using (var response = await _httpClient.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false))
{
ValidateResponse(response);
using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
{
var encoding = DetectEncoding(response);
if (encoding != Encoding.UTF8)
{
throw new EventSourceServiceCancelledException(
string.Format(Resources.ErrorWrongEncoding, encoding.HeaderName));
}
OnConnectionOpened(response.Headers);
if (_configuration.PreferDataAsUtf8Bytes)
{
await ProcessResponseFromUtf8StreamAsync(processResponseLineUTF8, stream, cancellationToken);
}
else
{
using (var reader = new StreamReader(stream, Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, bufferSize: ReadBufferSize))
{
await ProcessResponseFromReaderAsync(processResponseLineString, reader, cancellationToken);
}
}
}
OnConnectionClosed();
}
}
private Encoding DetectEncoding(HttpResponseMessage response)
{
var charset = response.Content.Headers.ContentType?.CharSet;
if (charset != null)
{
try
{
return Encoding.GetEncoding(charset);
}
catch (ArgumentException) { }
}
return Encoding.UTF8;
}
protected virtual async Task ProcessResponseFromReaderAsync(
Action<string> processResponse,
StreamReader reader,
CancellationToken cancellationToken
)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken,
token => AllowCancellation(reader.ReadLineAsync(), token));
if (line == null)
{
// this means the stream is done, i.e. the connection was closed
return;
}
processResponse(line);
}
}
protected async Task ProcessResponseFromUtf8StreamAsync(
Action<Utf8ByteSpan> processResponseLine,
Stream stream,
CancellationToken cancellationToken
)
{
var lineScanner = new ByteArrayLineScanner(ReadBufferSize);
while (!cancellationToken.IsCancellationRequested)
{
// Note that even though Stream.ReadAsync has an overload that takes a CancellationToken, that
// does not actually work for network sockets (https://stackoverflow.com/questions/12421989/networkstream-readasync-with-a-cancellation-token-never-cancels).
// So we must use AsyncHelpers.AllowCancellation to wrap it in a cancellable task.
int bytesRead = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken,
token => AllowCancellation(stream.ReadAsync(lineScanner.Buffer, lineScanner.Count, lineScanner.Available), token));
if (bytesRead == 0)
{
cancellationToken.ThrowIfCancellationRequested();
return;
}
lineScanner.AddedBytes(bytesRead);
while (lineScanner.ScanToEndOfLine(out var lineSpan))
{
processResponseLine(lineSpan);
}
}
}
private HttpRequestMessage CreateHttpRequestMessage(Uri uri, string lastEventId)
{
var request = new HttpRequestMessage(_configuration.Method ?? HttpMethod.Get, uri);
// Add all headers provided in the Configuration Headers. This allows a consumer to provide any request headers to the EventSource API
if (_configuration.RequestHeaders != null)
{
foreach (var item in _configuration.RequestHeaders)
{
try
{
request.Headers.Add(item.Key, item.Value);
}
catch (FormatException)
{
// Avoid showing the Authorization header value in the exception message
if (item.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
throw new FormatException("The Authorization header is invalid.");
}
throw;
}
}
}
// Add the request body, if any.
if (_configuration.RequestBodyFactory != null)
{
HttpContent requestBody = _configuration.RequestBodyFactory();
if (requestBody != null)
{
request.Content = requestBody;
}
}
// If the lastEventId was provided, include it as a header to the API request.
if (!string.IsNullOrWhiteSpace(lastEventId))
{
request.Headers.Remove(Constants.LastEventIdHttpHeader);
request.Headers.Add(Constants.LastEventIdHttpHeader, lastEventId);
}
// If we haven't set the LastEventId header and if the EventSource Configuration was provided with a LastEventId,
// include it as a header to the API request.
if (!string.IsNullOrWhiteSpace(_configuration.LastEventId) && !request.Headers.Contains(Constants.LastEventIdHttpHeader))
request.Headers.Add(Constants.LastEventIdHttpHeader, _configuration.LastEventId);
if (request.Headers.UserAgent.Count == 0)
request.Headers.UserAgent.ParseAdd(UserAgentProduct + "/" + UserAgentVersion);
// Add the Accept Header if it wasn't provided in the Configuration
if (!request.Headers.Contains(Constants.AcceptHttpHeader))
request.Headers.Add(Constants.AcceptHttpHeader, Constants.EventStreamContentType);
request.Headers.ExpectContinue = false;
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
return request;
}
private void ValidateResponse(HttpResponseMessage response)
{
// Any non-2xx response status is an error. A 204 (no content) is also an error.
if (!response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
throw new EventSourceServiceUnsuccessfulResponseException((int)response.StatusCode, response.Headers);
}
if (response.Content == null)
{
throw new EventSourceServiceCancelledException(Resources.ErrorEmptyResponse);
}
if (response.Content.Headers.ContentType.MediaType != Constants.EventStreamContentType)
{
throw new EventSourceServiceCancelledException(
string.Format(Resources.ErrorWrongContentType, response.Content.Headers.ContentType));
}
}
private void OnConnectionOpened(HttpHeaders headers)
{
ConnectionOpened?.Invoke(this, new EventSourceOpenedEventArgs(headers));
}
private void OnConnectionClosed()
{
ConnectionClosed?.Invoke(this, EventArgs.Empty);
}
#endregion
}
}
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// General superclass for exceptions that caused the EventSource to disconnect or fail to establish
/// a connection.
/// </summary>
public class EventSourceServiceCancelledException : Exception
{
#region Public Constructors
/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="message">the exception message</param>
public EventSourceServiceCancelledException(string message) : base(message) { }
/// <summary>
/// Creates a new instance with an inner exception.
/// </summary>
/// <param name="message">the exception message</param>
/// <param name="innerException">the inner exception</param>
public EventSourceServiceCancelledException(string message, Exception innerException) :
base(message, innerException)
{ }
#endregion
}
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Indicates that the EventSource was able to establish an HTTP connection, but received a
/// non-successful status code.
/// </summary>
public class EventSourceServiceUnsuccessfulResponseException : EventSourceServiceCancelledException
{
#region Public Properties
/// <summary>
/// The HTTP status code of the response.
/// </summary>
public int StatusCode { get; }
/// <summary>
/// The HTTP headers from the response.
/// </summary>
public IEnumerable<KeyValuePair<string, IEnumerable<string>>> Headers { get; }
#endregion
#region Public Constructors
/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="statusCode">the HTTP status code of the response</param>
public EventSourceServiceUnsuccessfulResponseException(int statusCode) :
this(statusCode, new Dictionary<string, IEnumerable<string>>())
{
}
/// <summary>
/// Creates a new instance with headers.
/// </summary>
/// <param name="statusCode">the HTTP status code of the response</param>
/// <param name="headers">the HTTP headers from the response</param>
public EventSourceServiceUnsuccessfulResponseException(int statusCode, IEnumerable<KeyValuePair<string, IEnumerable<string>>> headers) :
base(string.Format(Resources.ErrorHttpStatus, statusCode))
{
StatusCode = statusCode;
Headers = headers ?? new Dictionary<string, IEnumerable<string>>();
}
#endregion
}
}
@@ -0,0 +1,28 @@
using System;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Provides exception data raised in the EventSource <see cref="EventSource.Error" /> event.
/// </summary>
/// <seealso cref="System.EventArgs" />
public class ExceptionEventArgs : EventArgs
{
/// <summary>
/// Gets the <see cref="System.Exception"/> that represents the error that occurred.
/// </summary>
/// <value>
/// The exception.
/// </value>
public Exception Exception { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionEventArgs"/> class.
/// </summary>
/// <param name="ex">A <see cref="System.Exception"/> that represents the error that occurred.</param>
public ExceptionEventArgs(Exception ex)
{
Exception = ex;
}
}
}
@@ -0,0 +1,206 @@
using System;
namespace LaunchDarkly.EventSource
{
internal sealed class ExponentialBackoffWithDecorrelation
{
private readonly object _lock = new object();
private readonly Random _randomSource;
private TimeSpan _minimumDelay;
private TimeSpan _maximumDelay;
private int _backoffN;
private TimeSpan? _serverDirectedMinDelay;
public ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay)
: this(minimumDelay, maximumDelay, new Random())
{
}
/// <summary>
/// Constructs an instance with a caller-supplied <see cref="Random"/>, so that tests can
/// make jitter deterministic.
/// </summary>
internal ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay,
Random randomSource)
{
_minimumDelay = NonNegative(minimumDelay);
_maximumDelay = NonNegative(maximumDelay);
_randomSource = randomSource ?? new Random();
}
/// <summary>
/// Gets the next backoff duration, advancing n.
/// </summary>
public TimeSpan GetNextBackOff()
{
lock (_lock)
{
long unjittered = GetUnjitteredMillisecondsForN(_backoffN);
// Random.Next takes an int bound. 2^31 milliseconds is far longer than any
// reconnect delay we would use, so saturating here cannot affect a realistic
// delay.
int jitterBound = (int)Math.Min(unjittered, int.MaxValue);
long delayMillis = unjittered / 2
+ (jitterBound > 0 ? _randomSource.Next(jitterBound) / 2 : 0);
_backoffN++;
return TimeSpan.FromMilliseconds(delayMillis);
}
}
/// <summary>
/// Returns the delay in whole milliseconds for a given backoff <c>n</c>, before jitter is
/// applied: <c>minimumDelay * 2^n</c>, limited to the maximum.
/// </summary>
/// <remarks>
/// <para>
/// The caller must hold the lock. This reads the bounds without taking it because it is the
/// inner core of <see cref="GetNextBackOff"/>, which already holds it; taking it here would
/// only be a re-entrant acquisition on the hot path. Anything needing a synchronized read of
/// the bounds on its own should use the accessors below, which take the lock themselves.
/// </para>
/// <para>
/// <c>n</c> is compared against the ceiling rather than used to compute a value that is
/// then clamped, so no intermediate ever exceeds the maximum. That matters because
/// <c>n</c> is unbounded: a long outage keeps incrementing it, and computing
/// <c>minimumDelay * 2^n</c> directly would overflow.
/// </para>
/// </remarks>
internal long GetUnjitteredMillisecondsForN(int n)
{
long min = (_serverDirectedMinDelay ?? _minimumDelay).Ticks
/ TimeSpan.TicksPerMillisecond;
long max = _maximumDelay.Ticks / TimeSpan.TicksPerMillisecond;
if (min <= 0 || max <= 0)
{
return 0;
}
if (n <= 0)
{
return min > max ? max : min;
}
// Shifting the ceiling down cannot overflow, whereas shifting the minimum up can.
// The n >= 63 test is required rather than defensive: C# masks a 64-bit shift count
// to its low 6 bits, so `max >> 64` would silently mean `max >> 0`.
if (n >= 63 || min > (max >> n))
{
return max;
}
return min << n;
}
/// <summary>
/// Applies a server-directed reconnection time received via the SSE <c>retry:</c> field,
/// replacing the minimum delay used to compute subsequent backoffs.
/// </summary>
/// <remarks>
/// <para>
/// The override takes precedence over both the configured minimum and any minimum
/// installed by <see cref="SetBounds"/>. It is sticky: it survives bounds changes and
/// <see cref="ResetBackoffN"/>, and is replaced only by a later server-directed value.
/// </para>
/// <para>
/// The maximum is never affected, so the ceiling that bounds reconnection keeps applying.
/// </para>
/// <para>
/// n resets, so the next attempt uses the directed value itself rather than continuing to
/// double from wherever the progression had reached.
/// </para>
/// <para>
/// Negative values become zero. No upper bound is applied here; a caller taking a value
/// from an untrusted source is responsible for capping it first.
/// </para>
/// </remarks>
/// <param name="minDelay">the server-directed reconnection time</param>
public void SetServerDirectedMinDelay(TimeSpan minDelay)
{
var value = NonNegative(minDelay);
lock (_lock)
{
_serverDirectedMinDelay = value;
_backoffN = 0;
}
}
/// <summary>
/// Replaces the delay bounds.
/// </summary>
/// <remarks>
/// If the new bounds differ from the current ones, n resets so that the
/// next delay starts from the new minimum. If they are the same, this does nothing, which
/// makes repeated calls with identical values idempotent -- a caller that reapplies the
/// same bounds on every failure will not pin the delay at the minimum.
/// </remarks>
/// <param name="minimumDelay">the new minimum delay; negative values become zero</param>
/// <param name="maximumDelay">the new maximum delay; negative values become zero</param>
public void SetBounds(TimeSpan minimumDelay, TimeSpan maximumDelay)
{
var min = NonNegative(minimumDelay);
var max = NonNegative(maximumDelay);
lock (_lock)
{
if (min == _minimumDelay && max == _maximumDelay)
{
return;
}
_minimumDelay = min;
_maximumDelay = max;
_backoffN = 0;
}
}
/// <summary>
/// Resets n so the next delay starts from the current minimum, without
/// changing the bounds.
/// </summary>
public void ResetBackoffN()
{
lock (_lock)
{
_backoffN = 0;
}
}
// Accessors for tests.
internal int GetBackoffN()
{
lock (_lock)
{
return _backoffN;
}
}
internal TimeSpan? GetServerDirectedMinDelay()
{
lock (_lock)
{
return _serverDirectedMinDelay;
}
}
internal TimeSpan GetMinimumDelay()
{
lock (_lock)
{
return _minimumDelay;
}
}
internal TimeSpan GetMaximumDelay()
{
lock (_lock)
{
return _maximumDelay;
}
}
// Matches the clamping behavior of ConfigurationBuilder's retry-delay setters: a negative
// duration becomes zero rather than throwing.
private static TimeSpan NonNegative(TimeSpan t) => t < TimeSpan.Zero ? TimeSpan.Zero : t;
}
}
@@ -0,0 +1,160 @@
using System;
using System.Threading.Tasks;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// This interface defines the public members of <see cref="EventSource"/>.
/// </summary>
public interface IEventSource
{
#region Public Events
/// <summary>
/// Occurs when the connection to the EventSource API has been opened.
/// </summary>
event EventHandler<StateChangedEventArgs> Opened;
/// <summary>
/// Occurs when the connection to the EventSource API has been closed.
/// </summary>
event EventHandler<StateChangedEventArgs> Closed;
/// <summary>
/// Occurs when a Server Sent Event from the EventSource API has been received.
/// </summary>
event EventHandler<MessageReceivedEventArgs> MessageReceived;
/// <summary>
/// Occurs when a comment has been received from the EventSource API.
/// </summary>
event EventHandler<CommentReceivedEventArgs> CommentReceived;
/// <summary>
/// Occurs when an error has happened when the EventSource is open and processing Server Sent Events.
/// </summary>
event EventHandler<ExceptionEventArgs> Error;
#endregion Public Events
#region Public Properties
/// <summary>
/// Gets the state of the EventSource connection.
/// </summary>
ReadyState ReadyState { get; }
#endregion
#region Public Methods
/// <summary>
/// Initiates a connection to the SSE server and begins parsing events.
/// </summary>
/// <returns>a <see cref="Task"/> that will be completed only when the
/// <c>EventSource</c> is closed</returns>
/// <exception cref="InvalidOperationException">if the method was called again after the
/// stream connection was already active</exception>
Task StartAsync();
/// <summary>
/// Triggers the same "close and retry" behavior as if an error had been encountered on the stream.
/// </summary>
/// <remarks>
/// <para>
/// If the stream is currently active, this closes the connection, waits for some amount of time
/// as determined by the usual backoff behavior (and <paramref name="resetBackoffDelay"/>), and
/// then attempts to reconnect. If the stream is not yet connected, is already waiting to
/// reconnect, or has been permanently shut down, this has no effect.
/// </para>
/// <para>
/// The method returns immediately without waiting for the reconnection to happen. You will
/// receive <see cref="Closed"/> and <see cref="Opened"/> events when it does happen (or an
/// <see cref="Error"/> event if the new connection attempt fails).
/// </para>
/// </remarks>
/// <param name="resetBackoffDelay">true if the delay before reconnection should be reset to
/// the lowest level of the currently active bounds, which could be the temporary bounds if
/// <see cref="SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/> is in effect; false if it
/// should increase according to the usual exponential backoff logic</param>
void Restart(bool resetBackoffDelay);
/// <summary>
/// Temporarily replaces the bounds used to compute reconnection delays.
/// </summary>
/// <remarks>
/// <para>
/// The configured <see cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/> and
/// <see cref="ConfigurationBuilder.MaxRetryDelay(TimeSpan)"/> normally govern the
/// exponential backoff between reconnection attempts. This method installs different
/// bounds at runtime, so a caller can slow reconnection down without reconfiguring or
/// recreating the <c>EventSource</c>.
/// </para>
/// <para>
/// The bounds are <i>temporary</i>: they remain in effect until either a connection stays
/// open for at least
/// <see cref="ConfigurationBuilder.BackoffResetThreshold(TimeSpan)"/>, at which point the
/// configured bounds are restored automatically, or
/// <see cref="ClearTemporaryRetryDelayBounds"/> is called.
/// </para>
/// <para>
/// Changing the bounds resets the backoff level, so the next delay is computed from the new
/// <paramref name="initialDelay"/> rather than continuing to grow from where it left off.
/// Calling this with bounds that are already in effect does nothing, so it is safe to call
/// repeatedly.
/// </para>
/// <para>
/// Negative values are changed to zero. If <paramref name="initialDelay"/> is greater than
/// <paramref name="maxDelay"/>, delays are limited to <paramref name="maxDelay"/>. No
/// argument causes this method to throw, and no argument can cause a later reconnect to
/// fail.
/// </para>
/// <para>
/// A server-directed reconnection time received via the SSE <c>retry:</c> field supersedes
/// <paramref name="initialDelay"/>, and the configured
/// <see cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/> along with it. Such a
/// value does not affect <paramref name="maxDelay"/>, so the ceiling set here continues to
/// bound reconnection.
/// </para>
/// <para>
/// It is safe to call this from any thread, including from an
/// <see cref="Error"/> handler.
/// </para>
/// </remarks>
/// <param name="initialDelay">the lowest delay to use while these bounds are in effect,
/// unless a server-directed <c>retry:</c> value supersedes it</param>
/// <param name="maxDelay">the highest delay to use while these bounds are in effect</param>
/// <seealso cref="ClearTemporaryRetryDelayBounds"/>
void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay);
/// <summary>
/// Discards any bounds installed by
/// <see cref="SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/>, restoring the configured
/// <see cref="ConfigurationBuilder.MaxRetryDelay(TimeSpan)"/> and, unless a server-directed
/// <c>retry:</c> value is in effect, the configured
/// <see cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/>.
/// </summary>
/// <remarks>
/// <para>
/// Restoring the configured bounds resets the backoff level. If no temporary bounds
/// are in effect this does nothing. The count of reconnection attempts is not reset.
/// Calling this is not required as the configured bounds are also restored automatically
/// after a connection stays open for
/// <see cref="ConfigurationBuilder.BackoffResetThreshold(TimeSpan)"/>.
/// </para>
/// <para>
/// A server-directed reconnection time received via the SSE <c>retry:</c> field is not
/// discarded and continues to supersede
/// <see cref="ConfigurationBuilder.InitialRetryDelay(TimeSpan)"/>, so the configured
/// minimum may not be the one actually in use afterwards. Only a later <c>retry:</c> value
/// replaces it. <see cref="ConfigurationBuilder.MaxRetryDelay(TimeSpan)"/> is unaffected
/// and is always restored.
/// </para>
/// </remarks>
/// <seealso cref="SetTemporaryRetryDelayBounds(TimeSpan, TimeSpan)"/>
void ClearTemporaryRetryDelayBounds();
/// <summary>
/// Closes the connection to the SSE server. The <c>EventSource</c> cannot be reopened after this.
/// </summary>
void Close();
#endregion
}
}
@@ -0,0 +1,224 @@
using System;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Represents the Server-Sent Event message received from a stream.
/// </summary>
/// <remarks>
/// <para>
/// An SSE event consists of an event name (defaulting to "message" if not specified),
/// a data string, and an optional "ID" string that the server may provide.
/// </para>
/// <para>
/// The event name and ID properties are always stored as strings. By default, the
/// data property is also stored as a string. However, in some applications, it may
/// be desirable to represent the data as a UTF-8 byte array (for instance, if you are
/// using the <c>System.Text.Json</c> API to parse JSON data).
/// </para>
/// <para>
/// Since strings in .NET use two-byte UTF-16 characters, if you have a large block of
/// UTF-8 data it is considerably more efficient to process it in its original form
/// rather than converting it to or from a string. <see cref="EventSource"/> stores
/// data as strings by default, but you set <see cref="ConfigurationBuilder.PreferDataAsUtf8Bytes(bool)"/>
/// it can store the raw UTF-8 data instead. In either case, <c>MessageEvent</c> will
/// convert types transparently so that you can read either <see cref="MessageEvent.Data"/>
/// or <see cref="MessageEvent.DataUtf8Bytes"/>.
/// </para>
/// </remarks>
public struct MessageEvent
{
/// <summary>
/// The default value of <see cref="Name"/> if the SSE stream did not specify an
/// <c>event:</c> field.
/// </summary>
public const string DefaultName = "message";
#region Private Fields
private readonly string _name;
private readonly string _dataString;
private readonly Utf8ByteSpan _dataUtf8Bytes;
private readonly string _lastEventId;
private readonly Uri _origin;
#endregion
#region Public Constructors
/// <summary>
/// Initializes a new instance of the <see cref="MessageEvent"/> class.
/// </summary>
/// <param name="name">the event name</param>
/// <param name="data">the data received in the server-sent event</param>
/// <param name="lastEventId">the last event identifier, or null</param>
/// <param name="origin">the origin URI of the stream</param>
public MessageEvent(string name, string data, string lastEventId, Uri origin)
{
_name = name;
_dataString = data;
_dataUtf8Bytes = new Utf8ByteSpan();
_lastEventId = lastEventId;
_origin = origin;
}
/// <summary>
/// Initializes a new instance of the <see cref="MessageEvent"/> class,
/// providing the data as a UTF-8 byte span.
/// </summary>
/// <param name="name">the event name</param>
/// <param name="dataUtf8Bytes">the data received in the server-sent event;
/// the <c>MessageEvent</c> will store a reference to the byte array, rather than
/// copying it, so it should not be modified afterward by the caller
/// </param>
/// <param name="lastEventId">the last event identifier, or null</param>
/// <param name="origin">the origin URI of the stream</param>
public MessageEvent(string name, Utf8ByteSpan dataUtf8Bytes, string lastEventId, Uri origin)
{
_name = name;
_dataString = null;
_dataUtf8Bytes = dataUtf8Bytes;
_lastEventId = lastEventId;
_origin = origin;
}
/// <summary>
/// Initializes a new instance of the <see cref="MessageEvent" /> class.
/// </summary>
/// <param name="name">the event name</param>
/// <param name="data">the data received in the server-sent event</param>
/// <param name="origin">the origin URI of the stream</param>
/// <remarks>
/// The <see cref="LastEventId" /> will be initialized to null.
/// </remarks>
public MessageEvent(string name, string data, Uri origin) : this(name, data, null, origin) { }
#endregion
#region Public Properties
/// <summary>
/// The event name.
/// </summary>
/// <remarks>
/// This can be specified by the server in the <c>event:</c> field in the SSE data, as in
/// <c>event: my-event-name</c>. If there is no <c>event:</c> field, the default name
/// is <see cref="DefaultName"/>.
/// </remarks>
public string Name => _name;
/// <summary>
/// Gets the data received in the event as a string.
/// </summary>
/// <remarks>
/// <para>
/// This is the value of the <c>data:</c> field in the SSE data; if there are multiple
/// <c>data:</c> lines for a single event, they are concatenated with <c>"\n"</c>.
/// </para>
/// <para>
/// If the data was originally stored as a string, the same string is returned.
/// If it was stored as a UTF-8 byte array, the bytes are copied to a new string.
/// </para>
/// </remarks>
public string Data => _dataString is null ? _dataUtf8Bytes.GetString() : _dataString;
/// <summary>
/// Gets the data received in the event as a UTF-8 byte span.
/// </summary>
/// <remarks>
/// <para>
/// This is the value of the <c>data:</c> field in the SSE data; if there are multiple
/// <c>data:</c> lines for a single event, they are concatenated with <c>"\n"</c>.
/// </para>
/// <para>
/// If the data was originally stored as UTF-8 bytes, the returned value refers to
/// the same array, offset, and length (it is the caller's responsibility not to
/// modify the byte array). If it was originally stored as a string, the string
/// is copied to a new byte array.
/// </para>
/// </remarks>
public Utf8ByteSpan DataUtf8Bytes => _dataString is null ? _dataUtf8Bytes :
new Utf8ByteSpan(_dataString);
/// <summary>
/// Gets the last event identifier received in the server-sent event.
/// </summary>
/// <remarks>
/// This is the value of the <c>id:</c> field in the SSE data. If there is no such
/// field, it is null. You can use a previously received <see cref="LastEventId"/>
/// value with <see cref="ConfigurationBuilder.LastEventId(string)"/> when starting
/// a new <see cref="EventSource"/> to tell the server what the last event you
/// received was, although not all servers support this.
/// </remarks>
public string LastEventId => _lastEventId;
/// <summary>
/// Gets the origin URI of the stream that generated the server-sent event.
/// </summary>
public Uri Origin => _origin;
/// <summary>
/// True if the event data is stored internally as UTF-8 bytes.
/// </summary>
/// <remarks>
/// The data can be accessed with either <see cref="Data"/> or <see cref="DataUtf8Bytes"/>
/// regardless of the value of this property. The property only indicates the <i>original</i>
/// format of the data, so, for instance, if it is <see langword="true"/> then
/// reading <see cref="Data"/> will have more overhead (due to copying) than
/// reading <see cref="DataUtf8Bytes"/>.
/// </remarks>
public bool IsDataUtf8Bytes => _dataString is null;
#endregion
#region Public Methods
/// <summary>
/// Determines whether the specified <see cref="System.Object" /> is equal to this instance.
/// </summary>
/// <remarks>
/// This method is potentially inefficient and should be used only in testing.
/// </remarks>
/// <param name="obj">the <see cref="System.Object" /> to compare with this instance</param>
/// <returns><see langword="true"/> if the instances are equal</returns>
public override bool Equals(object obj)
{
if (!(obj is MessageEvent that) ||
!object.Equals(Name, that.Name) ||
!object.Equals(LastEventId, that.LastEventId) ||
!object.Equals(Origin, that.Origin))
{
return false;
}
if (this._dataString != null)
{
return that._dataString != null ?
this._dataString == that._dataString :
that._dataUtf8Bytes.Equals(this._dataString);
}
return that._dataString != null ?
this._dataUtf8Bytes.Equals(that._dataString) :
this._dataUtf8Bytes.Equals(that._dataUtf8Bytes);
}
/// <summary>
/// Returns a hash code for this instance.
/// </summary>
/// <returns>
/// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.
/// This method is potentially inefficient and should be used only in testing.
/// </returns>
public override int GetHashCode()
{
int hash = 17;
hash = hash * 31 + (_dataString != null ? _dataString.GetHashCode() : _dataUtf8Bytes.GetString().GetHashCode());
hash = hash * 31 + (_lastEventId != null ? _lastEventId.GetHashCode() : 0);
hash = hash * 31 + (_origin != null ? _origin.GetHashCode() : 0);
return hash;
}
#endregion
}
}
@@ -0,0 +1,31 @@
using System;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// The parameter type for a <see cref="EventSource.MessageReceived"/> event handler.
/// </summary>
/// <seealso cref="System.EventArgs" />
public class MessageReceivedEventArgs : EventArgs
{
/// <summary>
/// A <see cref="MessageEvent"/> representing the event that was received from the
/// SSE stream.
/// </summary>
public MessageEvent Message { get; }
/// <summary>
/// Shortcut for getting the <see cref="MessageEvent.Name"/> property of the event.
/// </summary>
public string EventName => Message.Name;
/// <summary>
/// Initializes a new instance.
/// </summary>
/// <param name="message">the <see cref="MessageEvent"/> received from the stream</param>
public MessageReceivedEventArgs(MessageEvent message)
{
Message = message;
}
}
}
@@ -0,0 +1,20 @@
using System;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// An exception that indicates that the configured read timeout elapsed without receiving
/// any new data from the server.
/// </summary>
/// <remarks>
/// Socket connections can fail silently, in which case an EventSource client without a read
/// timeout would hang forever waiting for new data. A read timeout allows you to make a new
/// stream connection in this case. The server can send periodic comment lines (":\n") to
/// keep the client from timing out if the connection is still working.
/// </remarks>
public class ReadTimeoutException : Exception
{
/// <inheritdoc/>
public override string Message => Resources.ErrorReadTimeout;
}
}
@@ -0,0 +1,29 @@
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Represents the state of the connection in the <see cref="EventSource"/> class.
/// </summary>
public enum ReadyState
{
/// <summary>
/// The initial state of the connection.
/// </summary>
Raw,
/// <summary>
/// The connection has not yet been established, or it was closed and is reconnecting.
/// </summary>
Connecting,
/// <summary>
/// The connection is open and is processing events as it receives them.
/// </summary>
Open,
/// <summary>
/// The connection is closed. This could also occur when an error is received.
/// </summary>
Closed,
/// <summary>
/// The connection has been shutdown explicitly by the consumer using the <see cref="EventSource.Close()"/> method.
/// </summary>
Shutdown
}
}
@@ -0,0 +1,114 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace LaunchDarkly.EventSource {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// This class was generated by MSBuild using the GenerateResource task.
/// To add or remove a member, edit your .resx file then rerun MSBuild.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Build.Tasks.StronglyTypedResourceBuilder", "15.1.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LaunchDarkly.EventSource.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Invalid attempt to call Start() while the connection state is {0}.
/// </summary>
internal static string ErrorAlreadyStarted {
get {
return ResourceManager.GetString("ErrorAlreadyStarted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to HTTP response had no body.
/// </summary>
internal static string ErrorEmptyResponse {
get {
return ResourceManager.GetString("ErrorEmptyResponse", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unexpected HTTP status code {0} from server.
/// </summary>
internal static string ErrorHttpStatus {
get {
return ResourceManager.GetString("ErrorHttpStatus", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Read timeout elapsed with no new data from server; connection may have been silently dropped.
/// </summary>
internal static string ErrorReadTimeout {
get {
return ResourceManager.GetString("ErrorReadTimeout", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unexpected HTTP content type &quot;{0}&quot;; should be &quot;text/event-stream&quot;.
/// </summary>
internal static string ErrorWrongContentType {
get {
return ResourceManager.GetString("ErrorWrongContentType", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unexpected character encoding {0}; should be UTF-8.
/// </summary>
internal static string ErrorWrongEncoding {
get {
return ResourceManager.GetString("ErrorWrongEncoding", resourceCulture);
}
}
}
}
@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ErrorReadTimeout" xml:space="preserve">
<value>Read timeout elapsed with no new data from server; connection may have been silently dropped</value>
</data>
<data name="ErrorAlreadyStarted" xml:space="preserve">
<value>Invalid attempt to call Start() while the connection state is {0}</value>
</data>
<data name="ErrorHttpStatus" xml:space="preserve">
<value>Unexpected HTTP status code {0} from server</value>
</data>
<data name="ErrorWrongContentType" xml:space="preserve">
<value>Unexpected HTTP content type "{0}"; should be "text/event-stream"</value>
</data>
<data name="ErrorWrongEncoding" xml:space="preserve">
<value>Unexpected character encoding {0}; should be UTF-8</value>
</data>
<data name="ErrorEmptyResponse" xml:space="preserve">
<value>HTTP response had no body</value>
</data>
</root>
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Provides data for the state of the <see cref="EventSource"/> connection.
/// </summary>
/// <seealso cref="System.EventArgs" />
public class StateChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the state of the EventSource connection.
/// </summary>
/// <value>
/// One of the <see cref="EventSource.ReadyState"/> values, which represents the state of the EventSource connection.
/// </value>
public ReadyState ReadyState { get; }
/// <summary>
/// Get the response headers. Only populated for <see cref="ReadyState.Open"/>.
/// <value>
/// A collection of header values when the ReadyState is Open, or null.
/// </value>
/// </summary>
public IEnumerable<KeyValuePair<string,IEnumerable<string>>> Headers { get; }
/// <summary>
/// Initializes a new instance of the <see cref="StateChangedEventArgs"/> class.
/// </summary>
/// <param name="readyState">One of the <see cref="EventSource.ReadyState"/> values, which represents the state of the EventSource connection.</param>
/// <param name="headers">Response headers when the <see cref="StateChangedEventArgs.ReadyState"/> is <see cref="ReadyState.Open"/>. Otherwise null.</param>
public StateChangedEventArgs(ReadyState readyState, IEnumerable<KeyValuePair<string,IEnumerable<string>>> headers = null)
{
ReadyState = readyState;
Headers = headers;
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.Text;
namespace LaunchDarkly.EventSource
{
/// <summary>
/// Points to a span of UTF-8-encoded text in a buffer.
/// </summary>
/// <remarks>
/// This is similar to the <c>Span</c> type in .NET 5. It is used internally by
/// <c>EventSource</c> to store event data if the stream is using UTF-8 encoding.
/// If so, reading <see cref="MessageEvent.DataUtf8Bytes"/> will return the same
/// byte array, to avoid unnecessary copying.
/// </remarks>
public struct Utf8ByteSpan
{
/// <summary>
/// The byte array containing the data. May be null if <see cref="Length"/> is zero.
/// </summary>
/// <remarks>
/// It is the caller's responsibility not to modify the array.
/// </remarks>
public byte[] Data { get; }
/// <summary>
/// The offset of the first relevant byte of data within the array. This may be
/// greater than zero if the span represents a subset of a larger buffer.
/// </summary>
public int Offset { get; }
/// <summary>
/// The number of bytes of relevant data within the array. This may be less than
/// <c>Data.Length</c> if the span represents a subset of a larger buffer.
/// </summary>
public int Length { get; }
/// <summary>
/// Constructs a new instance.
/// </summary>
/// <param name="data">The byte array containing the data.</param>
/// <param name="offset">The offset of the first relevant byte of data within the array.</param>
/// <param name="length">The number of bytes of relevant data within the array.</param>
public Utf8ByteSpan(byte[] data, int offset, int length)
{
Data = length == 0 ? null : data;
Offset = offset;
Length = length;
}
/// <summary>
/// Constructs a new instance by copying a string.
/// </summary>
/// <param name="s">A string to convert to UTF-8 bytes.</param>
public Utf8ByteSpan(string s)
{
Data = s.Length == 0 ? null : Encoding.UTF8.GetBytes(s);
Offset = 0;
Length = Data is null ? 0 : Data.Length;
}
/// <summary>
/// Converts the UTF-8 byte data to a string.
/// </summary>
/// <returns>A new string.</returns>
public string GetString() => Data is null ? "" : Encoding.UTF8.GetString(Data, Offset, Length);
/// <summary>
/// Tests whether the bytes in this span are the same as another span.
/// </summary>
/// <param name="other">Another <c>Utf8ByteSpan</c>.</param>
/// <returns>True if the two spans have the same length and the same
/// data, starting from each one's <c>Offset</c>.</returns>
public bool Equals(Utf8ByteSpan other)
{
var len = Length;
if (len != other.Length)
{
return false;
}
int offset = Offset, otherOffset = other.Offset;
byte[] data = Data, otherData = other.Data;
for (int i = 0; i < len; i++)
{
if (data[i + offset] != otherData[i + otherOffset])
{
return false;
}
}
return true;
}
/// <summary>
/// Tests whether the bytes in this span are the same as the UTF-8 encoding
/// of the specified string.
/// </summary>
/// <remarks>
/// This method is potentially inefficient and should be used only in testing.
/// </remarks>
/// <param name="s">A string.</param>
/// <returns>True if the bytes are equivalent.</returns>
public bool Equals(string s)
{
byte[] data = Data;
int offset = Offset, len = Length;
for (int i = 0; i < len; i++)
{
if (data[offset + i] > 127)
{
// There are multi-byte characters in the span, so we can't do a 1:1 comparison of bytes
return GetString().Equals(s);
}
}
// There are no multi-byte characters, so we don't need to convert to or from a UTF-16 string
if (len != s.Length)
{
return false;
}
for (int i = 0; i < len; i++)
{
if ((char)data[offset + i] != s[i])
{
return false;
}
}
return true;
}
}
}
@@ -1,3 +1,5 @@
using AllBase;
using AllBase.Model.DBModel;
using System;
using System.Collections.Generic;
using System.Configuration;
@@ -32,6 +34,25 @@ public sealed class TicketClientOptions
/// </summary>
public IList<TicketOrderOption> Orders { get; set; }
/// <summary>
/// 创建参数副本,避免宿主与页面共享可变集合。
/// </summary>
public TicketClientOptions Clone()
{
return new TicketClientOptions
{
UserName = UserName,
AppIds = (AppIds ?? Enumerable.Empty<string>())
.Where(appId => !string.IsNullOrWhiteSpace(appId))
.Select(appId => appId.Trim())
.Distinct(StringComparer.Ordinal)
.ToList(),
Orders = (Orders ?? Enumerable.Empty<TicketOrderOption>())
.Where(order => order != null)
.ToList()
};
}
public static TicketClientOptions FromApplicationSettings()
{
var purchaseAccount =
@@ -72,6 +93,11 @@ public sealed class TicketOrderOption
/// </summary>
public string GoodsName { get; set; }
/// <summary>
/// 商品缩略图地址,用于新建工单页展示关联商品。
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。
/// </summary>
@@ -97,23 +123,54 @@ public sealed class TicketOrderOption
}
/// <summary>
/// 订单详情展示文本,包含商品名、金额和下单时间。
/// 订单详情展示文本。商品名已包含在 <see cref="DisplayText"/> 中,这里只展示金额和下单时间。
/// </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}";
return $"金额 {amount} · {orderedAt}";
}
}
/// <summary>
/// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。
/// </summary>
public static TicketOrderOption FromWechatOrder(
oms_wei_order_info order,
string appId)
{
if (order == null)
return null;
var goodsName = string.Join(
"、",
(order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
.Select(item => item.title)
.Where(title => !string.IsNullOrWhiteSpace(title))
.Distinct());
if (goodsName.Length > 200)
goodsName = goodsName.Substring(0, 200) + "…";
return new TicketOrderOption
{
AppId = appId?.Trim(),
OrderId = order.order_id,
GoodsName = goodsName,
ImageUrl = (order.orderItems ?? Enumerable.Empty<oms_wei_order_item_info>())
.Select(item => item.thumb_img)
.FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)),
Amount = (order.order_price ?? 0) / 100m,
OrderedAt = order.create_time > 0
? BaseClass.StampToDateTime(order.create_time)
: default
};
}
/// <summary>
/// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。
/// </summary>
@@ -4,7 +4,7 @@ using System.Collections.Generic;
namespace OrderSupportTicket.Models;
/// <summary>
/// 工单列表本地缓存,仅用于比较服务器列表是否有新数据。
/// 工单列表本地缓存,用于比较服务器列表是否有新数据,并保存商品展示信息。
/// </summary>
public sealed class WorkOrderListCache
{
@@ -34,4 +34,19 @@ public sealed class WorkOrderListCacheItem
/// 最后一条公开消息时间,用于判断是否有任意新消息。
/// </summary>
public DateTimeOffset LastMessageAt { get; set; }
/// <summary>
/// 商品名称。订单列表异步加载或直接打开工单时,用缓存补充商品信息。
/// </summary>
public string ProductName { get; set; }
/// <summary>
/// 商品缩略图地址。
/// </summary>
public string ProductImageUrl { get; set; }
/// <summary>
/// 订单金额,单位元。旧缓存没有该字段时为 null。
/// </summary>
public decimal? Amount { get; set; }
}
+2 -1
View File
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LaunchDarkly.EventSource" Version="6.0.0" />
<!--<PackageReference Include="LaunchDarkly.EventSource" Version="6.0.0" />-->
<PackageReference Include="WPFCommon" Version="1.2.0" />
</ItemGroup>
@@ -22,6 +22,7 @@
<ItemGroup>
<Reference Include="System.Configuration" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Runtime.Serialization" />
<Reference Include="NaiBa.Util">
<HintPath>..\DllReference\NaiBa.Util.dll</HintPath>
@@ -77,7 +77,7 @@ public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
{
try
{
await source.StartAsync().ConfigureAwait(false);
await source.StartAsync();
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, source))
@@ -0,0 +1,35 @@
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media;
namespace OrderSupportTicket.Services;
/// <summary>
/// 商品缩略图加载器,限制并发下载数量,避免工单列表同时发起大量请求。
/// </summary>
internal static class ProductImageLoader
{
private static readonly SemaphoreSlim Gate = new SemaphoreSlim(4, 4);
public static async Task<ImageSource> LoadAsync(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
await Gate.WaitAsync().ConfigureAwait(false);
try
{
return await Task.Run(
() => AllBase.ImageBuild.LoadBitmipImageFromUrl(url.Trim()))
.ConfigureAwait(false);
}
catch
{
return null;
}
finally
{
Gate.Release();
}
}
}
+15 -10
View File
@@ -10,6 +10,7 @@ using System.Runtime.Serialization.Json;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using WxOrder.Framework.Commons;
namespace OrderSupportTicket.Services;
@@ -18,7 +19,7 @@ namespace OrderSupportTicket.Services;
/// </summary>
public sealed class TicketApiService : ITicketApiService
{
internal const string ServerUrl = "https://ai.e57.cn";
internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
private static readonly Regex AppIdRegex =
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
@@ -28,7 +29,13 @@ public sealed class TicketApiService : ITicketApiService
new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
private static readonly Regex OperationIdRegex =
new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
static TicketApiService()
{
if (EnvironmentHelper.IsTest)
{
ServerUrl = "http://localhost:5188";
}
}
/// <summary>
/// 获取工单列表
/// </summary>
@@ -64,7 +71,7 @@ public sealed class TicketApiService : ITicketApiService
return await PostAsync<WorkOrderListResponse>(
ApiRoot + "/list",
request).ConfigureAwait(false);
request);
}
/// <summary>
/// 获取工单消息
@@ -100,8 +107,7 @@ public sealed class TicketApiService : ITicketApiService
var path = ApiRoot
+ "/messages";
return await PostAsync<WorkOrderMessagesResponse>(path, request)
.ConfigureAwait(false);
return await PostAsync<WorkOrderMessagesResponse>(path, request);
}
/// <summary>
/// 创建工单
@@ -170,8 +176,7 @@ public sealed class TicketApiService : ITicketApiService
+ "/orders/"
+ Uri.EscapeDataString(workOrder.WechatOrderId)
+ "/messages";
return await PostAsync<SendMessageResponse>(path, request)
.ConfigureAwait(false);
return await PostAsync<SendMessageResponse>(path, request);
}
public async Task MarkReadAsync(
@@ -196,7 +201,7 @@ public sealed class TicketApiService : ITicketApiService
+ "/orders/"
+ Uri.EscapeDataString(wechatOrderId)
+ "/read";
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
var html = await PostBodyAsync(path, request);
EnsureSuccessBody(html);
}
@@ -245,7 +250,7 @@ public sealed class TicketApiService : ITicketApiService
string path,
object request)
{
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
var html = await PostBodyAsync(path, request);
return DeserializeResponse<T>(html);
}
@@ -256,7 +261,7 @@ public sealed class TicketApiService : ITicketApiService
var url = BuildUri(path);
// 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。
var json = Serialize(request);
var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false);
var html = await GetHtml.PostJsonAsync(url, json);
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
return html;
@@ -0,0 +1,291 @@
using OrderSupportTicket.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Threading;
namespace OrderSupportTicket.Services;
/// <summary>
/// 应用级工单实时监视器。SSE 连接的生命周期与应用一致,不随工单页关闭而停止。
/// </summary>
public sealed class TicketRealtimeMonitor : IDisposable
{
private readonly object _sync = new object();
private readonly HashSet<string> _seenEventIds =
new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, int> _unreadMessages =
new Dictionary<string, int>(StringComparer.Ordinal);
private readonly Dispatcher _dispatcher;
private TicketClientOptions _options;
private string _optionsFingerprint;
private TicketRealtimeNotification _latestNotification;
private bool _subscribed;
private bool _disposed;
private TicketRealtimeMonitor()
{
RealtimeService = new EventSourceTicketRealtimeService();
RealtimeService.NotificationReceived += OnNotificationReceived;
_subscribed = true;
_dispatcher = System.Windows.Application.Current?.Dispatcher
?? Dispatcher.CurrentDispatcher;
}
/// <summary>
/// 全局唯一的工单实时监视器。
/// </summary>
public static TicketRealtimeMonitor Current { get; } = new TicketRealtimeMonitor();
/// <summary>
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
/// </summary>
public ITicketRealtimeService RealtimeService { get; }
/// <summary>
/// 收到新的工单留言通知时触发。
/// </summary>
public event EventHandler<TicketInboxMessageEventArgs> NewMessageReceived;
/// <summary>
/// 未读工单数量变化时触发。
/// </summary>
public event EventHandler<TicketUnreadCountChangedEventArgs> UnreadCountChanged;
/// <summary>
/// 当前未读工单数量。
/// </summary>
public int UnreadCount
{
get
{
lock (_sync)
return _unreadMessages.Values.Sum();
}
}
/// <summary>
/// 是否已经启动过实时连接。
/// </summary>
public bool IsStarted
{
get
{
lock (_sync)
return _options != null;
}
}
/// <summary>
/// 最近一条新消息通知,供主页面点击角标时定位工单。
/// </summary>
public TicketRealtimeNotification LatestNotification
{
get
{
lock (_sync)
return _latestNotification;
}
}
/// <summary>
/// 启动或复用实时连接。相同账号和 APPID 重复调用不会重建连接。
/// </summary>
public void Start(TicketClientOptions options)
{
if (_disposed)
throw new ObjectDisposedException(nameof(TicketRealtimeMonitor));
if (options == null)
throw new ArgumentNullException(nameof(options));
TicketApiService.ValidateOptions(options);
var fingerprint = BuildFingerprint(options);
lock (_sync)
{
if (string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal)
&& RealtimeService.State != TicketRealtimeState.Disconnected)
{
return;
}
_options = options;
_optionsFingerprint = fingerprint;
}
RealtimeService.Start(options);
}
/// <summary>
/// 将指定工单标记为已读。
/// </summary>
public void MarkRead(string appId, string wechatOrderId)
{
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId))
return;
var key = BuildTicketKey(appId, wechatOrderId);
var changed = false;
lock (_sync)
changed = _unreadMessages.Remove(key);
if (changed)
RaiseUnreadCountChanged();
}
/// <summary>
/// 清空全部未读提示。
/// </summary>
public void MarkAllRead()
{
var changed = false;
lock (_sync)
{
if (_unreadMessages.Count > 0)
{
_unreadMessages.Clear();
changed = true;
}
}
if (changed)
RaiseUnreadCountChanged();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_subscribed)
{
RealtimeService.NotificationReceived -= OnNotificationReceived;
_subscribed = false;
}
RealtimeService.Dispose();
}
private void OnNotificationReceived(
object sender,
TicketRealtimeNotificationEventArgs args)
{
var notification = args?.Notification;
if (notification == null || notification.IsResyncRequired)
return;
if (string.IsNullOrWhiteSpace(notification.AppId)
|| string.IsNullOrWhiteSpace(notification.WechatOrderId))
{
return;
}
if (!IsMessageAdded(notification))
return;
if (!string.IsNullOrWhiteSpace(notification.EventId))
{
lock (_sync)
{
if (!_seenEventIds.Add(notification.EventId))
return;
if (_seenEventIds.Count > 5000)
_seenEventIds.Clear();
}
}
var key = BuildTicketKey(notification.AppId, notification.WechatOrderId);
int unreadCount;
lock (_sync)
{
_unreadMessages.TryGetValue(key, out var ticketUnreadCount);
_unreadMessages[key] = ticketUnreadCount + 1;
_latestNotification = notification;
unreadCount = _unreadMessages.Values.Sum();
}
_dispatcher.BeginInvoke(new Action(() =>
{
if (_disposed)
return;
NewMessageReceived?.Invoke(
this,
new TicketInboxMessageEventArgs(notification, unreadCount));
UnreadCountChanged?.Invoke(
this,
new TicketUnreadCountChangedEventArgs(unreadCount));
}));
}
private void RaiseUnreadCountChanged()
{
var unreadCount = UnreadCount;
_dispatcher.BeginInvoke(new Action(() =>
{
if (!_disposed)
UnreadCountChanged?.Invoke(
this,
new TicketUnreadCountChangedEventArgs(unreadCount));
}));
}
private static bool IsMessageAdded(TicketRealtimeNotification notification)
{
if (!string.IsNullOrWhiteSpace(notification.MessageId))
return true;
return !string.IsNullOrWhiteSpace(notification.Type)
&& notification.Type.IndexOf(
"message_added",
StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string BuildTicketKey(string appId, string wechatOrderId)
{
return appId + "|" + wechatOrderId;
}
private static string BuildFingerprint(TicketClientOptions options)
{
var appIds = (options.AppIds ?? Enumerable.Empty<string>())
.Where(appId => !string.IsNullOrWhiteSpace(appId))
.Select(appId => appId.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(appId => appId, StringComparer.Ordinal);
return options.UserName + "|" + string.Join(",", appIds);
}
}
/// <summary>
/// 主页面收到的工单新消息事件参数。
/// </summary>
public sealed class TicketInboxMessageEventArgs : EventArgs
{
public TicketInboxMessageEventArgs(
TicketRealtimeNotification notification,
int unreadCount)
{
Notification = notification;
UnreadCount = unreadCount;
}
/// <summary>
/// 服务端推送的原始通知。
/// </summary>
public TicketRealtimeNotification Notification { get; }
/// <summary>
/// 收到该通知后的未读工单数量。
/// </summary>
public int UnreadCount { get; }
}
/// <summary>
/// 未读工单数量变化事件参数。
/// </summary>
public sealed class TicketUnreadCountChangedEventArgs : EventArgs
{
public TicketUnreadCountChangedEventArgs(int unreadCount)
{
UnreadCount = unreadCount;
}
/// <summary>
/// 当前未读工单数量。
/// </summary>
public int UnreadCount { get; }
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,12 @@
using AllBase.Helper;
using OrderSupportTicket.Models.Api;
using OrderSupportTicket.Services;
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Threading;
using WPFCommon.Models;
namespace OrderSupportTicket.ViewModels;
@@ -21,6 +27,13 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
private int _unreadCount;
private bool _hasNewMessage;
private string _version;
private string _productName;
private string _productImageUrl;
private decimal? _productAmount;
private ImageSource _productImage;
private int _productImageVersion;
private readonly Dispatcher _dispatcher =
System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
public WorkOrderSummaryViewModel(WorkOrderSummaryDto dto)
{
@@ -35,7 +48,29 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
public string AppId
{
get => _appId;
private set => SetValue(ref _appId, value);
private set
{
if (SetValue(ref _appId, value))
RaisePropertyChanged(nameof(ShopName));
}
}
/// <summary>
/// 工单所属店铺的显示名称。列表中不直接展示 APPID。
/// </summary>
public string ShopName
{
get
{
var shop = GlobalConfig.ShopLoginList?.FirstOrDefault(s =>
string.Equals(s.ShopId, AppId, StringComparison.Ordinal)
|| string.Equals(s.AppKey, AppId, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(shop?.ShopAccount))
return shop.ShopAccount;
if (!string.IsNullOrWhiteSpace(shop?.CompanyName))
return shop.CompanyName;
return "未知店铺";
}
}
/// <summary>
@@ -144,6 +179,66 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
private set => SetValue(ref _version, value);
}
/// <summary>
/// 商品名称,优先来自当前订单列表或本地工单缓存。
/// </summary>
public string ProductName
{
get => _productName;
private set
{
if (SetValue(ref _productName, value))
RaisePropertyChanged(nameof(ProductNameText));
}
}
/// <summary>
/// 商品名称的展示文本,尚未同步到商品信息时给出提示。
/// </summary>
public string ProductNameText =>
string.IsNullOrWhiteSpace(ProductName) ? "商品信息待同步" : ProductName;
/// <summary>
/// 商品金额,单位元;旧缓存没有金额时为 null。
/// </summary>
public decimal? ProductAmount
{
get => _productAmount;
private set
{
if (SetValue(ref _productAmount, value))
RaisePropertyChanged(nameof(ProductAmountText));
}
}
/// <summary>
/// 商品金额展示文本。
/// </summary>
public string ProductAmountText =>
ProductAmount.HasValue
? "¥ " + ProductAmount.Value.ToString(
"N2",
CultureInfo.GetCultureInfo("zh-CN"))
: string.Empty;
/// <summary>
/// 商品缩略图地址。
/// </summary>
public string ProductImageUrl
{
get => _productImageUrl;
private set => SetValue(ref _productImageUrl, value);
}
/// <summary>
/// 商品缩略图,加载完成前为空。
/// </summary>
public ImageSource ProductImage
{
get => _productImage;
private set => SetValue(ref _productImage, value);
}
/// <summary>
/// 用于合并工单的 APPID 与订单号组合键。
/// </summary>
@@ -230,6 +325,30 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
UnreadCount = 1;
}
/// <summary>
/// 合并商品信息。空值不会覆盖已有数据,便于先应用缓存、再应用最新的订单信息。
/// </summary>
public void ApplyProductInfo(
string productName,
string imageUrl,
decimal? amount)
{
if (!string.IsNullOrWhiteSpace(productName))
ProductName = productName.Trim();
if (amount.HasValue)
ProductAmount = amount;
if (string.IsNullOrWhiteSpace(imageUrl))
return;
var normalizedUrl = imageUrl.Trim();
if (string.Equals(ProductImageUrl, normalizedUrl, StringComparison.Ordinal))
return;
ProductImageUrl = normalizedUrl;
_ = LoadProductImageAsync(normalizedUrl);
}
private void Apply(WorkOrderSummaryDto dto)
{
AppId = dto.AppId;
@@ -246,6 +365,35 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx
Version = dto.Version;
}
private async Task LoadProductImageAsync(string url)
{
try
{
var version = ++_productImageVersion;
ProductImage = null;
var image = await ProductImageLoader.LoadAsync(url);
if (image == null || version != _productImageVersion)
return;
if (_dispatcher.CheckAccess())
{
ProductImage = image;
return;
}
await _dispatcher.InvokeAsync(() =>
{
if (version == _productImageVersion)
ProductImage = image;
});
}
catch
{
// 商品图加载失败不影响工单列表展示。
}
}
private bool IsAtLeastCurrentVersion(string incomingVersion)
{
if (string.IsNullOrWhiteSpace(incomingVersion))
@@ -4,12 +4,14 @@
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:fk="WxOrder.Framework"
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}"
fk:DialogWindowProperty.Height="750"
fk:DialogWindowProperty.ResizeMode="CanResizeWithGrip"
fk:DialogWindowProperty.Width="1000"
fk:DialogWindowProperty.WindowStartupLocation="CenterScreen"
Background="#EEF1F4"
FontFamily="Microsoft YaHei UI"
FontSize="14"
@@ -21,6 +23,7 @@
<SolidColorBrush x:Key="LineBrush" Color="#DDE2E8" />
<SolidColorBrush x:Key="TextBrush" Color="#1F2933" />
<SolidColorBrush x:Key="SubTextBrush" Color="#6B7785" />
<SolidColorBrush x:Key="PriceBrush" Color="#C2410C" />
<SolidColorBrush x:Key="BackgroundBrush" Color="#EEF1F4" />
<SolidColorBrush x:Key="PanelBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="OtherBubbleBrush" Color="#F1F3F6" />
@@ -32,6 +35,20 @@
<SolidColorBrush x:Key="DoneBrush" Color="#5B6673" />
<SolidColorBrush x:Key="DoneBackgroundBrush" Color="#E9ECF0" />
<Style x:Key="CopyableOrderIdStyle" TargetType="TextBlock">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="ToolTip" Value="点击复制订单号" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource BrandBrush}" />
<Setter Property="TextDecorations" Value="Underline" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="{StaticResource BrandBrush}" />
@@ -146,7 +163,7 @@
x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="3,0,0,1">
BorderThickness="3,0,0,0">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
@@ -187,12 +204,13 @@
CornerRadius="6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Grid.Column="0"
Width="300"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,1,0">
<Grid>
@@ -225,22 +243,48 @@
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="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="11,8,14,4">
<TextBox
Padding="8,5"
Background="#FAFBFC"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
Foreground="{StaticResource TextBrush}"
GotFocus="TicketSearchBox_GotFocus"
Text="{Binding TicketSearchText, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Margin="9,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 TicketSearchText}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding TicketSearchText}" Value="{x:Null}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<Border
Grid.Row="0"
Grid.Row="1"
Padding="11,10,14,10"
Background="{StaticResource BrandSoftBrush}"
BorderBrush="{StaticResource BrandBrush}"
@@ -254,7 +298,9 @@
<TextBlock
Margin="0,5,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
MouseLeftButtonUp="CopyOrderId_MouseLeftButtonUp"
Style="{StaticResource CopyableOrderIdStyle}"
Tag="{Binding SelectedOrder.OrderId}"
Text="{Binding SelectedOrder.OrderId, StringFormat=订单 {0}}"
TextTrimming="CharacterEllipsis" />
<TextBlock
@@ -266,33 +312,44 @@
</Border>
<ListBox
Grid.Row="1"
Grid.Row="2"
behaviors:ListBoxInteractionBehavior.SelectedItemCommand="{Binding SelectTicketCommand}"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource TicketListItemStyle}"
ItemsSource="{Binding Tickets}"
ItemsSource="{Binding TicketView}"
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 Margin="11,10,14,10" ToolTip="{Binding ProductNameText}">
<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">
<Grid Grid.Row="0" Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
FontWeight="SemiBold"
MouseLeftButtonUp="CopyOrderId_MouseLeftButtonUp"
Style="{StaticResource CopyableOrderIdStyle}"
Tag="{Binding WechatOrderId}"
Text="{Binding WechatOrderId}"
TextTrimming="CharacterEllipsis" />
<Border
x:Name="UnreadBadge"
Grid.Column="1"
Height="18"
MinWidth="18"
Margin="6,-1,0,0"
@@ -306,37 +363,97 @@
Foreground="White"
Text="{Binding UnreadCount}" />
</Border>
</StackPanel>
<Border HorizontalAlignment="Right" Style="{StaticResource StatusTagStyle}">
</Grid>
<Border
Grid.Column="1"
Margin="8,0,0,0"
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 Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding PreviewText}"
TextTrimming="CharacterEllipsis" />
<TextBlock
<Grid
Width="48"
Height="48"
VerticalAlignment="Center">
<Border
Background="#F1F3F6"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="4">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Segoe MDL2 Assets"
FontSize="18"
Foreground="#9AA5B1"
Text="&#xE8B9;" />
</Border>
<Border
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="4">
<Border.Background>
<ImageBrush
ImageSource="{Binding ProductImage}"
Stretch="UniformToFill" />
</Border.Background>
</Border>
</Grid>
<StackPanel
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding UpdatedAtText}" />
Margin="9,0,0,0"
VerticalAlignment="Center">
<TextBlock
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="{Binding ProductNameText}"
TextTrimming="CharacterEllipsis" />
<Grid Margin="0,3,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource PriceBrush}"
Text="{Binding ProductAmountText}" />
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding ShopName, StringFormat=店铺 {0}}"
TextTrimming="CharacterEllipsis" />
</Grid>
<Grid Margin="0,3,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
FontSize="11"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding PreviewText}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
FontSize="11"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding UpdatedAtText}" />
</Grid>
</StackPanel>
</Grid>
</Grid>
<DataTemplate.Triggers>
@@ -458,26 +575,93 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal">
<Grid Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Width="44"
Height="44"
VerticalAlignment="Center">
<Border
Background="#F1F3F6"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="4">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Segoe MDL2 Assets"
FontSize="17"
Foreground="#9AA5B1"
Text="&#xE8B9;" />
</Border>
<Border
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="4">
<Border.Background>
<ImageBrush
ImageSource="{Binding SelectedTicket.ProductImage}"
Stretch="UniformToFill" />
</Border.Background>
</Border>
</Grid>
<StackPanel
Grid.Column="1"
Margin="10,0,0,0"
VerticalAlignment="Center">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
MouseLeftButtonUp="CopyOrderId_MouseLeftButtonUp"
Style="{StaticResource CopyableOrderIdStyle}"
Tag="{Binding SelectedTicket.WechatOrderId}"
Text="{Binding SelectedTicket.WechatOrderId}"
TextTrimming="CharacterEllipsis" />
<Border
Grid.Column="1"
Margin="9,0,0,0"
VerticalAlignment="Center"
DataContext="{Binding SelectedTicket}"
Style="{StaticResource StatusTagStyle}">
<TextBlock Style="{StaticResource StatusTextStyle}" Text="{Binding StatusText}" />
</Border>
</Grid>
<TextBlock
Margin="0,2,0,0"
FontSize="13"
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>
Text="{Binding SelectedTicket.ProductNameText}"
TextTrimming="CharacterEllipsis" />
<Grid Margin="0,2,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource PriceBrush}"
Text="{Binding SelectedTicket.ProductAmountText}" />
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding SelectedTicket.ShopName, StringFormat=店铺 {0}}"
TextTrimming="CharacterEllipsis" />
</Grid>
</StackPanel>
<TextBlock
Margin="0,2,0,0"
FontSize="12"
Foreground="{StaticResource SubTextBrush}"
Text="{Binding SelectedTicket.AppId, StringFormat=店铺 {0}}" />
</StackPanel>
</Grid>
<Button
Grid.Column="1"
Command="{Binding LoadEarlierMessagesCommand}"
@@ -638,6 +822,7 @@
</Grid>
</Border>
</Border>
</Grid>
<Grid Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
@@ -674,147 +859,58 @@
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}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Width="72"
Height="72"
VerticalAlignment="Center">
<Border
Background="#F1F3F6"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="6">
<TextBlock
Margin="10,0,0,0"
HorizontalAlignment="Center"
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>
FontFamily="Segoe MDL2 Assets"
FontSize="22"
Foreground="#9AA5B1"
Text="&#xE8B9;" />
</Border>
<Border
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="6">
<Border.Background>
<ImageBrush
ImageSource="{Binding SelectedOrderImage}"
Stretch="UniformToFill" />
</Border.Background>
</Border>
</Grid>
<StackPanel
Grid.Column="1"
Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Foreground="{StaticResource SubTextBrush}" Text="关联订单" />
<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>
Margin="0,4,0,0"
FontWeight="SemiBold"
Foreground="{StaticResource TextBrush}"
Text="{Binding SelectedOrderText}"
TextWrapping="Wrap" />
<TextBlock
Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="0,7,0,0"
Margin="0,4,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>
Text="{Binding SelectedOrderDescription}"
TextWrapping="Wrap" />
</StackPanel>
</Grid>
</Border>
</Grid>
@@ -878,6 +974,7 @@
</Grid>
</Border>
</Border>
</Grid>
<StackPanel
@@ -888,7 +985,7 @@
HorizontalAlignment="Center"
FontSize="14"
Foreground="{StaticResource SubTextBrush}"
Text="在左侧选择工单,或点击“新建”提交新工单" />
Text="在左侧选择工单查看对话" />
<TextBlock
Margin="0,6,0,0"
HorizontalAlignment="Center"
@@ -1,7 +1,10 @@
using _29WxOrderWpf.ToolCtrls;
using OrderSupportTicket.Models;
using OrderSupportTicket.Services;
using OrderSupportTicket.ViewModels;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace OrderSupportTicket.Views;
@@ -11,12 +14,14 @@ namespace OrderSupportTicket.Views;
public partial class OrderSupportTicketView : UserControl
{
private bool _active;
private Window _hostWindow;
public OrderSupportTicketView()
: this(
new TicketApiService(),
new EventSourceTicketRealtimeService(),
TicketClientOptions.FromApplicationSettings())
CreateDefaultOptions(),
ownsRealtime: true)
{
}
@@ -24,37 +29,76 @@ public partial class OrderSupportTicketView : UserControl
ITicketApiService api,
ITicketRealtimeService realtime,
TicketClientOptions options = null)
: this(api, realtime, options, ownsRealtime: true)
{
}
/// <summary>
/// 创建工单页。ownsRealtime 为 false 时复用宿主提供的共享 SSE 连接,
/// 页面关闭不会停止该连接。
/// </summary>
public OrderSupportTicketView(
ITicketApiService api,
ITicketRealtimeService realtime,
TicketClientOptions options,
bool ownsRealtime)
{
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);
ViewModel = new OrderSupportTicketViewModel(
api,
realtime,
options ?? CreateDefaultOptions(),
ownsRealtime);
DataContext = ViewModel;
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
/// <summary>
/// 当前联调使用的默认参数。正式接入时可由宿主替换为登录账号和店铺 APPID。
/// </summary>
public static TicketClientOptions CreateDefaultOptions(string fallbackUserName = null)
{
var configured = TicketClientOptions.FromApplicationSettings();
var userName = !string.IsNullOrWhiteSpace(configured.UserName)
? configured.UserName
: (!string.IsNullOrWhiteSpace(fallbackUserName)
? fallbackUserName.Trim()
: "18990991234");
var appIds = configured.AppIds != null && configured.AppIds.Count > 0
? configured.AppIds
: new List<string>
{
"wx316b1291351558aa",
"wx7999743c3d64c05c"
};
return new TicketClientOptions
{
UserName = userName,
AppIds = appIds,
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),
}
]
};
}
/// <summary>
/// 当前页面对应的工单 ViewModel。
/// </summary>
@@ -76,11 +120,58 @@ public partial class OrderSupportTicketView : UserControl
ViewModel.OpenNewTicket(order);
}
/// <summary>
/// 打开并定位到指定工单。
/// </summary>
public void OpenTicket(string appId, string wechatOrderId)
{
ViewModel.OpenTicket(appId, wechatOrderId);
}
private void TicketSearchBox_GotFocus(object sender, RoutedEventArgs e)
{
ViewModel.CancelNewTicketDraft();
}
/// <summary>
/// 点击订单号复制到剪贴板,并使用宿主窗口的 Toast 提示结果。
/// </summary>
private void CopyOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement element)
return;
var orderId = element.Tag as string;
if (string.IsNullOrWhiteSpace(orderId)
&& element.DataContext is WorkOrderSummaryViewModel ticket)
{
orderId = ticket.WechatOrderId;
}
if (string.IsNullOrWhiteSpace(orderId))
return;
var window = Window.GetWindow(this) ?? _hostWindow;
if (window == null)
return;
try
{
Clipboard.SetText(orderId);
ToastHelper.Show(window, "订单号已复制");
}
catch (Exception ex)
{
ToastHelper.Show(window, "复制失败:" + ex.Message, ToastType.Error);
}
}
private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
{
if (_active)
return;
_active = true;
AttachHostWindow(Window.GetWindow(this));
UpdateWindowActiveState();
await ViewModel.InitializeAsync();
}
@@ -89,6 +180,72 @@ public partial class OrderSupportTicketView : UserControl
if (!_active)
return;
_active = false;
ViewModel.IsWindowActive = false;
DetachHostWindow();
await ViewModel.DeactivateAsync();
}
private void AttachHostWindow(Window window)
{
DetachHostWindow();
_hostWindow = window;
if (_hostWindow == null)
return;
_hostWindow.Activated += OnHostWindowActivated;
_hostWindow.Deactivated += OnHostWindowDeactivated;
_hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
_hostWindow.StateChanged += OnHostWindowStateChanged;
_hostWindow.Closed += OnHostWindowClosed;
}
private void DetachHostWindow()
{
if (_hostWindow == null)
return;
_hostWindow.Activated -= OnHostWindowActivated;
_hostWindow.Deactivated -= OnHostWindowDeactivated;
_hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
_hostWindow.StateChanged -= OnHostWindowStateChanged;
_hostWindow.Closed -= OnHostWindowClosed;
_hostWindow = null;
}
private void UpdateWindowActiveState()
{
var window = _hostWindow;
ViewModel.IsWindowActive = window != null
&& window.IsVisible
&& window.IsActive
&& window.WindowState != WindowState.Minimized;
}
private void OnHostWindowActivated(object sender, EventArgs e)
{
UpdateWindowActiveState();
}
private void OnHostWindowDeactivated(object sender, EventArgs e)
{
UpdateWindowActiveState();
}
private void OnHostWindowIsVisibleChanged(
object sender,
DependencyPropertyChangedEventArgs e)
{
UpdateWindowActiveState();
}
private void OnHostWindowStateChanged(object sender, EventArgs e)
{
UpdateWindowActiveState();
}
private void OnHostWindowClosed(object sender, EventArgs e)
{
ViewModel.IsWindowActive = false;
DetachHostWindow();
}
}
@@ -12,7 +12,41 @@ namespace WxOrder.Framework.Commons.Dialogs
public static class DialogService
{
// ========== 对外接口 ==========
public static bool ExistsWindow(FrameworkElement view)
{
var window = Application.Current.Windows.OfType<Window>().FirstOrDefault(w => w.Content != null && w.Content.GetType() == view.GetType());
return window != null;
}
public static void ActivateWindow(FrameworkElement view)
{
var window = Application.Current.Windows.OfType<Window>().FirstOrDefault(w => w.Content != null && w.Content.GetType() == view.GetType());
if (window == null)
return;
if (!window.IsVisible)
window.Show();
if (window.WindowState == WindowState.Minimized)
window.WindowState = WindowState.Normal;
if (!window.Activate())
{
// 窗口被其他进程遮挡时,短暂置顶以切换到用户面前。
window.Topmost = true;
window.Topmost = false;
window.Activate();
}
}
public static void ShowOrActivateWindow(FrameworkElement view, IDialogParameters parameters = null, Action<IDialogResult> callback = null)
{
if (ExistsWindow(view))
{
ActivateWindow(view);
}
else
{
Show(view, parameters, callback);
}
}
/// <summary>非模态弹出</summary>
public static void Show(FrameworkElement view,
IDialogParameters parameters = null,