修改工单流程页面,订单列表增加工单按钮
This commit is contained in:
1 parent
f9dcae4610
commit
10bb0803d9
44 files changed
+5920
-1291
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
+51
@@ -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 "{0}"; should be "text/event-stream".
|
||||
/// </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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
</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,
|
||||
|
||||
File renamed without changes.
Reference in new issue
Block a user