工单 优化 ,增加图片
This commit is contained in:
1 parent
bf5a70597c
commit
cf2efbcb16
27 files changed
+3552
-1535
No files matched your search
+265
-264
@@ -1,273 +1,274 @@
|
||||
<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
|
||||
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
|
||||
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">
|
||||
<WrapPanel
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center">
|
||||
<Button
|
||||
x:Name="btOrderSupport"
|
||||
Click="bt_showOrderSupport"
|
||||
IsEnabled="False"
|
||||
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
|
||||
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" />
|
||||
<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>
|
||||
</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" />
|
||||
<buyerctrls:TbBuyerOrderReadCtrl
|
||||
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>
|
||||
<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" />
|
||||
<buyerctrls:TbBuyerOrderReadCtrl
|
||||
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>
|
||||
|
||||
|
||||
<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>
|
||||
@@ -239,7 +239,10 @@ namespace _29WxOrderWpf
|
||||
private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
|
||||
{
|
||||
_ticketMonitor.Dispose();
|
||||
Process.GetCurrentProcess().Kill();
|
||||
}
|
||||
else
|
||||
e.Cancel = true;
|
||||
}
|
||||
@@ -611,6 +614,10 @@ namespace _29WxOrderWpf
|
||||
await _ticketMonitor.Start(options);
|
||||
UpdateOrderSupportBadge();
|
||||
_ = LoadHistoricalTicketUnreadAsync(options);
|
||||
this.Dispatcher.Invoke(() =>
|
||||
{
|
||||
btOrderSupport.IsEnabled = true;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -415,7 +415,7 @@ namespace AllBase
|
||||
bitmap.Freeze(); // 防止后续更改,提高性能
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 处理异常,例如打印错误信息或设置一个默认图片等
|
||||
|
||||
@@ -177,6 +177,7 @@ namespace LaunchDarkly.EventSource
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error(e.Message);
|
||||
CancelCurrentRequest();
|
||||
|
||||
// If the user called Close(), ReadyState = Shutdown, so errors are irrelevant.
|
||||
@@ -200,7 +201,6 @@ namespace LaunchDarkly.EventSource
|
||||
realException = new TimeoutException();
|
||||
}
|
||||
}
|
||||
|
||||
if (realException != null)
|
||||
{
|
||||
OnError(new ExceptionEventArgs(realException));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace OrderSupportTicket.Models.Api;
|
||||
@@ -139,10 +139,16 @@ internal sealed class SendMessageRequest
|
||||
public string ClientMessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 留言正文,长度 1~4000 字。
|
||||
/// 留言正文;有图片附件时可为空。
|
||||
/// </summary>
|
||||
////[JsonProperty("text")]
|
||||
public string Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片上传回执,最多 3 个;无图片时为 null,序列化时省略。
|
||||
/// </summary>
|
||||
////[JsonProperty("attachments")]
|
||||
public IList<MessageAttachmentReceipt> Attachments { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,6 +175,176 @@ internal sealed class MarkReadRequest
|
||||
public int ThroughSequenceNo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 申请图片直传凭证请求。
|
||||
/// </summary>
|
||||
internal sealed class CreateImageUploadTicketRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 拍单账号。
|
||||
/// </summary>
|
||||
public string PurchaseAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单所属店铺 APPID。
|
||||
/// </summary>
|
||||
public string AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名,1~180 字符。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 精确的图片 MIME 类型:image/jpeg、image/png 或 image/webp。
|
||||
/// </summary>
|
||||
public string MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片实际字节数,1~5,242,880。
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选的上传标识;省略时由服务端生成。
|
||||
/// </summary>
|
||||
public string RequestId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图片直传凭证响应。
|
||||
/// </summary>
|
||||
public sealed class ImageUploadTicketResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 请求是否成功。
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次上传的凭证信息。
|
||||
/// </summary>
|
||||
public ImageUploadTicketDto Upload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单所属店铺 APPID。
|
||||
/// </summary>
|
||||
public string AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信订单号。
|
||||
/// </summary>
|
||||
public string WechatOrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接口返回的身份校验标记,当前恒为 false。
|
||||
/// </summary>
|
||||
public bool IdentityVerified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次 HTTP 请求的排查编号;不是 upload.requestId。
|
||||
/// </summary>
|
||||
public string RequestId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务端签发的单次图片直传凭证。
|
||||
/// </summary>
|
||||
public sealed class ImageUploadTicketDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传模式,文档约定为 single。
|
||||
/// </summary>
|
||||
public string Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传 HTTP 方法,文档约定为 PUT。
|
||||
/// </summary>
|
||||
public string Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次图片上传标识。
|
||||
/// </summary>
|
||||
public string RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端签发的上传凭证,需原样提交留言接口。
|
||||
/// </summary>
|
||||
public string Ticket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 带短时签名的 COS 上传地址。
|
||||
/// </summary>
|
||||
public string UploadUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 直传时必须原样携带的请求头。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Headers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传凭证过期时间。
|
||||
/// </summary>
|
||||
public string ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图片直传完成后提交给留言接口的回执。
|
||||
/// </summary>
|
||||
public sealed class MessageAttachmentReceipt
|
||||
{
|
||||
/// <summary>
|
||||
/// 原样使用 upload.requestId。
|
||||
/// </summary>
|
||||
public string RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原样使用 upload.ticket。
|
||||
/// </summary>
|
||||
public string Ticket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可选的 COS 响应 ETag。
|
||||
/// </summary>
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 留言中的公开图片附件。
|
||||
/// </summary>
|
||||
public sealed class TicketAttachmentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 附件唯一 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string OriginalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片 MIME 类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片字节数。
|
||||
/// </summary>
|
||||
public long ByteSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片创建时间。
|
||||
/// </summary>
|
||||
public string CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 短时读取地址;暂不可用时为 null。
|
||||
/// </summary>
|
||||
public string ReadUrl { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工单摘要分页响应。
|
||||
/// </summary>
|
||||
@@ -371,6 +547,12 @@ public sealed class TicketMessageDto
|
||||
/// </summary>
|
||||
////[JsonProperty("createdAt")]
|
||||
public string CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开图片附件;无图片时为空数组。
|
||||
/// </summary>
|
||||
////[JsonProperty("attachments")]
|
||||
public List<TicketAttachmentDto> Attachments { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AllBase\29WxOrderAllBase.csproj" />
|
||||
<ProjectReference Include="..\WxOrder.Framework\WxOrder.Framework.csproj" />
|
||||
<ProjectReference Include="..\Work\29WxOrderWork.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class EventSourceTicketRealtimeService
|
||||
/// </summary>
|
||||
public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected;
|
||||
|
||||
public async Task Start(TicketClientOptions options)
|
||||
public Task Start(TicketClientOptions options)
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService));
|
||||
@@ -56,7 +56,8 @@ public sealed class EventSourceTicketRealtimeService
|
||||
}
|
||||
|
||||
SetState(TicketRealtimeState.Connecting, "连接中…");
|
||||
await RunAsync(source);
|
||||
_= RunAsync(source);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
@@ -220,6 +221,7 @@ public sealed class EventSourceTicketRealtimeService
|
||||
ConfigurationBuilder builder = Configuration.Builder(uri);
|
||||
builder.RequestHeader("X-AppIds", $"[\"{string.Join("\",\"", appIds)}\"]");
|
||||
builder.Logger(NLogger.Instance);
|
||||
//builder.LastEventId("");
|
||||
//var proxy = new WebProxy("http://127.0.0.1:8081"); //
|
||||
//var handler = new HttpClientHandler
|
||||
//{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
@@ -13,11 +13,13 @@ public sealed class TicketApiException : Exception
|
||||
public TicketApiException(
|
||||
int statusCode,
|
||||
string message,
|
||||
string responseBody = null)
|
||||
string responseBody = null,
|
||||
string errorCode = null)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ResponseBody = responseBody;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -29,4 +31,9 @@ public sealed class TicketApiException : Exception
|
||||
/// 服务端返回的原始响应正文。
|
||||
/// </summary>
|
||||
public string ResponseBody { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端返回的错误码,例如 client_api_image_expired。
|
||||
/// </summary>
|
||||
public string ErrorCode { get; }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
using AllBase;
|
||||
using AllBase;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using WxOrder.Framework.Commons;
|
||||
@@ -19,6 +22,16 @@ public sealed class TicketApiService
|
||||
{
|
||||
internal static string ServerUrl = "https://ai.e57.cn";
|
||||
private const string ApiRoot = "/api/open/v2/customer-work-orders";
|
||||
|
||||
/// <summary>
|
||||
/// 每条留言最多可携带的图片数量。
|
||||
/// </summary>
|
||||
public const int MaxImageCount = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 单张图片允许的最大字节数(5 MiB)。
|
||||
/// </summary>
|
||||
public const long MaxImageBytes = 5 * 1024 * 1024;
|
||||
private static readonly Regex AppIdRegex =
|
||||
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex OrderIdRegex =
|
||||
@@ -27,6 +40,14 @@ public sealed class TicketApiService
|
||||
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);
|
||||
private static readonly HashSet<string> SupportedImageMimeTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp"
|
||||
};
|
||||
private static readonly HttpClient ImageUploadClient = CreateImageUploadClient();
|
||||
internal static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
@@ -158,22 +179,25 @@ public sealed class TicketApiService
|
||||
TicketClientOptions options,
|
||||
WorkOrderSummaryDto workOrder,
|
||||
string text,
|
||||
string clientMessageId)
|
||||
string clientMessageId,
|
||||
IList<MessageAttachmentReceipt> attachments = null)
|
||||
{
|
||||
ValidateOptions(options);
|
||||
if (workOrder == null)
|
||||
throw new ArgumentNullException(nameof(workOrder));
|
||||
ValidateAppId(workOrder.AppId);
|
||||
ValidateWechatOrderId(workOrder.WechatOrderId);
|
||||
ValidateMessageText(text);
|
||||
ValidateMessageContent(text, attachments);
|
||||
ValidateOperationId(clientMessageId, "clientMessageId", required: true);
|
||||
|
||||
var hasAttachments = attachments != null && attachments.Count > 0;
|
||||
var request = new SendMessageRequest
|
||||
{
|
||||
PurchaseAccount = options.UserName,
|
||||
AppId = workOrder.AppId,
|
||||
ClientMessageId = clientMessageId,
|
||||
Text = text
|
||||
Text = string.IsNullOrWhiteSpace(text) ? null : text,
|
||||
Attachments = hasAttachments ? attachments.ToList() : null
|
||||
};
|
||||
var path = ApiRoot
|
||||
+ "/orders/"
|
||||
@@ -182,6 +206,139 @@ public sealed class TicketApiService
|
||||
return await PostAsync<SendMessageResponse>(path, request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 申请单张图片的短时直传凭证。每张图片单独申请。
|
||||
/// </summary>
|
||||
public async Task<ImageUploadTicketResponse> CreateImageUploadTicketAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
string fileName,
|
||||
string mimeType,
|
||||
long size,
|
||||
string requestId = null)
|
||||
{
|
||||
ValidateOptions(options);
|
||||
ValidateAppId(appId);
|
||||
ValidateWechatOrderId(wechatOrderId);
|
||||
var normalizedFileName = ValidateImageFileName(fileName);
|
||||
var normalizedMimeType = ValidateImageMimeType(mimeType);
|
||||
ValidateImageSize(size);
|
||||
ValidateOperationId(requestId, "requestId", required: false);
|
||||
|
||||
var request = new CreateImageUploadTicketRequest
|
||||
{
|
||||
PurchaseAccount = options.UserName,
|
||||
AppId = appId,
|
||||
FileName = normalizedFileName,
|
||||
MimeType = normalizedMimeType,
|
||||
Size = size,
|
||||
RequestId = string.IsNullOrWhiteSpace(requestId) ? null : requestId
|
||||
};
|
||||
var path = ApiRoot
|
||||
+ "/orders/"
|
||||
+ Uri.EscapeDataString(wechatOrderId)
|
||||
+ "/message-attachments/upload-ticket";
|
||||
var response = await PostAsync<ImageUploadTicketResponse>(path, request);
|
||||
ValidateImageUploadTicket(response?.Upload);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用服务端签发的凭证把图片二进制直接 PUT 到 COS。
|
||||
/// 返回的 requestId/ticket/etag 需原样提交到留言接口。
|
||||
/// </summary>
|
||||
public async Task<MessageAttachmentReceipt> UploadImageAsync(
|
||||
ImageUploadTicketDto upload,
|
||||
string filePath)
|
||||
{
|
||||
ValidateImageUploadTicket(upload);
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
throw new ArgumentException("图片文件路径不能为空。", nameof(filePath));
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await Task.Run(() => File.ReadAllBytes(filePath)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is IOException
|
||||
|| ex is UnauthorizedAccessException
|
||||
|| ex is ArgumentException)
|
||||
{
|
||||
throw new ArgumentException("读取图片文件失败:" + ex.Message, nameof(filePath));
|
||||
}
|
||||
if (bytes.Length < 1 || bytes.Length > MaxImageBytes)
|
||||
throw new ArgumentException("单张图片必须为 1 字节~5 MiB。", nameof(filePath));
|
||||
|
||||
var contentType = GetUploadHeader(upload.Headers, "Content-Type")
|
||||
?? "application/octet-stream";
|
||||
using (var request = new HttpRequestMessage(
|
||||
new HttpMethod(upload.Method.ToUpperInvariant()),
|
||||
upload.UploadUrl))
|
||||
using (var content = new ByteArrayContent(bytes))
|
||||
{
|
||||
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
|
||||
foreach (var header in upload.Headers ?? new Dictionary<string, string>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header.Key))
|
||||
continue;
|
||||
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
|
||||
content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
request.Content = content;
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await ImageUploadClient
|
||||
.SendAsync(request)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is HttpRequestException
|
||||
|| ex is TaskCanceledException)
|
||||
{
|
||||
throw new TicketApiException(0, "图片直传网络异常,请稍后重试。");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
string body = null;
|
||||
try
|
||||
{
|
||||
body = await response.Content
|
||||
.ReadAsStringAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 存储端错误正文仅用于提取可读提示,失败时使用通用文案。
|
||||
}
|
||||
throw new TicketApiException(
|
||||
statusCode,
|
||||
ExtractUploadErrorMessage(body, statusCode));
|
||||
}
|
||||
|
||||
var etag = response.Headers.TryGetValues("ETag", out var values)
|
||||
? values.FirstOrDefault()
|
||||
: null;
|
||||
return new MessageAttachmentReceipt
|
||||
{
|
||||
RequestId = upload.RequestId,
|
||||
Ticket = upload.Ticket,
|
||||
Etag = string.IsNullOrWhiteSpace(etag) ? null : etag
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
@@ -342,25 +499,52 @@ public sealed class TicketApiService
|
||||
return new TicketApiException(
|
||||
ExtractStatusCode(responseBody),
|
||||
message,
|
||||
responseBody);
|
||||
responseBody,
|
||||
ExtractErrorCode(responseBody));
|
||||
}
|
||||
|
||||
private static ApiErrorResponse ExtractErrorResponse(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<ApiErrorResponse>(responseBody, JsonSettings);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractErrorCode(string responseBody)
|
||||
{
|
||||
return ExtractErrorResponse(responseBody)?.Code;
|
||||
}
|
||||
|
||||
private static int ExtractStatusCode(string responseBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(responseBody))
|
||||
var error = ExtractErrorResponse(responseBody);
|
||||
if (error == null || string.IsNullOrWhiteSpace(error.Code))
|
||||
return 0;
|
||||
if (int.TryParse(error.Code, out var code))
|
||||
return code;
|
||||
|
||||
try
|
||||
switch (error.Code.Trim().ToLowerInvariant())
|
||||
{
|
||||
var error = JsonConvert.DeserializeObject<ApiErrorResponse>(responseBody, JsonSettings);
|
||||
if (error != null && int.TryParse(error.Code, out var code))
|
||||
return code;
|
||||
case "client_api_image_expired":
|
||||
return 410;
|
||||
case "client_api_image_already_used":
|
||||
return 409;
|
||||
case "client_api_image_invalid":
|
||||
return 422;
|
||||
case "client_api_image_upload_failed":
|
||||
return 400;
|
||||
case "client_api_images_unavailable":
|
||||
return 503;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeAppIds(IEnumerable<string> appIds)
|
||||
@@ -389,14 +573,152 @@ public sealed class TicketApiService
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateMessageText(string text)
|
||||
|
||||
private static void ValidateMessageContent(
|
||||
string text,
|
||||
IList<MessageAttachmentReceipt> attachments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)
|
||||
|| text.Length < 1
|
||||
|| text.Length > 4000)
|
||||
var attachmentCount = attachments?.Count ?? 0;
|
||||
if (attachmentCount > MaxImageCount)
|
||||
{
|
||||
throw new ArgumentException("text 必须为 1~4000 字。", nameof(text));
|
||||
throw new ArgumentException(
|
||||
"每条留言最多提交 " + MaxImageCount + " 张图片。",
|
||||
nameof(attachments));
|
||||
}
|
||||
if (attachmentCount > 0)
|
||||
ValidateAttachmentReceipts(attachments);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
if (text.Length > 4000)
|
||||
throw new ArgumentException("text 不能超过 4000 字。", nameof(text));
|
||||
}
|
||||
else if (attachmentCount == 0)
|
||||
{
|
||||
throw new ArgumentException("text 与图片附件不能同时为空。", nameof(text));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAttachmentReceipts(
|
||||
IList<MessageAttachmentReceipt> attachments)
|
||||
{
|
||||
var requestIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (attachment == null
|
||||
|| string.IsNullOrWhiteSpace(attachment.RequestId)
|
||||
|| !OperationIdRegex.IsMatch(attachment.RequestId)
|
||||
|| string.IsNullOrWhiteSpace(attachment.Ticket))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"图片上传回执不完整或 requestId 格式不正确。",
|
||||
nameof(attachments));
|
||||
}
|
||||
if (!requestIds.Add(attachment.RequestId))
|
||||
throw new ArgumentException("图片上传回执不能重复。", nameof(attachments));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidateImageFileName(string fileName)
|
||||
{
|
||||
var normalized = (fileName ?? string.Empty).Trim();
|
||||
if (normalized.Length < 1
|
||||
|| normalized.Length > 180
|
||||
|| normalized.Any(char.IsControl)
|
||||
|| normalized.IndexOfAny(new[] { '/', '\\' }) >= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"fileName 必须为 1~180 字符且不能包含路径分隔符或控制字符。",
|
||||
nameof(fileName));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ValidateImageMimeType(string mimeType)
|
||||
{
|
||||
var normalized = (mimeType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!SupportedImageMimeTypes.Contains(normalized))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"mimeType 仅支持 image/jpeg、image/png 或 image/webp。",
|
||||
nameof(mimeType));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static void ValidateImageSize(long size)
|
||||
{
|
||||
if (size < 1 || size > MaxImageBytes)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"单张图片必须为 1 字节~5 MiB。",
|
||||
nameof(size));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateImageUploadTicket(ImageUploadTicketDto upload)
|
||||
{
|
||||
if (upload == null
|
||||
|| string.IsNullOrWhiteSpace(upload.RequestId)
|
||||
|| string.IsNullOrWhiteSpace(upload.Ticket)
|
||||
|| string.IsNullOrWhiteSpace(upload.UploadUrl)
|
||||
|| string.IsNullOrWhiteSpace(GetUploadHeader(upload.Headers, "Content-Type"))
|
||||
|| !string.Equals(upload.Method, "PUT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new TicketApiException(0, "服务端返回的图片上传凭证不完整。");
|
||||
}
|
||||
if (!Uri.TryCreate(upload.UploadUrl, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
|
||||
{
|
||||
throw new TicketApiException(0, "服务端返回的图片上传地址无效。");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetUploadHeader(
|
||||
IDictionary<string, string> headers,
|
||||
string name)
|
||||
{
|
||||
if (headers == null)
|
||||
return null;
|
||||
foreach (var header in headers)
|
||||
{
|
||||
if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
return header.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractUploadErrorMessage(
|
||||
string body,
|
||||
int statusCode)
|
||||
{
|
||||
var error = ExtractErrorResponse(body);
|
||||
if (!string.IsNullOrWhiteSpace(error?.Message))
|
||||
return error.Message;
|
||||
if (!string.IsNullOrWhiteSpace(error?.Error))
|
||||
return error.Error;
|
||||
if (statusCode == 403)
|
||||
return "图片上传地址已失效或无权访问,请重新选择图片后发送。";
|
||||
return "图片直传失败(HTTP " + statusCode + "),请重试。";
|
||||
}
|
||||
|
||||
private static HttpClient CreateImageUploadClient()
|
||||
{
|
||||
try
|
||||
{
|
||||
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 旧系统不支持显式 TLS 1.2 时保留运行时默认值。
|
||||
}
|
||||
return new HttpClient(new HttpClientHandler
|
||||
{
|
||||
UseProxy = false
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(120)
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateOperationId(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
@@ -137,14 +137,50 @@ public sealed class TicketService : IDisposable
|
||||
TicketClientOptions options,
|
||||
WorkOrderSummaryDto workOrder,
|
||||
string text,
|
||||
string clientMessageId)
|
||||
string clientMessageId,
|
||||
IList<MessageAttachmentReceipt> attachments = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.SendMessageAsync(
|
||||
options,
|
||||
workOrder,
|
||||
text,
|
||||
clientMessageId);
|
||||
clientMessageId,
|
||||
attachments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 申请单张图片的短时直传凭证。
|
||||
/// </summary>
|
||||
public Task<ImageUploadTicketResponse> CreateImageUploadTicketAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
string fileName,
|
||||
string mimeType,
|
||||
long size,
|
||||
string requestId = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.CreateImageUploadTicketAsync(
|
||||
options,
|
||||
appId,
|
||||
wechatOrderId,
|
||||
fileName,
|
||||
mimeType,
|
||||
size,
|
||||
requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将图片二进制直接上传到 COS,返回留言接口需要的回执。
|
||||
/// </summary>
|
||||
public Task<MessageAttachmentReceipt> UploadImageAsync(
|
||||
ImageUploadTicketDto upload,
|
||||
string filePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.UploadImageAsync(upload, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 留言气泡中的公开图片附件。
|
||||
/// </summary>
|
||||
public sealed class ChatAttachmentViewModel : BindableBaseEx
|
||||
{
|
||||
private bool _isLoading;
|
||||
private bool _isLoadFailed;
|
||||
private ImageSource _image;
|
||||
|
||||
public ChatAttachmentViewModel(TicketAttachmentDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
throw new ArgumentNullException(nameof(dto));
|
||||
Id = dto.Id;
|
||||
OriginalName = dto.OriginalName;
|
||||
ContentType = dto.ContentType;
|
||||
ByteSize = dto.ByteSize;
|
||||
ReadUrl = dto.ReadUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附件唯一 ID。
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string OriginalName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片 MIME 类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片字节数。
|
||||
/// </summary>
|
||||
public long ByteSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端生成的短时读取地址。
|
||||
/// </summary>
|
||||
public string ReadUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 已下载并解码的图片;加载完成前为空。
|
||||
/// </summary>
|
||||
public ImageSource Image
|
||||
{
|
||||
get => _image;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _image, value))
|
||||
RaisePropertyChanged(nameof(HasImage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已成功显示图片。
|
||||
/// </summary>
|
||||
public bool HasImage => Image != null;
|
||||
|
||||
/// <summary>
|
||||
/// 图片是否已经确认加载失败。加载完成前保持为 false。
|
||||
/// </summary>
|
||||
public bool IsLoadFailed
|
||||
{
|
||||
get => _isLoadFailed;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _isLoadFailed, value))
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附件名称、大小与加载状态提示。
|
||||
/// </summary>
|
||||
public string ToolTipText
|
||||
{
|
||||
get
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(OriginalName) ? "图片" : OriginalName;
|
||||
var size = FormatSize(ByteSize);
|
||||
if (string.IsNullOrWhiteSpace(ReadUrl))
|
||||
return name + "(" + size + ",暂不可用)";
|
||||
if (_isLoading)
|
||||
return name + "(" + size + ",加载中…)";
|
||||
if (IsLoadFailed)
|
||||
return name + "(" + size + ",加载失败)";
|
||||
return name + "(" + size + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载并解码短时读取地址。失败时保留占位提示,不阻断文字留言展示。
|
||||
/// </summary>
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
if (Image != null || _isLoading)
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(ReadUrl))
|
||||
{
|
||||
IsLoadFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoadFailed = false;
|
||||
_isLoading = true;
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
try
|
||||
{
|
||||
var image = await ProductImageLoader.LoadAsync(ReadUrl);
|
||||
if (image == null)
|
||||
IsLoadFailed = true;
|
||||
else
|
||||
await SetImageAsync(image);
|
||||
}
|
||||
catch
|
||||
{
|
||||
IsLoadFailed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetImageAsync(ImageSource image)
|
||||
{
|
||||
if (image == null)
|
||||
return;
|
||||
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null || dispatcher.CheckAccess())
|
||||
{
|
||||
Image = image;
|
||||
IsLoadFailed = false;
|
||||
return;
|
||||
}
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Image = image;
|
||||
IsLoadFailed = false;
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatSize(long byteSize)
|
||||
{
|
||||
if (byteSize >= 1024 * 1024)
|
||||
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
|
||||
if (byteSize >= 1024)
|
||||
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
|
||||
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
@@ -22,6 +26,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
{
|
||||
if (dto == null)
|
||||
throw new ArgumentNullException(nameof(dto));
|
||||
Attachments = new ObservableCollection<ChatAttachmentViewModel>();
|
||||
MessageId = dto.MessageId;
|
||||
SequenceNo = dto.SequenceNo;
|
||||
SenderType = dto.SenderType;
|
||||
@@ -33,6 +38,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
? "该消息已撤回"
|
||||
: (dto.Text ?? string.Empty);
|
||||
CreatedAt = ParseTimestamp(dto.CreatedAt);
|
||||
ReplaceAttachments(dto.Attachments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,9 +87,18 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
public string Content
|
||||
{
|
||||
get => _content;
|
||||
private set => SetValue(ref _content, value);
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _content, value))
|
||||
RaisePropertyChanged(nameof(HasText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否有可展示的文字内容。
|
||||
/// </summary>
|
||||
public bool HasText => !string.IsNullOrWhiteSpace(Content);
|
||||
|
||||
/// <summary>
|
||||
/// 消息是否已撤回。
|
||||
/// </summary>
|
||||
@@ -106,6 +121,16 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息中的公开图片附件。
|
||||
/// </summary>
|
||||
public ObservableCollection<ChatAttachmentViewModel> Attachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含图片附件。
|
||||
/// </summary>
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 当前消息是否由客户发送。
|
||||
/// </summary>
|
||||
@@ -120,6 +145,50 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
? string.Empty
|
||||
: CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// 使用服务端最新数据更新消息。短时图片地址会随每次查询变化,因此需要刷新附件。
|
||||
/// </summary>
|
||||
public void UpdateFrom(TicketMessageDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
return;
|
||||
|
||||
SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName)
|
||||
? (IsCustomer ? "我" : "客服")
|
||||
: dto.SenderName;
|
||||
IsRecalled = dto.Recalled;
|
||||
Content = dto.Recalled
|
||||
? "该消息已撤回"
|
||||
: (dto.Text ?? string.Empty);
|
||||
CreatedAt = ParseTimestamp(dto.CreatedAt);
|
||||
ReplaceAttachments(dto.Attachments);
|
||||
}
|
||||
|
||||
private void ReplaceAttachments(IEnumerable<TicketAttachmentDto> attachments)
|
||||
{
|
||||
Attachments.Clear();
|
||||
foreach (var dto in attachments ?? Enumerable.Empty<TicketAttachmentDto>())
|
||||
{
|
||||
if (dto == null)
|
||||
continue;
|
||||
Attachments.Add(new ChatAttachmentViewModel(dto));
|
||||
}
|
||||
RaisePropertyChanged(nameof(HasAttachments));
|
||||
_ = LoadAttachmentsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAttachmentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(Attachments.Select(item => item.LoadAsync()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 单张图片加载失败时保留占位提示,不影响文字留言显示。
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset ParseTimestamp(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AllBase.Helper;
|
||||
using AllBase.Helper;
|
||||
using AllBase.Model.DBModel;
|
||||
using Microsoft.Win32;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
@@ -8,8 +9,10 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
@@ -17,6 +20,7 @@ using System.Windows.Threading;
|
||||
using WPFCommon.Models;
|
||||
using WxOrder.Framework.Commons;
|
||||
using WxOrder.Framework.Commons.Dialogs;
|
||||
using WxOrder.Framework.Controls;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
@@ -36,8 +40,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand;
|
||||
private readonly AsyncDelegateCommand _createTicketCommand;
|
||||
private readonly AsyncDelegateCommand _sendMessageCommand;
|
||||
private readonly AsyncDelegateCommand<PendingAttachmentViewModel> _removePendingAttachmentCommand;
|
||||
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
|
||||
private readonly AsyncDelegateCommand _retryRealtimeCommand;
|
||||
private readonly DelegateCommand<FrameworkElement> _addImagesCommand;
|
||||
private readonly HashSet<string> _seenEventIds =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _lastReadSequences =
|
||||
@@ -57,6 +63,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private string _chatText;
|
||||
private string _draftText;
|
||||
private string _ticketSearchText = string.Empty;
|
||||
private int _selectedTicketStatusIndex;
|
||||
private bool _isDraft;
|
||||
private bool _isBusy;
|
||||
private bool _isLoading;
|
||||
@@ -88,6 +95,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
Tickets = new ObservableCollection<WorkOrderSummaryViewModel>();
|
||||
Messages = new ObservableCollection<ChatMessageViewModel>();
|
||||
PendingAttachments = new ObservableCollection<PendingAttachmentViewModel>();
|
||||
TicketView = CollectionViewSource.GetDefaultView(Tickets);
|
||||
TicketView.Filter = FilterTicket;
|
||||
_selectTicketCommand = new AsyncDelegateCommand<WorkOrderSummaryViewModel>(
|
||||
@@ -99,12 +107,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
_sendMessageCommand = new AsyncDelegateCommand(
|
||||
SendMessageAsync,
|
||||
CanSendMessage);
|
||||
_removePendingAttachmentCommand =
|
||||
new AsyncDelegateCommand<PendingAttachmentViewModel>(
|
||||
RemovePendingAttachmentAsync,
|
||||
attachment => attachment != null && !IsBusy);
|
||||
_loadEarlierMessagesCommand = new AsyncDelegateCommand(
|
||||
LoadEarlierMessagesAsync,
|
||||
() => !IsBusy && SelectedTicket != null && HasMoreMessages);
|
||||
_retryRealtimeCommand = new AsyncDelegateCommand(
|
||||
RestartRealtimeAsync,
|
||||
() => !IsBusy && _configured);
|
||||
() => _configured);
|
||||
_addImagesCommand = new DelegateCommand<FrameworkElement>(
|
||||
AddImages,
|
||||
_ => CanAddImage);
|
||||
_ticketService.NotificationReceived += OnNotificationReceived;
|
||||
_ticketService.StateChanged += OnRealtimeStateChanged;
|
||||
SseState = _ticketService.RealtimeState;
|
||||
@@ -144,6 +159,40 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
/// </summary>
|
||||
public ICommand SendMessageCommand => _sendMessageCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 移除待发送图片的命令。
|
||||
/// </summary>
|
||||
public ICommand RemovePendingAttachmentCommand => _removePendingAttachmentCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 当前留言待发送的本地图片,最多 3 张。
|
||||
/// </summary>
|
||||
public ObservableCollection<PendingAttachmentViewModel> PendingAttachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已选择待发送图片。
|
||||
/// </summary>
|
||||
public bool HasPendingAttachments => PendingAttachments.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 是否还可以继续选择图片。
|
||||
/// </summary>
|
||||
public bool CanAddImage =>
|
||||
!IsBusy
|
||||
&& SelectedTicket != null
|
||||
&& PendingAttachments.Count < TicketApiService.MaxImageCount;
|
||||
|
||||
/// <summary>
|
||||
/// 选择本地图片并加入当前留言的待发送列表。
|
||||
/// </summary>
|
||||
public ICommand AddImagesCommand => _addImagesCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 待发送图片数量展示文本。
|
||||
/// </summary>
|
||||
public string AttachmentCountText =>
|
||||
PendingAttachments.Count + " / " + TicketApiService.MaxImageCount;
|
||||
|
||||
/// <summary>
|
||||
/// 加载当前工单更早留言的命令。
|
||||
/// </summary>
|
||||
@@ -154,10 +203,23 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
/// </summary>
|
||||
public ICommand RetryRealtimeCommand => _retryRealtimeCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 当前正在查看的历史工单。
|
||||
/// </summary>
|
||||
public WorkOrderSummaryViewModel SelectedTicket
|
||||
/// <summary>
|
||||
/// 显示图片
|
||||
/// </summary>
|
||||
public ICommand ShowImageWindowCommand => new DelegateCommand<ChatAttachmentViewModel>(ShowImageWindow);
|
||||
/// <summary>
|
||||
/// 显示图片
|
||||
/// </summary>
|
||||
/// <param name="chatAttachment"></param>
|
||||
private void ShowImageWindow(ChatAttachmentViewModel chatAttachment)
|
||||
{
|
||||
ImageViewerWindow.OpenDialog(chatAttachment.Image);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前正在查看的历史工单。
|
||||
/// </summary>
|
||||
public WorkOrderSummaryViewModel SelectedTicket
|
||||
{
|
||||
get => _selectedTicket;
|
||||
private set
|
||||
@@ -167,6 +229,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
RaisePropertyChanged(nameof(IsChatVisible));
|
||||
RaisePropertyChanged(nameof(IsEmptyVisible));
|
||||
RaisePropertyChanged(nameof(ComposerPlaceholder));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
RaiseSendCanExecuteChanged();
|
||||
RaiseLoadEarlierCanExecuteChanged();
|
||||
}
|
||||
@@ -240,6 +304,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 历史工单状态页签索引:0 全部、1 待客服回复、2 客服已回复、3 已完结。
|
||||
/// </summary>
|
||||
public int SelectedTicketStatusIndex
|
||||
{
|
||||
get => _selectedTicketStatusIndex;
|
||||
set
|
||||
{
|
||||
if (SetValue(ref _selectedTicketStatusIndex, value))
|
||||
TicketView?.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前是否处于新建工单草稿状态。
|
||||
/// </summary>
|
||||
@@ -253,6 +330,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
RaisePropertyChanged(nameof(IsChatVisible));
|
||||
RaisePropertyChanged(nameof(IsEmptyVisible));
|
||||
RaisePropertyChanged(nameof(IsDraftVisible));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
RaiseCreateCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -269,11 +348,14 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
if (SetValue(ref _isBusy, value))
|
||||
{
|
||||
RaisePropertyChanged(nameof(IsNotBusy));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
RaiseSendCanExecuteChanged();
|
||||
RaiseCreateCanExecuteChanged();
|
||||
_removePendingAttachmentCommand.RaiseCanExecuteChanged();
|
||||
RaiseLoadEarlierCanExecuteChanged();
|
||||
_retryRealtimeCommand.RaiseCanExecuteChanged();
|
||||
_selectTicketCommand.RaiseCanExecuteChanged();
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey))
|
||||
_ = SelectPendingTicketAsync();
|
||||
}
|
||||
@@ -624,6 +706,146 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
CancelDraft();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把文件选择器返回的本地图片加入待发送列表。
|
||||
/// 每次留言最多 3 张,单张不超过 5 MiB。
|
||||
/// </summary>
|
||||
public void AddPendingImages(IEnumerable<string> filePaths)
|
||||
{
|
||||
if (filePaths == null || IsBusy || IsDraft || SelectedTicket == null)
|
||||
return;
|
||||
|
||||
var errors = new List<string>();
|
||||
var added = 0;
|
||||
foreach (var filePath in filePaths)
|
||||
{
|
||||
if (PendingAttachments.Count >= TicketApiService.MaxImageCount)
|
||||
{
|
||||
errors.Add("每次留言最多上传 3 张图片。");
|
||||
break;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
continue;
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
errors.Add("图片路径无效:" + filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PendingAttachments.Any(item =>
|
||||
string.Equals(item.FilePath, fullPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!PendingAttachmentViewModel.TryCreate(
|
||||
fullPath,
|
||||
out var attachment,
|
||||
out var error))
|
||||
{
|
||||
errors.Add(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
PendingAttachments.Add(attachment);
|
||||
added++;
|
||||
}
|
||||
|
||||
OnPendingAttachmentsChanged();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SetNotice(
|
||||
string.Join(" ", errors.Distinct(StringComparer.Ordinal)),
|
||||
true);
|
||||
}
|
||||
else if (added > 0)
|
||||
{
|
||||
ClearNotice();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将剪贴板中的位图加入待发送列表。
|
||||
/// </summary>
|
||||
public void AddPendingImage(System.Windows.Media.Imaging.BitmapSource bitmap)
|
||||
{
|
||||
if (IsBusy || IsDraft || SelectedTicket == null)
|
||||
return;
|
||||
if (PendingAttachments.Count >= TicketApiService.MaxImageCount)
|
||||
{
|
||||
SetNotice("每次留言最多上传 3 张图片。", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PendingAttachmentViewModel.TryCreateTemporary(
|
||||
bitmap, out var attachment, out var error))
|
||||
{
|
||||
SetNotice(error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
PendingAttachments.Add(attachment);
|
||||
OnPendingAttachmentsChanged();
|
||||
ClearNotice();
|
||||
}
|
||||
|
||||
private void AddImages(FrameworkElement commandSource)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择图片",
|
||||
Filter = "图片文件 (*.jpg;*.jpeg;*.png;*.webp)|*.jpg;*.jpeg;*.png;*.webp",
|
||||
CheckFileExists = true,
|
||||
Multiselect = true
|
||||
};
|
||||
|
||||
var owner = commandSource == null ? null : Window.GetWindow(commandSource);
|
||||
var selected = owner == null
|
||||
? dialog.ShowDialog()
|
||||
: dialog.ShowDialog(owner);
|
||||
if (selected == true)
|
||||
AddPendingImages(dialog.FileNames);
|
||||
}
|
||||
|
||||
private Task RemovePendingAttachmentAsync(
|
||||
PendingAttachmentViewModel attachment)
|
||||
{
|
||||
if (attachment == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (PendingAttachments.Remove(attachment))
|
||||
attachment.Dispose();
|
||||
OnPendingAttachmentsChanged();
|
||||
ClearNotice();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ClearPendingAttachments()
|
||||
{
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.Dispose();
|
||||
if (PendingAttachments.Count > 0)
|
||||
PendingAttachments.Clear();
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
OnPendingAttachmentsChanged();
|
||||
}
|
||||
|
||||
private void OnPendingAttachmentsChanged()
|
||||
{
|
||||
RaisePropertyChanged(nameof(HasPendingAttachments));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
RaisePropertyChanged(nameof(AttachmentCountText));
|
||||
RaiseSendCanExecuteChanged();
|
||||
_removePendingAttachmentCommand.RaiseCanExecuteChanged();
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
public void Configure(TicketClientOptions options)
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -652,6 +874,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
DraftText = string.Empty;
|
||||
Tickets.Clear();
|
||||
Messages.Clear();
|
||||
ClearPendingAttachments();
|
||||
|
||||
if (_options.AppIds == null || _options.AppIds.Count == 0)
|
||||
{
|
||||
@@ -826,6 +1049,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
return;
|
||||
SaveWorkOrderList();
|
||||
_disposed = true;
|
||||
ClearPendingAttachments();
|
||||
_ticketService.NotificationReceived -= OnNotificationReceived;
|
||||
_ticketService.StateChanged -= OnRealtimeStateChanged;
|
||||
_ticketService.Dispose();
|
||||
@@ -842,6 +1066,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
IsDraft = true;
|
||||
SelectedTicket = null;
|
||||
Messages.Clear();
|
||||
ClearPendingAttachments();
|
||||
HasMoreMessages = false;
|
||||
_nextBefore = null;
|
||||
DraftText = string.Empty;
|
||||
@@ -861,6 +1086,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
IsDraft = false;
|
||||
SelectedOrder = null;
|
||||
DraftText = string.Empty;
|
||||
ClearPendingAttachments();
|
||||
_pendingCreateFingerprint = null;
|
||||
_pendingCreateRequestId = null;
|
||||
ClearNotice();
|
||||
@@ -904,6 +1130,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
TicketSearchText = string.Empty;
|
||||
CancelDraft();
|
||||
ClearPendingAttachments();
|
||||
ApplyKnownProductInfo(ticket);
|
||||
SelectedTicket = ticket;
|
||||
await LoadMessagesAsync(ticket, replace: true);
|
||||
@@ -987,7 +1214,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
var ticket = SelectedTicket;
|
||||
var text = (ChatText ?? string.Empty).Trim();
|
||||
var fingerprint = ticket.Key + "|" + text;
|
||||
var pendingAttachments = PendingAttachments.ToList();
|
||||
var attachmentFingerprint = string.Join(
|
||||
"|",
|
||||
pendingAttachments.Select(item => item.Fingerprint));
|
||||
var fingerprint = ticket.Key + "|" + text + "|" + attachmentFingerprint;
|
||||
if (!string.Equals(
|
||||
fingerprint,
|
||||
_pendingMessageFingerprint,
|
||||
@@ -1001,32 +1232,88 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
ClearNotice();
|
||||
try
|
||||
{
|
||||
var receipts = new List<MessageAttachmentReceipt>();
|
||||
for (var index = 0; index < pendingAttachments.Count; index++)
|
||||
{
|
||||
var attachment = pendingAttachments[index];
|
||||
if (!attachment.HasReceipt)
|
||||
{
|
||||
SetNotice(
|
||||
"正在上传图片 "
|
||||
+ (index + 1)
|
||||
+ "/"
|
||||
+ pendingAttachments.Count
|
||||
+ "…",
|
||||
false);
|
||||
var uploadTicket =
|
||||
await _ticketService.CreateImageUploadTicketAsync(
|
||||
_options,
|
||||
ticket.AppId,
|
||||
ticket.WechatOrderId,
|
||||
attachment.FileName,
|
||||
attachment.MimeType,
|
||||
attachment.ByteSize);
|
||||
var receipt = await _ticketService.UploadImageAsync(
|
||||
uploadTicket.Upload,
|
||||
attachment.FilePath);
|
||||
attachment.SetReceipt(receipt);
|
||||
}
|
||||
receipts.Add(attachment.Receipt);
|
||||
}
|
||||
|
||||
if (pendingAttachments.Count > 0)
|
||||
SetNotice("正在发送留言…", false);
|
||||
|
||||
var result = await _ticketService.SendMessageAsync(
|
||||
_options,
|
||||
ToSummaryDto(ticket),
|
||||
text,
|
||||
_pendingMessageId);
|
||||
string.IsNullOrWhiteSpace(text) ? null : text,
|
||||
_pendingMessageId,
|
||||
receipts);
|
||||
|
||||
if (result.Message != null)
|
||||
AppendMessage(result.Message);
|
||||
ticket.ApplySendResult(result.Status, result.Version);
|
||||
MoveTicketToTop(ticket);
|
||||
TicketView.Refresh();
|
||||
ChatText = string.Empty;
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
ClearPendingAttachments();
|
||||
SetNotice(
|
||||
result.Reopened
|
||||
? "留言已发送,已完结工单已重新开启。"
|
||||
: "留言已发送。",
|
||||
: (pendingAttachments.Count > 0
|
||||
? "图片已发送。"
|
||||
: "留言已发送。"),
|
||||
false);
|
||||
await MarkReadBestEffortAsync(ticket);
|
||||
SaveWorkOrderList();
|
||||
MessageScrollVersion++;
|
||||
}
|
||||
catch (TicketApiException ex)
|
||||
when (ex.StatusCode == 409)
|
||||
when (ex.StatusCode == 410
|
||||
|| string.Equals(
|
||||
ex.ErrorCode,
|
||||
"client_api_image_expired",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetNotice("工单状态或数据版本发生冲突,正在刷新最新内容。", true);
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.ClearReceipt();
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
SetNotice(
|
||||
"图片上传凭证已过期,已重置上传状态,请重新点击发送。",
|
||||
true);
|
||||
}
|
||||
catch (TicketApiException ex)
|
||||
when (ex.StatusCode == 409
|
||||
|| string.Equals(
|
||||
ex.ErrorCode,
|
||||
"client_api_image_already_used",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.ClearReceipt();
|
||||
SetNotice("工单状态或图片回执发生冲突,正在刷新最新内容。", true);
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
await RefreshAllWorkOrdersAsync(
|
||||
@@ -1256,6 +1543,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
ApplyKnownProductInfo(existing);
|
||||
SaveWorkOrderList();
|
||||
ReorderTickets();
|
||||
TicketView.Refresh();
|
||||
if (SelectedTicket != null
|
||||
&& string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal)
|
||||
&& !IsDraft)
|
||||
@@ -1276,7 +1564,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
var id = dto.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
id = "sequence:" + dto.SequenceNo;
|
||||
if (!byId.ContainsKey(id))
|
||||
if (byId.TryGetValue(id, out var existing))
|
||||
existing.UpdateFrom(dto);
|
||||
else
|
||||
byId[id] = new ChatMessageViewModel(dto);
|
||||
}
|
||||
|
||||
@@ -1450,6 +1740,13 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
if (!(item is WorkOrderSummaryViewModel ticket))
|
||||
return false;
|
||||
|
||||
var expectedStatus = GetStatusFilter(SelectedTicketStatusIndex);
|
||||
if (expectedStatus != null
|
||||
&& !string.Equals(ticket.Status, expectedStatus, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyword = (TicketSearchText ?? string.Empty).Trim();
|
||||
if (keyword.Length == 0)
|
||||
return true;
|
||||
@@ -1461,6 +1758,21 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|| ContainsIgnoreCase(ticket.PreviewText, keyword);
|
||||
}
|
||||
|
||||
private static string GetStatusFilter(int selectedIndex)
|
||||
{
|
||||
switch (selectedIndex)
|
||||
{
|
||||
case 1:
|
||||
return "waiting_reply";
|
||||
case 2:
|
||||
return "replied";
|
||||
case 3:
|
||||
return "completed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string source, string keyword)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(source)
|
||||
@@ -1469,10 +1781,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
private bool CanSendMessage()
|
||||
{
|
||||
var text = (ChatText ?? string.Empty).Trim();
|
||||
return !IsBusy
|
||||
&& SelectedTicket != null
|
||||
&& !string.IsNullOrWhiteSpace(ChatText)
|
||||
&& ChatText.Trim().Length <= 4000;
|
||||
&& (!string.IsNullOrWhiteSpace(text) || PendingAttachments.Count > 0)
|
||||
&& text.Length <= 4000;
|
||||
}
|
||||
|
||||
private void RaiseSendCanExecuteChanged()
|
||||
@@ -1768,9 +2081,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
{
|
||||
400 => "请求格式错误。",
|
||||
404 => "该 APPID 和订单下尚无工单。",
|
||||
409 => "数据版本或幂等编号冲突。",
|
||||
409 => "数据版本、幂等编号或图片回执冲突。",
|
||||
410 => "图片上传凭证已过期。",
|
||||
413 => "请求内容超过大小限制。",
|
||||
422 => "参数或订单归属校验失败。",
|
||||
422 => "参数、图片格式或订单归属校验失败。",
|
||||
429 => "请求过于频繁,请稍后重试。",
|
||||
503 => "工单服务暂未开启或数据库繁忙。",
|
||||
_ => null
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 发送留言前已选择、待上传的本地图片。
|
||||
/// </summary>
|
||||
public sealed class PendingAttachmentViewModel : BindableBaseEx, IDisposable
|
||||
{
|
||||
private const long MaxImageBytes = TicketApiService.MaxImageBytes;
|
||||
private ImageSource _preview;
|
||||
private MessageAttachmentReceipt _receipt;
|
||||
private string _receiptFingerprint;
|
||||
private readonly bool _deleteFileOnDispose;
|
||||
private bool _disposed;
|
||||
|
||||
private PendingAttachmentViewModel(
|
||||
string filePath,
|
||||
string fileName,
|
||||
string mimeType,
|
||||
long byteSize,
|
||||
DateTime lastWriteTimeUtc,
|
||||
bool deleteFileOnDispose = false)
|
||||
{
|
||||
FilePath = filePath;
|
||||
FileName = fileName;
|
||||
MimeType = mimeType;
|
||||
ByteSize = byteSize;
|
||||
_deleteFileOnDispose = deleteFileOnDispose;
|
||||
Fingerprint = filePath.ToLowerInvariant()
|
||||
+ "|"
|
||||
+ byteSize.ToString(CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
Preview = LoadPreview(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 本地绝对路径,仅用于读取并直传,不提交给业务接口。
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片 MIME 类型。
|
||||
/// </summary>
|
||||
public string MimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片字节数。
|
||||
/// </summary>
|
||||
public long ByteSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 本地文件内容标识,用于判断是否需要重新上传。
|
||||
/// </summary>
|
||||
public string Fingerprint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 本地缩略图;WebP 等当前 WPF 解码器不支持时为空。
|
||||
/// </summary>
|
||||
public ImageSource Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _preview, value))
|
||||
RaisePropertyChanged(nameof(HasPreview));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已加载本地缩略图。
|
||||
/// </summary>
|
||||
public bool HasPreview => Preview != null;
|
||||
|
||||
/// <summary>
|
||||
/// 当前文件是否已有可复用的上传回执。
|
||||
/// </summary>
|
||||
public bool HasReceipt =>
|
||||
Receipt != null
|
||||
&& string.Equals(_receiptFingerprint, Fingerprint, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 上传回执;仅在本类内部更新。
|
||||
/// </summary>
|
||||
public MessageAttachmentReceipt Receipt
|
||||
{
|
||||
get => _receipt;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _receipt, value))
|
||||
RaisePropertyChanged(nameof(HasReceipt));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图片大小展示文本。
|
||||
/// </summary>
|
||||
public string SizeText => FormatSize(ByteSize);
|
||||
|
||||
/// <summary>
|
||||
/// 保存本次上传成功的回执,后续网络重试可直接复用。
|
||||
/// </summary>
|
||||
public void SetReceipt(MessageAttachmentReceipt receipt)
|
||||
{
|
||||
if (receipt == null)
|
||||
throw new ArgumentNullException(nameof(receipt));
|
||||
_receiptFingerprint = Fingerprint;
|
||||
Receipt = receipt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除已失效的上传回执,下次发送时重新申请凭证。
|
||||
/// </summary>
|
||||
public void ClearReceipt()
|
||||
{
|
||||
_receiptFingerprint = null;
|
||||
Receipt = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并创建本地待发送图片。
|
||||
/// </summary>
|
||||
public static bool TryCreate(
|
||||
string filePath,
|
||||
out PendingAttachmentViewModel attachment,
|
||||
out string error)
|
||||
{
|
||||
attachment = null;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
error = "图片路径为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
FileInfo file;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(filePath);
|
||||
file = new FileInfo(fullPath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
error = "图片文件不存在:" + Path.GetFileName(filePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = "无法读取图片文件:" + ex.Message;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetMimeType(file.Extension, out var mimeType))
|
||||
{
|
||||
error = "仅支持 JPG、PNG、WebP 图片:" + file.Name;
|
||||
return false;
|
||||
}
|
||||
if (file.Length < 1 || file.Length > MaxImageBytes)
|
||||
{
|
||||
error = "单张图片必须大于 0 且不超过 5 MiB:" + file.Name;
|
||||
return false;
|
||||
}
|
||||
|
||||
attachment = new PendingAttachmentViewModel(
|
||||
fullPath,
|
||||
file.Name,
|
||||
mimeType,
|
||||
file.Length,
|
||||
file.LastWriteTimeUtc);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将剪贴板位图保存为临时 PNG,并创建可上传的附件。
|
||||
/// </summary>
|
||||
public static bool TryCreateTemporary(
|
||||
BitmapSource bitmap,
|
||||
out PendingAttachmentViewModel attachment,
|
||||
out string error)
|
||||
{
|
||||
attachment = null;
|
||||
error = null;
|
||||
if (bitmap == null)
|
||||
{
|
||||
error = "剪贴板中没有可用的图片。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string filePath = null;
|
||||
try
|
||||
{
|
||||
filePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"WxOrder-Clipboard-" + Guid.NewGuid().ToString("N") + ".png");
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using (var stream = File.Create(filePath))
|
||||
encoder.Save(stream);
|
||||
|
||||
var file = new FileInfo(filePath);
|
||||
if (file.Length < 1 || file.Length > MaxImageBytes)
|
||||
{
|
||||
error = "粘贴的图片必须大于 0 且不超过 5 MiB。";
|
||||
TryDelete(filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
attachment = new PendingAttachmentViewModel(
|
||||
file.FullName,
|
||||
"粘贴图片.png",
|
||||
"image/png",
|
||||
file.Length,
|
||||
file.LastWriteTimeUtc,
|
||||
deleteFileOnDispose: true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDelete(filePath);
|
||||
error = "无法读取剪贴板图片:" + ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
if (_deleteFileOnDispose)
|
||||
TryDelete(FilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据扩展名映射文档要求的精确 MIME 类型。
|
||||
/// </summary>
|
||||
public static bool TryGetMimeType(string extension, out string mimeType)
|
||||
{
|
||||
switch ((extension ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
return true;
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
return true;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
return true;
|
||||
default:
|
||||
mimeType = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImageSource LoadPreview(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var image = new BitmapImage();
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
|
||||
image.DecodePixelWidth = 160;
|
||||
image.StreamSource = stream;
|
||||
image.EndInit();
|
||||
}
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 临时文件清理失败不影响留言发送流程。
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long byteSize)
|
||||
{
|
||||
if (byteSize >= 1024 * 1024)
|
||||
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
|
||||
if (byteSize >= 1024)
|
||||
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
|
||||
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
||||
<SolidColorBrush x:Key="BrandBrush" Color="#98638F" />
|
||||
<SolidColorBrush x:Key="BrandSoftBrush" Color="#F3EAF2" />
|
||||
<SolidColorBrush x:Key="LineBrush" Color="#DDE2E8" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#1F2933" />
|
||||
<SolidColorBrush x:Key="SubTextBrush" Color="#6B7785" />
|
||||
<SolidColorBrush x:Key="PriceBrush" Color="#C2410C" />
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#EEF1F4" />
|
||||
<SolidColorBrush x:Key="PanelBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="OtherBubbleBrush" Color="#F1F3F6" />
|
||||
<SolidColorBrush x:Key="WaitingBrush" Color="#B45309" />
|
||||
<SolidColorBrush x:Key="WaitingBackgroundBrush" Color="#FDF0DA" />
|
||||
<SolidColorBrush x:Key="RepliedBrush" Color="#1D5FA8" />
|
||||
<SolidColorBrush x:Key="RepliedBackgroundBrush" Color="#E2EDF9" />
|
||||
<SolidColorBrush x:Key="DoneBrush" Color="#5B6673" />
|
||||
<SolidColorBrush x:Key="DoneBackgroundBrush" Color="#E9ECF0" />
|
||||
|
||||
<Style x:Key="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}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BrandBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14,6" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border
|
||||
x:Name="Root"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="4">
|
||||
<ContentPresenter
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Root" Property="Opacity" Value="0.88" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Root" Property="Opacity" Value="0.72" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Root" Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="GhostButtonStyle" TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{StaticResource BrandBrush}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BrandBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="12,5" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border
|
||||
x:Name="Root"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="4">
|
||||
<ContentPresenter
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Root" Property="Background" Value="{StaticResource BrandSoftBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Root" Property="Opacity" Value="0.42" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="StatusTagStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource DoneBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Status}" Value="waiting_reply">
|
||||
<Setter Property="Background" Value="{StaticResource WaitingBackgroundBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Status}" Value="replied">
|
||||
<Setter Property="Background" Value="{StaticResource RepliedBackgroundBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource DoneBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Status}" Value="waiting_reply">
|
||||
<Setter Property="Foreground" Value="{StaticResource WaitingBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Status}" Value="replied">
|
||||
<Setter Property="Foreground" Value="{StaticResource RepliedBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
<Style x:Key="TicketListItemStyle" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border
|
||||
x:Name="Root"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="3,0,0,0">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Root" Property="Background" Value="{StaticResource BackgroundBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Root" Property="Background" Value="{StaticResource BrandSoftBrush}" />
|
||||
<Setter TargetName="Root" Property="BorderBrush" Value="{StaticResource BrandBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="ChatListItemStyle" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<ContentPresenter />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="AttachmentRemoveButtonStyle" TargetType="Button">
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="Background" Value="#CC1F2933" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border
|
||||
x:Name="Root"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="10">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Root" Property="Background" Value="#E61F2933" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="StatusTabItemStyle" TargetType="TabItem">
|
||||
<Setter Property="Padding" Value="6,6" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{StaticResource SubTextBrush}" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="TabItem">
|
||||
<Border
|
||||
x:Name="Root"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="Transparent"
|
||||
BorderBrush="{StaticResource BrandBrush}"
|
||||
BorderThickness="0,0,0,2">
|
||||
<ContentPresenter
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
ContentSource="Header"
|
||||
RecognizesAccessKey="True" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource BrandBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="False">
|
||||
<Setter TargetName="Root" Property="BorderBrush" Value="Transparent" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Root" Property="Background" Value="{StaticResource BrandSoftBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="ChatScrollBarStyle" TargetType="ScrollBar">
|
||||
<Setter Property="Width" Value="8" />
|
||||
<Setter Property="MinWidth" Value="8" />
|
||||
<Setter Property="MaxWidth" Value="8" />
|
||||
<Setter Property="Margin" Value="0,4,2,4" />
|
||||
<Setter Property="Opacity" Value="0.65" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="Transparent">
|
||||
<Track
|
||||
x:Name="PART_Track"
|
||||
Focusable="False"
|
||||
IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton
|
||||
Command="ScrollBar.PageUpCommand"
|
||||
Focusable="False"
|
||||
Opacity="0" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb MinHeight="32">
|
||||
<Thumb.Template>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border
|
||||
x:Name="ThumbBorder"
|
||||
Margin="1,0"
|
||||
Background="#AAB4C0"
|
||||
CornerRadius="3" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="ThumbBorder" Property="Background" Value="{StaticResource BrandBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsDragging" Value="True">
|
||||
<Setter TargetName="ThumbBorder" Property="Background" Value="#7F4F77" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Thumb.Template>
|
||||
</Thumb>
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton
|
||||
Command="ScrollBar.PageDownCommand"
|
||||
Focusable="False"
|
||||
Opacity="0" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,234 +1,105 @@
|
||||
using _29WxOrderWpf.ToolCtrls;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Services;
|
||||
using OrderSupportTicket.ViewModels;
|
||||
using System.Runtime.InteropServices;
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace OrderSupportTicket.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 工单对话页面。页面生命周期只负责激活/停用 ViewModel,业务交互均在 ViewModel 中。
|
||||
/// 工单页面外壳,只负责组合子视图以及管理页面生命周期。
|
||||
/// </summary>
|
||||
public partial class OrderSupportTicketView : UserControl
|
||||
{
|
||||
private bool _active;
|
||||
private Window _hostWindow;
|
||||
private bool _active;
|
||||
private Window _hostWindow;
|
||||
|
||||
public OrderSupportTicketView()
|
||||
: this(new TicketService(), CreateDefaultOptions())
|
||||
{
|
||||
}
|
||||
public OrderSupportTicketView() : this(new TicketService(), CreateDefaultOptions()) { }
|
||||
|
||||
/// <summary>
|
||||
/// 使用工单业务服务创建工单页。
|
||||
/// </summary>
|
||||
public OrderSupportTicketView(
|
||||
TicketService ticketService,
|
||||
TicketClientOptions options = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
ViewModel = new OrderSupportTicketViewModel(
|
||||
ticketService,
|
||||
options ?? CreateDefaultOptions());
|
||||
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>
|
||||
public OrderSupportTicketViewModel ViewModel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 宿主可在页面加载前注入账号、APPID 与本地订单数据。
|
||||
/// </summary>
|
||||
public void Configure(TicketClientOptions options)
|
||||
{
|
||||
ViewModel.Configure(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从订单页直接打开新工单草稿。
|
||||
/// </summary>
|
||||
public void OpenNewTicket(TicketOrderOption order)
|
||||
{
|
||||
ViewModel.OpenNewTicket(order);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
if (ClipboardHelper.TrySetText(orderId))
|
||||
{
|
||||
ToastHelper.Show(window, "订单号已复制");
|
||||
}
|
||||
else
|
||||
{
|
||||
ToastHelper.Show(window, "复制失败");
|
||||
}
|
||||
public OrderSupportTicketView(TicketService ticketService, TicketClientOptions options = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
ViewModel = new OrderSupportTicketViewModel(ticketService, options ?? CreateDefaultOptions());
|
||||
DataContext = ViewModel;
|
||||
Loaded += OnLoaded;
|
||||
Unloaded += OnUnloaded;
|
||||
}
|
||||
|
||||
private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
|
||||
{
|
||||
if (_active)
|
||||
return;
|
||||
_active = true;
|
||||
AttachHostWindow(Window.GetWindow(this));
|
||||
UpdateWindowActiveState();
|
||||
await ViewModel.InitializeAsync();
|
||||
}
|
||||
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" };
|
||||
|
||||
private async void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
_active = false;
|
||||
ViewModel.IsWindowActive = false;
|
||||
DetachHostWindow();
|
||||
await ViewModel.DeactivateAsync();
|
||||
}
|
||||
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) }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private void AttachHostWindow(Window window)
|
||||
{
|
||||
DetachHostWindow();
|
||||
_hostWindow = window;
|
||||
if (_hostWindow == null)
|
||||
return;
|
||||
public OrderSupportTicketViewModel ViewModel { get; }
|
||||
public void Configure(TicketClientOptions options) => ViewModel.Configure(options);
|
||||
public void OpenNewTicket(TicketOrderOption order) => ViewModel.OpenNewTicket(order);
|
||||
public void OpenTicket(string appId, string wechatOrderId) => ViewModel.OpenTicket(appId, wechatOrderId);
|
||||
|
||||
_hostWindow.Activated += OnHostWindowActivated;
|
||||
_hostWindow.Deactivated += OnHostWindowDeactivated;
|
||||
_hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
|
||||
_hostWindow.StateChanged += OnHostWindowStateChanged;
|
||||
_hostWindow.Closed += OnHostWindowClosed;
|
||||
}
|
||||
private async void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_active) return;
|
||||
_active = true;
|
||||
AttachHostWindow(Window.GetWindow(this));
|
||||
UpdateWindowActiveState();
|
||||
await ViewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
private void DetachHostWindow()
|
||||
{
|
||||
if (_hostWindow == null)
|
||||
return;
|
||||
private async void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_active) return;
|
||||
_active = false;
|
||||
ViewModel.IsWindowActive = false;
|
||||
DetachHostWindow();
|
||||
await ViewModel.DeactivateAsync();
|
||||
}
|
||||
|
||||
_hostWindow.Activated -= OnHostWindowActivated;
|
||||
_hostWindow.Deactivated -= OnHostWindowDeactivated;
|
||||
_hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
|
||||
_hostWindow.StateChanged -= OnHostWindowStateChanged;
|
||||
_hostWindow.Closed -= OnHostWindowClosed;
|
||||
_hostWindow = null;
|
||||
}
|
||||
private void AttachHostWindow(Window window)
|
||||
{
|
||||
DetachHostWindow();
|
||||
_hostWindow = window;
|
||||
if (_hostWindow == null) return;
|
||||
_hostWindow.Activated += OnHostWindowStateChanged;
|
||||
_hostWindow.Deactivated += OnHostWindowStateChanged;
|
||||
_hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
|
||||
_hostWindow.StateChanged += OnHostWindowStateChanged;
|
||||
_hostWindow.Closed += OnHostWindowClosed;
|
||||
}
|
||||
|
||||
private void UpdateWindowActiveState()
|
||||
{
|
||||
var window = _hostWindow;
|
||||
ViewModel.IsWindowActive = window != null
|
||||
&& window.IsVisible
|
||||
&& window.IsActive
|
||||
&& window.WindowState != WindowState.Minimized;
|
||||
}
|
||||
private void DetachHostWindow()
|
||||
{
|
||||
if (_hostWindow == null) return;
|
||||
_hostWindow.Activated -= OnHostWindowStateChanged;
|
||||
_hostWindow.Deactivated -= OnHostWindowStateChanged;
|
||||
_hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
|
||||
_hostWindow.StateChanged -= OnHostWindowStateChanged;
|
||||
_hostWindow.Closed -= OnHostWindowClosed;
|
||||
_hostWindow = null;
|
||||
}
|
||||
|
||||
private void OnHostWindowActivated(object sender, EventArgs e)
|
||||
{
|
||||
UpdateWindowActiveState();
|
||||
}
|
||||
private void UpdateWindowActiveState()
|
||||
{
|
||||
ViewModel.IsWindowActive = _hostWindow != null && _hostWindow.IsVisible && _hostWindow.IsActive && _hostWindow.WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
private void OnHostWindowStateChanged(object sender, EventArgs e) => UpdateWindowActiveState();
|
||||
private void OnHostWindowIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) => UpdateWindowActiveState();
|
||||
private void OnHostWindowClosed(object sender, EventArgs e) { ViewModel.IsWindowActive = false; DetachHostWindow(); }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<UserControl
|
||||
x:Class="OrderSupportTicket.Views.TicketComposerView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:behaviors="clr-namespace:OrderSupportTicket.Behaviors"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:OrderSupportTicket.ViewModels"
|
||||
xmlns:wpf="WPFCommon">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="OrderSupportTicketStyles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<wpf:CommandProxy x:Key="AddImagesCommand" Command="{Binding AddImagesCommand}" />
|
||||
</ResourceDictionary>
|
||||
</UserControl.Resources>
|
||||
<Grid>
|
||||
<Border
|
||||
Padding="18,12"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="0,1,0,0"
|
||||
Visibility="{Binding IsChatVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<Border
|
||||
Padding="12,10"
|
||||
Background="White"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ItemsControl
|
||||
Margin="0,0,0,8"
|
||||
ItemsSource="{Binding PendingAttachments}"
|
||||
Visibility="{Binding HasPendingAttachments, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:PendingAttachmentViewModel}">
|
||||
<Grid
|
||||
Width="76"
|
||||
Height="76"
|
||||
Margin="0,0,8,4"
|
||||
ToolTip="{Binding FileName}">
|
||||
<Border
|
||||
Background="#F1F3F6"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
ClipToBounds="True"
|
||||
CornerRadius="5">
|
||||
<Grid>
|
||||
<Image Source="{Binding Preview}" Stretch="UniformToFill" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button
|
||||
Margin="0,-6,-6,0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Command="{Binding DataContext.RemovePendingAttachmentCommand, RelativeSource={RelativeSource AncestorType={x:Type UserControl}}}"
|
||||
CommandParameter="{Binding}"
|
||||
Content="×"
|
||||
Style="{StaticResource AttachmentRemoveButtonStyle}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBox
|
||||
Grid.Row="1"
|
||||
MinHeight="64"
|
||||
MaxHeight="110"
|
||||
AcceptsReturn="True"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
MaxLength="4000"
|
||||
PreviewKeyDown="ChatTextBox_PreviewKeyDown"
|
||||
Text="{Binding ChatText, UpdateSourceTrigger=PropertyChanged}"
|
||||
TextWrapping="Wrap"
|
||||
ToolTip="{Binding ComposerPlaceholder}"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding
|
||||
Key="Enter"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Modifiers="Control" />
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
<Grid Grid.Row="2" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button
|
||||
Height="28"
|
||||
Padding="9,0"
|
||||
Command="{Binding Source={StaticResource AddImagesCommand}}"
|
||||
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType={x:Type UserControl}}}"
|
||||
Content="图片"
|
||||
Style="{StaticResource GhostButtonStyle}" />
|
||||
<TextBlock
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding AttachmentCountText}" />
|
||||
<TextBlock
|
||||
Margin="12,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="Ctrl+Enter 发送" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<TextBlock
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding ChatCharacterCountText}" />
|
||||
<Button
|
||||
Width="72"
|
||||
Height="32"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="发送"
|
||||
Style="{StaticResource PrimaryButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
<Border
|
||||
Padding="18,12"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="0,1,0,0"
|
||||
Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<Border
|
||||
Padding="12,10"
|
||||
Background="White"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox
|
||||
MinHeight="64"
|
||||
MaxHeight="110"
|
||||
AcceptsReturn="True"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
MaxLength="2000"
|
||||
Text="{Binding DraftText, UpdateSourceTrigger=PropertyChanged}"
|
||||
TextWrapping="Wrap"
|
||||
ToolTip="请描述您遇到的问题"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding
|
||||
Key="Enter"
|
||||
Command="{Binding CreateTicketCommand}"
|
||||
Modifiers="Control" />
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
<Grid Grid.Row="1" Margin="0,8,0,0">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="Ctrl+Enter 提交" />
|
||||
<TextBlock
|
||||
Margin="0,0,96,0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding DraftCharacterCountText}" />
|
||||
<Button
|
||||
Width="86"
|
||||
Height="32"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding CreateTicketCommand}"
|
||||
Content="提交工单"
|
||||
Style="{StaticResource PrimaryButtonStyle}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,29 @@
|
||||
using OrderSupportTicket.ViewModels;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace OrderSupportTicket.Views;
|
||||
|
||||
public partial class TicketComposerView : UserControl
|
||||
{
|
||||
public TicketComposerView() => InitializeComponent();
|
||||
|
||||
private void ChatTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
var isPaste = (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control) || (e.Key == Key.Insert && Keyboard.Modifiers == ModifierKeys.Shift);
|
||||
if (!isPaste || DataContext is not OrderSupportTicketViewModel viewModel) return;
|
||||
IDataObject data;
|
||||
try { data = Clipboard.GetDataObject(); } catch (ExternalException) { return; }
|
||||
if (data == null) return;
|
||||
if (data.GetDataPresent(DataFormats.Bitmap) && data.GetData(DataFormats.Bitmap) is BitmapSource bitmap) { viewModel.AddPendingImage(bitmap); e.Handled = true; return; }
|
||||
if (!data.GetDataPresent(DataFormats.FileDrop) || data.GetData(DataFormats.FileDrop) is not string[] files) return;
|
||||
var images = files.Where(path => PendingAttachmentViewModel.TryGetMimeType(System.IO.Path.GetExtension(path), out _)).ToArray();
|
||||
if (images.Length == 0) return;
|
||||
viewModel.AddPendingImages(images);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<UserControl
|
||||
x:Class="OrderSupportTicket.Views.TicketConversationView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:behaviors="clr-namespace:OrderSupportTicket.Behaviors"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:OrderSupportTicket.ViewModels"
|
||||
xmlns:wpf="WPFCommon"
|
||||
d:Background="White"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="OrderSupportTicketStyles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<wpf:CommandProxy x:Key="ShowImageCommand" Command="{Binding ShowImageWindowCommand}" />
|
||||
</ResourceDictionary>
|
||||
</UserControl.Resources>
|
||||
<Grid>
|
||||
<Grid d:Visibility="Visible" Visibility="{Binding IsChatVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Border
|
||||
Padding="18,11"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock
|
||||
FontWeight="SemiBold"
|
||||
MouseLeftButtonUp="CopyOrderId_MouseLeftButtonUp"
|
||||
Style="{StaticResource CopyableOrderIdStyle}"
|
||||
Tag="{Binding SelectedTicket.WechatOrderId}"
|
||||
Text="{Binding SelectedTicket.WechatOrderId}" />
|
||||
<Border
|
||||
Margin="9,0,0,0"
|
||||
DataContext="{Binding SelectedTicket}"
|
||||
Style="{StaticResource StatusTagStyle}">
|
||||
<TextBlock Style="{StaticResource StatusTextStyle}" Text="{Binding StatusText}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Margin="0,3,0,0"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding SelectedTicket.ProductNameText}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock
|
||||
Margin="0,2,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding SelectedTicket.ShopName, StringFormat=店铺 {0}}" />
|
||||
</StackPanel>
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Padding="12,5"
|
||||
Background="Transparent"
|
||||
BorderBrush="#98638F"
|
||||
Command="{Binding LoadEarlierMessagesCommand}"
|
||||
Content="加载更早留言">
|
||||
<Button.Style>
|
||||
<Style BasedOn="{StaticResource GhostButtonStyle}" TargetType="Button">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasMoreMessages}" Value="False">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Grid.Row="1">
|
||||
<ListBox
|
||||
Margin="18,10"
|
||||
behaviors:ListBoxInteractionBehavior.ScrollVersion="{Binding MessageScrollVersion}"
|
||||
d:ItemsSource="{d:SampleData ItemCount=10}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ItemContainerStyle="{StaticResource ChatListItemStyle}"
|
||||
ItemsSource="{Binding Messages}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
VirtualizingPanel.ScrollUnit="Pixel">
|
||||
<ListBox.Resources>
|
||||
<Style BasedOn="{StaticResource ChatScrollBarStyle}" TargetType="ScrollBar" />
|
||||
</ListBox.Resources>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:ChatMessageViewModel}">
|
||||
<Grid Margin="0,5">
|
||||
<StackPanel
|
||||
x:Name="BubbleColumn"
|
||||
MaxWidth="560"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock
|
||||
x:Name="Sender"
|
||||
Margin="1,0,0,3"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"><Run Text="{Binding SenderDisplayName, Mode=OneWay}" /><Run Text=" " /><Run Text="{Binding CreatedAtText, Mode=OneWay}" /></TextBlock>
|
||||
<Border
|
||||
x:Name="Bubble"
|
||||
Padding="13,9"
|
||||
Background="#F1F3F6"
|
||||
CornerRadius="8,8,8,2">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
x:Name="MessageText"
|
||||
Foreground="#1F2933"
|
||||
LineHeight="22"
|
||||
Text="{Binding Content}"
|
||||
TextWrapping="Wrap"
|
||||
Visibility="{Binding HasText, Converter={StaticResource BoolToVisibility}}" />
|
||||
<ItemsControl
|
||||
Margin="0,6,0,0"
|
||||
d:ItemsSource="{d:SampleData ItemCount=1}"
|
||||
ItemsSource="{Binding Attachments}"
|
||||
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:ChatAttachmentViewModel}">
|
||||
<Border
|
||||
Margin="0,0,6,4"
|
||||
Background="#26000000"
|
||||
CornerRadius="5"
|
||||
Cursor="Hand"
|
||||
ToolTip="{Binding ToolTipText}">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding
|
||||
Command="{Binding Source={StaticResource ShowImageCommand}}"
|
||||
CommandParameter="{Binding}"
|
||||
MouseAction="LeftClick" />
|
||||
</Border.InputBindings>
|
||||
<Grid Width="150" Height="150">
|
||||
<Image Source="{Binding Image}" Stretch="Uniform" />
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="50"
|
||||
Text="{StaticResource txt-image}">
|
||||
<TextBlock.Style>
|
||||
<Style BasedOn="{StaticResource TextBlockIcon}" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="#5B6673" />
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding DataContext.IsCustomer, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}}" Value="True">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsLoadFailed}" Value="True">
|
||||
<Setter Property="Text" Value="{StaticResource txt-dismiss_square}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding HasImage}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCustomer}" Value="True">
|
||||
<Setter TargetName="BubbleColumn" Property="HorizontalAlignment" Value="Right" />
|
||||
<Setter TargetName="Bubble" Property="Background" Value="#98638F" />
|
||||
<Setter TargetName="MessageText" Property="Foreground" Value="White" />
|
||||
<Setter TargetName="Sender" Property="HorizontalAlignment" Value="Right" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsRecalled}" Value="True">
|
||||
<Setter TargetName="Bubble" Property="Opacity" Value="0.62" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#6B7785"
|
||||
Text="暂无公开留言">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Messages.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid d:Visibility="Hidden" Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Border
|
||||
Padding="18,11"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel>
|
||||
<TextBlock FontWeight="SemiBold" Text="新建工单" />
|
||||
<TextBlock
|
||||
Margin="0,2,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="提交后进入等待客服回复;已完结订单再次提交会重开原工单。" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Margin="18"
|
||||
Padding="14,12"
|
||||
VerticalAlignment="Top"
|
||||
Background="#FAFBFC"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6">
|
||||
<StackPanel>
|
||||
<TextBlock Foreground="#6B7785" Text="关联订单" />
|
||||
<TextBlock
|
||||
Margin="0,4,0,0"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding SelectedOrderText}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
Margin="0,4,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding SelectedOrderDescription}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<StackPanel
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
d:Visibility="Hidden"
|
||||
Visibility="{Binding IsEmptyVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Foreground="#6B7785" Text="在左侧选择工单查看对话" />
|
||||
<TextBlock
|
||||
Margin="0,6,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="实时通知已订阅当前拍单账号下的全部店铺" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,18 @@
|
||||
using _29WxOrderWpf.ToolCtrls;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Work._29Tools.Forms.ToolForms;
|
||||
|
||||
namespace OrderSupportTicket.Views;
|
||||
|
||||
public partial class TicketConversationView : UserControl
|
||||
{
|
||||
public TicketConversationView() => InitializeComponent();
|
||||
private void CopyOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement element || element.Tag is not string orderId || string.IsNullOrWhiteSpace(orderId)) return;
|
||||
var window = Window.GetWindow(this);
|
||||
if (window != null) ToastHelper.Show(window, ClipboardHelper.TrySetText(orderId) ? "订单号已复制" : "复制失败");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<UserControl
|
||||
x:Class="OrderSupportTicket.Views.TicketSidebarView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:behaviors="clr-namespace:OrderSupportTicket.Behaviors"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="clr-namespace:OrderSupportTicket.Models"
|
||||
xmlns:vm="clr-namespace:OrderSupportTicket.ViewModels"
|
||||
d:Background="White"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<ResourceDictionary Source="OrderSupportTicketStyles.xaml" />
|
||||
</UserControl.Resources>
|
||||
<Border BorderBrush="#DDE2E8" BorderThickness="0,0,1,0">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="40" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<StackPanel
|
||||
Margin="14,0"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal">
|
||||
<TextBlock
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Text="历史工单" />
|
||||
<Border
|
||||
Margin="8,0,0,0"
|
||||
Padding="7,2"
|
||||
Background="#B3261E"
|
||||
CornerRadius="9"
|
||||
Visibility="{Binding HasNewData, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock
|
||||
FontSize="11"
|
||||
Foreground="White"
|
||||
Text="{Binding NewDataText}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid Margin="11,8,14,4">
|
||||
<TextBox
|
||||
Padding="8,5"
|
||||
Background="#FAFBFC"
|
||||
BorderBrush="#DDE2E8"
|
||||
GotFocus="TicketSearchBox_GotFocus"
|
||||
Text="{Binding TicketSearchText, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock
|
||||
Margin="9,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#6B7785"
|
||||
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>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
<TabControl
|
||||
Grid.Row="1"
|
||||
Margin="11,0,14,4"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ItemContainerStyle="{StaticResource StatusTabItemStyle}"
|
||||
SelectedIndex="{Binding SelectedTicketStatusIndex}">
|
||||
<TabItem Header="全部" />
|
||||
<TabItem Header="待客服回复" />
|
||||
<TabItem Header="客服已回复" />
|
||||
<TabItem Header="已完结" />
|
||||
</TabControl>
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Padding="11,10"
|
||||
Background="#F3EAF2"
|
||||
BorderBrush="#98638F"
|
||||
BorderThickness="3,1,0,1"
|
||||
Visibility="{Binding IsDraftVisible, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel>
|
||||
<TextBlock FontWeight="SemiBold" Text="新工单(未提交)" />
|
||||
<TextBlock
|
||||
Margin="0,5,0,0"
|
||||
FontSize="12"
|
||||
MouseLeftButtonUp="CopyOrderId_MouseLeftButtonUp"
|
||||
Style="{StaticResource CopyableOrderIdStyle}"
|
||||
Tag="{Binding SelectedOrder.OrderId}"
|
||||
Text="{Binding SelectedOrder.OrderId, StringFormat=订单 {0}}" />
|
||||
<TextBlock
|
||||
Margin="0,4,0,0"
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="请在右侧描述问题" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<ListBox
|
||||
Grid.Row="3"
|
||||
behaviors:ListBoxInteractionBehavior.SelectedItemCommand="{Binding SelectTicketCommand}"
|
||||
d:ItemsSource="{d:SampleData ItemCount=10}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ItemContainerStyle="{StaticResource TicketListItemStyle}"
|
||||
ItemsSource="{Binding TicketView}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
SelectedItem="{Binding SelectedTicket, Mode=OneWay}">
|
||||
<ListBox.Resources>
|
||||
<Style BasedOn="{StaticResource ChatScrollBarStyle}" TargetType="ScrollBar" />
|
||||
</ListBox.Resources>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:WorkOrderSummaryViewModel}">
|
||||
<Grid Margin="11,10,14,10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextBrush}"
|
||||
Tag="{Binding WechatOrderId}"
|
||||
Text="{Binding WechatOrderId}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border
|
||||
x:Name="UnreadBadge"
|
||||
Grid.Column="1"
|
||||
Height="18"
|
||||
MinWidth="18"
|
||||
Margin="6,-1,0,0"
|
||||
Padding="5,0"
|
||||
Background="{StaticResource BrandBrush}"
|
||||
CornerRadius="9">
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="11"
|
||||
Foreground="White"
|
||||
Text="{Binding UnreadCount}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<Border
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
Style="{StaticResource StatusTagStyle}">
|
||||
<TextBlock Style="{StaticResource StatusTextStyle}" Text="{Binding StatusText}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" Margin="0,7,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="48" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid Width="48" Height="48">
|
||||
<Border
|
||||
Background="#F1F3F6"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="18"
|
||||
Foreground="#9AA5B1"
|
||||
Style="{StaticResource TextBlockIcon}"
|
||||
Text="{StaticResource txt-image}" />
|
||||
</Border>
|
||||
<Border
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Border.Background>
|
||||
<ImageBrush ImageSource="{Binding ProductImage}" Stretch="UniformToFill" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
</Grid>
|
||||
<StackPanel Grid.Column="1" Margin="9,0,0,0">
|
||||
<TextBlock
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
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
|
||||
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>
|
||||
<DataTrigger Binding="{Binding UnreadCount}" Value="0">
|
||||
<Setter TargetName="UnreadBadge" Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Padding="14,9"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderBrush="#DDE2E8"
|
||||
BorderThickness="0,1,0,0"
|
||||
Command="{Binding RetryRealtimeCommand}"
|
||||
Cursor="Hand">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse
|
||||
Width="8"
|
||||
Height="8"
|
||||
Margin="0,0,8,0">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="#D14343" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding SseState}" Value="{x:Static models:TicketRealtimeState.Connected}">
|
||||
<Setter Property="Fill" Value="#2F7D4F" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding SseState}" Value="{x:Static models:TicketRealtimeState.Connecting}">
|
||||
<Setter Property="Fill" Value="#D99A1B" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding SseState}" Value="{x:Static models:TicketRealtimeState.Reconnecting}">
|
||||
<Setter Property="Fill" Value="#D99A1B" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
<TextBlock
|
||||
FontSize="12"
|
||||
Foreground="#6B7785"
|
||||
Text="{Binding SseStatusText}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,22 @@
|
||||
using _29WxOrderWpf.ToolCtrls;
|
||||
using OrderSupportTicket.ViewModels;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Work._29Tools.Forms.ToolForms;
|
||||
|
||||
namespace OrderSupportTicket.Views;
|
||||
|
||||
public partial class TicketSidebarView : UserControl
|
||||
{
|
||||
public TicketSidebarView() => InitializeComponent();
|
||||
private void TicketSearchBox_GotFocus(object sender, RoutedEventArgs e) { if (DataContext is OrderSupportTicketViewModel vm) vm.CancelNewTicketDraft(); }
|
||||
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;
|
||||
var window = Window.GetWindow(this);
|
||||
if (window != null && !string.IsNullOrWhiteSpace(orderId)) ToastHelper.Show(window, ClipboardHelper.TrySetText(orderId) ? "订单号已复制" : "复制失败");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<Window
|
||||
x:Class="WxOrder.Framework.Controls.ImageViewerWindow"
|
||||
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:WxOrder.Framework.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Title="图片查看"
|
||||
Width="900"
|
||||
Height="650"
|
||||
MinWidth="320"
|
||||
MinHeight="240"
|
||||
Background="#FF1E1E1E"
|
||||
Focusable="True"
|
||||
KeyDown="Window_KeyDown"
|
||||
ShowInTaskbar="False"
|
||||
mc:Ignorable="d">
|
||||
<Grid
|
||||
x:Name="Root"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
LostMouseCapture="Root_LostMouseCapture"
|
||||
MouseLeftButtonDown="Root_MouseLeftButtonDown"
|
||||
MouseLeftButtonUp="Root_MouseLeftButtonUp"
|
||||
MouseMove="Root_MouseMove"
|
||||
MouseWheel="Root_MouseWheel"
|
||||
SizeChanged="Root_SizeChanged">
|
||||
|
||||
<!-- Stretch=None:以图片原始尺寸显示,缩放/平移全部由 RenderTransform 完成 -->
|
||||
<Image
|
||||
x:Name="Img"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
RenderTransformOrigin="0,0"
|
||||
Stretch="None" />
|
||||
|
||||
<!-- 底部工具栏 -->
|
||||
<Border
|
||||
x:Name="ToolBar"
|
||||
Margin="0,0,0,12"
|
||||
Padding="6,4"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Background="#99000000"
|
||||
CornerRadius="6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button
|
||||
Width="28"
|
||||
Click="ZoomOut_Click"
|
||||
Content="-"
|
||||
ToolTip="缩小" />
|
||||
<TextBlock
|
||||
x:Name="ZoomText"
|
||||
Width="52"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="White"
|
||||
Text="100%"
|
||||
TextAlignment="Center" />
|
||||
<Button
|
||||
Width="28"
|
||||
Click="ZoomIn_Click"
|
||||
Content="+"
|
||||
ToolTip="放大" />
|
||||
<Button
|
||||
Width="40"
|
||||
Margin="6,0,0,0"
|
||||
Click="Fit_Click"
|
||||
Content="适应"
|
||||
ToolTip="适应窗口" />
|
||||
<Button
|
||||
Width="36"
|
||||
Margin="4,0,0,0"
|
||||
Click="Actual_Click"
|
||||
Content="1:1"
|
||||
ToolTip="原始大小" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,452 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace WxOrder.Framework.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 图片查看弹窗,直接静态调用:
|
||||
/// ImageViewerWindow.Open(@"C:\a.jpg");
|
||||
/// ImageViewerWindow.OpenDialog("https://example.com/a.png", "预览");
|
||||
/// source 支持 string(路径/URL) / Uri / ImageSource / byte[] / Stream
|
||||
/// </summary>
|
||||
public partial class ImageViewerWindow : Window
|
||||
{
|
||||
private readonly object _source;
|
||||
|
||||
private ImageViewerWindow(object source, string title)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// 保证 Img 位于 Canvas 中:
|
||||
// 如果 Img 直接放在 Grid 里,图片比窗口大时会被 WPF 按窗口大小做布局裁剪,
|
||||
// 缩放后只剩左上角一块,表现为“图片没居中”。
|
||||
EnsureImageInCanvas();
|
||||
|
||||
var group = new TransformGroup();
|
||||
group.Children.Add(_scaleT);
|
||||
group.Children.Add(_translateT);
|
||||
Img.RenderTransform = group;
|
||||
Img.RenderTransformOrigin = new Point(0, 0);
|
||||
|
||||
_source = source;
|
||||
Title = title ?? GuessTitle(source);
|
||||
|
||||
// 默认大小:屏幕工作区的 70%
|
||||
Width = Math.Max(MinWidth, SystemParameters.WorkArea.Width * 0.7);
|
||||
Height = Math.Max(MinHeight, SystemParameters.WorkArea.Height * 0.7);
|
||||
|
||||
ImageFailed += (s, ex) =>
|
||||
{
|
||||
MessageBox.Show(this, "图片加载失败:" + ex.Message, "提示",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
Close();
|
||||
};
|
||||
|
||||
// 放到 Loaded 里设置,这样加载失败时可以安全地关闭窗口
|
||||
Loaded += (s, e) => Source = _source;
|
||||
}
|
||||
|
||||
/// <summary>如果 Img 的父级不是 Canvas,就在原位置包一层 Canvas。</summary>
|
||||
private void EnsureImageInCanvas()
|
||||
{
|
||||
if (Img.Parent is Canvas) return;
|
||||
if (Img.Parent is not Panel parent) return;
|
||||
|
||||
int index = parent.Children.IndexOf(Img);
|
||||
parent.Children.RemoveAt(index);
|
||||
|
||||
Img.Stretch = Stretch.None;
|
||||
Img.HorizontalAlignment = HorizontalAlignment.Left;
|
||||
Img.VerticalAlignment = VerticalAlignment.Top;
|
||||
Canvas.SetLeft(Img, 0);
|
||||
Canvas.SetTop(Img, 0);
|
||||
|
||||
var canvas = new Canvas { IsHitTestVisible = false };
|
||||
canvas.Children.Add(Img);
|
||||
parent.Children.Insert(index, canvas);
|
||||
}
|
||||
|
||||
#region 静态调用入口
|
||||
|
||||
/// <summary>非模态弹出(不阻塞调用方)</summary>
|
||||
public static void Open(object source, string title = null, Window owner = null)
|
||||
{
|
||||
RunOnUi(() => Create(source, title, owner).Show());
|
||||
}
|
||||
|
||||
/// <summary>模态弹出(关闭前阻塞调用方)</summary>
|
||||
public static void OpenDialog(object source, string title = null, Window owner = null)
|
||||
{
|
||||
RunOnUi(() => Create(source, title, owner).ShowDialog());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static ImageViewerWindow Create(object source, string title, Window owner)
|
||||
{
|
||||
var win = new ImageViewerWindow(source, title);
|
||||
|
||||
owner = owner ?? Application.Current?.MainWindow;
|
||||
if (owner != null && owner.IsLoaded && owner != win)
|
||||
{
|
||||
win.Owner = owner;
|
||||
win.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
}
|
||||
else
|
||||
{
|
||||
win.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
private static void RunOnUi(Action action)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher
|
||||
?? throw new InvalidOperationException("没有可用的 WPF Application。");
|
||||
if (dispatcher.CheckAccess()) action();
|
||||
else dispatcher.Invoke(action); // 支持从后台线程调用
|
||||
}
|
||||
|
||||
private static string GuessTitle(object source)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (source is string s && !string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
if (Uri.TryCreate(s, UriKind.Absolute, out var u))
|
||||
return Path.GetFileName(u.IsFile ? u.LocalPath : u.AbsolutePath);
|
||||
return Path.GetFileName(s);
|
||||
}
|
||||
if (source is Uri uri)
|
||||
return Path.GetFileName(uri.IsFile ? uri.LocalPath : uri.AbsolutePath);
|
||||
}
|
||||
catch { /* 忽略,使用默认标题 */ }
|
||||
return "图片查看";
|
||||
}
|
||||
|
||||
private void Window_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape: Close(); break;
|
||||
case Key.Add:
|
||||
case Key.OemPlus: ZoomIn(); break;
|
||||
case Key.Subtract:
|
||||
case Key.OemMinus: ZoomOut(); break;
|
||||
case Key.D0:
|
||||
case Key.NumPad0: FitToView(); break;
|
||||
case Key.D1:
|
||||
case Key.NumPad1: ActualSize(); break;
|
||||
}
|
||||
}
|
||||
|
||||
#region 依赖属性
|
||||
|
||||
/// <summary>图片来源:string(路径或URL) / Uri / ImageSource / byte[] / Stream</summary>
|
||||
public static readonly DependencyProperty SourceProperty =
|
||||
DependencyProperty.Register(nameof(Source), typeof(object), typeof(ImageViewerWindow),
|
||||
new PropertyMetadata(null, (d, e) => ((ImageViewerWindow)d).LoadSource(e.NewValue)));
|
||||
|
||||
public static readonly DependencyProperty ShowToolbarProperty =
|
||||
DependencyProperty.Register(nameof(ShowToolbar), typeof(bool), typeof(ImageViewerWindow),
|
||||
new PropertyMetadata(true, (d, e) =>
|
||||
((ImageViewerWindow)d).ToolBar.Visibility = (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed));
|
||||
|
||||
public object Source
|
||||
{
|
||||
get => GetValue(SourceProperty);
|
||||
set => SetValue(SourceProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowToolbar
|
||||
{
|
||||
get => (bool)GetValue(ShowToolbarProperty);
|
||||
set => SetValue(ShowToolbarProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 公共属性 / 事件
|
||||
|
||||
public double MinZoom { get; set; } = 0.05;
|
||||
public double MaxZoom { get; set; } = 32;
|
||||
|
||||
/// <summary>当前缩放比例(1 = 100%)</summary>
|
||||
public double Zoom
|
||||
{
|
||||
get => _scale;
|
||||
set => ZoomAt(ViewCenter, value);
|
||||
}
|
||||
|
||||
public event EventHandler ZoomChanged;
|
||||
public event EventHandler<Exception> ImageFailed;
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly ScaleTransform _scaleT = new ScaleTransform();
|
||||
private readonly TranslateTransform _translateT = new TranslateTransform();
|
||||
|
||||
private ImageSource _image;
|
||||
private BitmapSource _pending; // 正在下载的网络图片
|
||||
private double _scale = 1, _tx, _ty;
|
||||
private bool _fit = true; // 是否处于"适应窗口"状态(窗口大小变化时自动重新适应)
|
||||
private bool _dragging;
|
||||
private Point _lastPos;
|
||||
|
||||
private Point ViewCenter => new Point(Root.ActualWidth / 2, Root.ActualHeight / 2);
|
||||
|
||||
#region 加载图片
|
||||
|
||||
private void LoadSource(object src)
|
||||
{
|
||||
DetachPending();
|
||||
try
|
||||
{
|
||||
var img = CreateImage(src);
|
||||
if (img is BitmapSource bs && bs.IsDownloading)
|
||||
{
|
||||
_pending = bs;
|
||||
bs.DownloadCompleted += OnDownloadCompleted;
|
||||
bs.DownloadFailed += OnDownloadFailed;
|
||||
SetImage(null);
|
||||
return;
|
||||
}
|
||||
SetImage(img);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetImage(null);
|
||||
ImageFailed?.Invoke(this, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachPending()
|
||||
{
|
||||
if (_pending == null) return;
|
||||
_pending.DownloadCompleted -= OnDownloadCompleted;
|
||||
_pending.DownloadFailed -= OnDownloadFailed;
|
||||
_pending = null;
|
||||
}
|
||||
|
||||
private void OnDownloadCompleted(object sender, EventArgs e)
|
||||
{
|
||||
var bs = (BitmapSource)sender;
|
||||
if (bs != _pending) return;
|
||||
DetachPending();
|
||||
if (bs.CanFreeze) bs.Freeze();
|
||||
SetImage(bs);
|
||||
}
|
||||
|
||||
private void OnDownloadFailed(object sender, ExceptionEventArgs e)
|
||||
{
|
||||
DetachPending();
|
||||
SetImage(null);
|
||||
ImageFailed?.Invoke(this, e.ErrorException);
|
||||
}
|
||||
|
||||
private static ImageSource CreateImage(object src)
|
||||
{
|
||||
switch (src)
|
||||
{
|
||||
case null: return null;
|
||||
case ImageSource s: return s;
|
||||
case Uri u: return FromUri(u);
|
||||
case string str:
|
||||
var uri = ToUri(str);
|
||||
return uri == null ? null : FromUri(uri);
|
||||
case byte[] bytes:
|
||||
using (var ms = new MemoryStream(bytes)) return FromStream(ms);
|
||||
case Stream stream: return FromStream(stream);
|
||||
default:
|
||||
throw new NotSupportedException("不支持的图片来源类型:" + src.GetType().FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri ToUri(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
if (Uri.TryCreate(s, UriKind.Absolute, out var u)) return u; // C:\a.png / http://... / pack://...
|
||||
return new Uri(Path.GetFullPath(s)); // 相对路径
|
||||
}
|
||||
|
||||
private static BitmapImage FromUri(Uri uri)
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.UriSource = uri;
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad; // 加载后不占用文件
|
||||
bmp.CreateOptions = BitmapCreateOptions.IgnoreImageCache; // 文件被替换后能刷新
|
||||
bmp.EndInit();
|
||||
if (!bmp.IsDownloading && bmp.CanFreeze) bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private static BitmapImage FromStream(Stream stream)
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.StreamSource = stream;
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.EndInit();
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private void SetImage(ImageSource img)
|
||||
{
|
||||
_image = img;
|
||||
Img.Source = img;
|
||||
_fit = true;
|
||||
FitToView();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 缩放 / 平移
|
||||
|
||||
/// <summary>适应窗口(只缩小,不放大小图)</summary>
|
||||
public void FitToView()
|
||||
{
|
||||
_fit = true;
|
||||
if (_image == null) return;
|
||||
double cw = Root.ActualWidth, ch = Root.ActualHeight;
|
||||
if (cw <= 0 || ch <= 0 || _image.Width <= 0 || _image.Height <= 0) return;
|
||||
|
||||
CenterAtScale(Math.Min(1.0, Math.Min(cw / _image.Width, ch / _image.Height)));
|
||||
}
|
||||
|
||||
/// <summary>按原始大小显示,并将图片几何中心放到视口中心。</summary>
|
||||
public void ActualSize()
|
||||
{
|
||||
CenterAtScale(1.0);
|
||||
_fit = false;
|
||||
}
|
||||
|
||||
public void ZoomIn() => ZoomAt(ViewCenter, _scale * 1.25);
|
||||
public void ZoomOut() => ZoomAt(ViewCenter, _scale / 1.25);
|
||||
|
||||
/// <summary>以 p(控件坐标)为不动点缩放到 newScale</summary>
|
||||
private void ZoomAt(Point p, double newScale)
|
||||
{
|
||||
if (_image == null) return;
|
||||
newScale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
|
||||
double ratio = newScale / _scale;
|
||||
_tx = p.X - (p.X - _tx) * ratio;
|
||||
_ty = p.Y - (p.Y - _ty) * ratio;
|
||||
_scale = newScale;
|
||||
_fit = false;
|
||||
ApplyTransform();
|
||||
}
|
||||
|
||||
/// <summary>按指定比例显示,并将图片几何中心与视口中心对齐。</summary>
|
||||
private void CenterAtScale(double newScale)
|
||||
{
|
||||
if (_image == null) return;
|
||||
|
||||
_scale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
|
||||
_tx = (Root.ActualWidth - _image.Width * _scale) / 2;
|
||||
_ty = (Root.ActualHeight - _image.Height * _scale) / 2;
|
||||
ApplyTransform();
|
||||
}
|
||||
|
||||
private void ApplyTransform()
|
||||
{
|
||||
if (_image == null)
|
||||
{
|
||||
ZoomText.Text = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
ClampOffset();
|
||||
_scaleT.ScaleX = _scaleT.ScaleY = _scale;
|
||||
_translateT.X = _tx;
|
||||
_translateT.Y = _ty;
|
||||
|
||||
// 缩小用高质量,放大到像素级时用最近邻(能看清像素)
|
||||
RenderOptions.SetBitmapScalingMode(Img,
|
||||
_scale >= 3 ? BitmapScalingMode.NearestNeighbor :
|
||||
_scale < 1 ? BitmapScalingMode.Fant : BitmapScalingMode.Linear);
|
||||
|
||||
ZoomText.Text = $"{_scale * 100:0}%";
|
||||
ZoomChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>图片小于视口时居中;大于视口时不允许拖出空白</summary>
|
||||
private void ClampOffset()
|
||||
{
|
||||
double cw = Root.ActualWidth, ch = Root.ActualHeight;
|
||||
double w = _image.Width * _scale, h = _image.Height * _scale;
|
||||
_tx = w <= cw ? (cw - w) / 2 : Math.Min(0, Math.Max(cw - w, _tx));
|
||||
_ty = h <= ch ? (ch - h) / 2 : Math.Min(0, Math.Max(ch - h, _ty));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 事件处理
|
||||
|
||||
private void Root_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
{
|
||||
if (_image == null) return;
|
||||
if (_fit) FitToView();
|
||||
else ApplyTransform();
|
||||
}
|
||||
|
||||
private void Root_MouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
if (_image == null) return;
|
||||
double factor = Math.Pow(1.001, e.Delta); // 一格滚轮约 ×1.13,触摸板更平滑
|
||||
ZoomAt(e.GetPosition(Root), _scale * factor);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Root_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (_image == null) return;
|
||||
|
||||
if (e.ClickCount == 2) // 双击:适应窗口 <-> 100%
|
||||
{
|
||||
if (_fit) ZoomAt(e.GetPosition(Root), 1.0);
|
||||
else FitToView();
|
||||
return;
|
||||
}
|
||||
|
||||
_dragging = true;
|
||||
_lastPos = e.GetPosition(Root);
|
||||
Root.CaptureMouse();
|
||||
Root.Cursor = Cursors.SizeAll;
|
||||
}
|
||||
|
||||
private void Root_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_dragging) return;
|
||||
var pos = e.GetPosition(Root);
|
||||
_tx += pos.X - _lastPos.X;
|
||||
_ty += pos.Y - _lastPos.Y;
|
||||
_lastPos = pos;
|
||||
_fit = false;
|
||||
ApplyTransform();
|
||||
}
|
||||
|
||||
private void Root_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => EndDrag();
|
||||
private void Root_LostMouseCapture(object sender, MouseEventArgs e) => EndDrag();
|
||||
|
||||
private void EndDrag()
|
||||
{
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
Root.ReleaseMouseCapture();
|
||||
Root.Cursor = null;
|
||||
}
|
||||
|
||||
private void ZoomIn_Click(object sender, RoutedEventArgs e) => ZoomIn();
|
||||
private void ZoomOut_Click(object sender, RoutedEventArgs e) => ZoomOut();
|
||||
private void Fit_Click(object sender, RoutedEventArgs e) => FitToView();
|
||||
private void Actual_Click(object sender, RoutedEventArgs e) => ActualSize();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -34,7 +34,7 @@ namespace WxOrder.Framework.Helpers
|
||||
/// </summary>
|
||||
public sealed class NLogger : IDisposable, ILogger
|
||||
{
|
||||
private const string DefaultName = "Default";
|
||||
private const string DefaultName = "App";
|
||||
|
||||
private static readonly object _lock = new object();
|
||||
private static NLogger? _instance;
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Services\" />
|
||||
<Folder Include="Controls\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user