工单 优化 ,增加图片

This commit is contained in:
chfei committed 2026-10-20 17:03:23 +08:00
1 parent bf5a70597c
commit cf2efbcb16
27 files changed
+3555 -1538

No files matched your search

+265 -264
View File
@@ -1,273 +1,274 @@
<basewindows:BaseWindow <basewindows:BaseWindow
x:Class="_29WxOrderWpf.MainWindow" x:Class="_29WxOrderWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows" xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows"
xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls" xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:_29WxOrderWpf" xmlns:local="clr-namespace:_29WxOrderWpf"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls" xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls"
xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls" xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls"
xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls" xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls"
x:Name="mainWindow" x:Name="mainWindow"
Title="MainWindow" Title="MainWindow"
Width="1600" Width="1600"
Height="900" Height="900"
Closing="BaseWindow_Closing" Closing="BaseWindow_Closing"
Loaded="Window_Loaded" Loaded="Window_Loaded"
Style="{StaticResource BaseNoneBorderWindowStyle}" Style="{StaticResource BaseNoneBorderWindowStyle}"
mc:Ignorable="d"> mc:Ignorable="d">
<Grid <Grid
x:Name="mainGrid" x:Name="mainGrid"
Margin="{Binding ElementName=mainWindow, Path=Padding}" Margin="{Binding ElementName=mainWindow, Path=Padding}"
Background="Transparent"> Background="Transparent">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="60" /> <RowDefinition Height="60" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid <Grid
x:Name="gridTitle" x:Name="gridTitle"
Background="Transparent" Background="Transparent"
MouseLeftButtonDown="gridTitle_MouseLeftButtonDown"> MouseLeftButtonDown="gridTitle_MouseLeftButtonDown">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" /> <ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" /> <ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" /> <ColumnDefinition Width="auto" />
<ColumnDefinition Width="auto" /> <ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Image <Image
Width="50" Width="50"
Height="50" Height="50"
Margin="4" Margin="4"
HorizontalAlignment="Left" HorizontalAlignment="Left"
VerticalAlignment="Center" VerticalAlignment="Center"
Source="{StaticResource main29WeixinIcon}" /> Source="{StaticResource main29WeixinIcon}" />
<Label <Label
x:Name="lblAppName" x:Name="lblAppName"
Grid.Column="1" Grid.Column="1"
Margin="0,0,0,0" Margin="0,0,0,0"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
VerticalContentAlignment="Center" VerticalContentAlignment="Center"
Content="拍单软件" Content="拍单软件"
FontSize="20" FontSize="20"
Foreground="{StaticResource mainColorLightBrush}" /> Foreground="{StaticResource mainColorLightBrush}" />
<WrapPanel <WrapPanel
Grid.Column="2" Grid.Column="2"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button <Button
x:Name="btOrderSupport" x:Name="btOrderSupport"
Click="bt_showOrderSupport" Click="bt_showOrderSupport"
Style="{StaticResource IconButtonStyle}" IsEnabled="False"
ToolTip="客户工单"> Style="{StaticResource IconButtonStyle}"
<Grid Width="30" Height="30"> ToolTip="客户工单">
<TextBlock <Grid Width="30" Height="30">
HorizontalAlignment="Center" <TextBlock
VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="22" VerticalAlignment="Center"
Foreground="{StaticResource mainColorLightBrush}" FontSize="22"
Style="{StaticResource TextBlockIcon}" Foreground="{StaticResource mainColorLightBrush}"
Text="{StaticResource txt-alert}" /> Style="{StaticResource TextBlockIcon}"
<Border Text="{StaticResource txt-alert}" />
x:Name="borderOrderSupportUnread" <Border
Height="16" x:Name="borderOrderSupportUnread"
MinWidth="16" Height="16"
Padding="4,0" MinWidth="16"
HorizontalAlignment="Right" Padding="4,0"
VerticalAlignment="Top" HorizontalAlignment="Right"
d:Visibility="Visible" VerticalAlignment="Top"
Background="#E53935" d:Visibility="Visible"
CornerRadius="8" Background="#E53935"
Visibility="Collapsed"> CornerRadius="8"
<TextBlock Visibility="Collapsed">
x:Name="tbOrderSupportUnread" <TextBlock
HorizontalAlignment="Center" x:Name="tbOrderSupportUnread"
VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="10" VerticalAlignment="Center"
FontWeight="Bold" FontSize="10"
Foreground="White" FontWeight="Bold"
Text="0" /> Foreground="White"
</Border> Text="0" />
</Grid> </Border>
</Button> </Grid>
<Border </Button>
Width="1" <Border
Height="20" Width="1"
Margin="10,0" Height="20"
Background="#b893b2" /> Margin="10,0"
<Button Click="bt_setting" Style="{StaticResource IconButtonStyle}"> Background="#b893b2" />
<TextBlock <Button Click="bt_setting" Style="{StaticResource IconButtonStyle}">
FontSize="22" <TextBlock
Foreground="{StaticResource mainColorLightBrush}" FontSize="22"
Style="{StaticResource TextBlockIcon}" Foreground="{StaticResource mainColorLightBrush}"
Text="{StaticResource txt-settings}" /> Style="{StaticResource TextBlockIcon}"
</Button> Text="{StaticResource txt-settings}" />
<Border </Button>
Width="1" <Border
Height="20" Width="1"
Margin="10,0" Height="20"
Background="#b893b2" /> Margin="10,0"
</WrapPanel> Background="#b893b2" />
<WrapPanel </WrapPanel>
Grid.Column="3" <WrapPanel
HorizontalAlignment="Right" Grid.Column="3"
VerticalAlignment="Top"> HorizontalAlignment="Right"
VerticalAlignment="Top">
<UniformGrid Rows="2"> <UniformGrid Rows="2">
<WrapPanel> <WrapPanel>
<Label <Label
x:Name="lblDebug" x:Name="lblDebug"
Margin="2,4,0,0" Margin="2,4,0,0"
Content="[release]" Content="[release]"
FontSize="12" FontSize="12"
Foreground="{StaticResource mainColorLightBrush}" Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" /> Opacity="0.7" />
<Label <Label
x:Name="lblVersion" x:Name="lblVersion"
Margin="2,4,0,0" Margin="2,4,0,0"
Content="版本信息" Content="版本信息"
FontSize="12" FontSize="12"
Foreground="{StaticResource mainColorLightBrush}" Foreground="{StaticResource mainColorLightBrush}"
Opacity="0.7" /> Opacity="0.7" />
</WrapPanel> </WrapPanel>
<Label <Label
x:Name="lblBalance" x:Name="lblBalance"
Grid.Column="4" Grid.Column="4"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Top" VerticalAlignment="Top"
VerticalContentAlignment="Center" VerticalContentAlignment="Center"
d:Content="快递余额:999.99" d:Content="快递余额:999.99"
Content="{Binding Balance}" Content="{Binding Balance}"
Cursor="Hand" Cursor="Hand"
FontSize="12" FontSize="12"
Foreground="#60ff66" Foreground="#60ff66"
MouseLeftButtonUp="lblBalance_MouseLeftButtonUp" MouseLeftButtonUp="lblBalance_MouseLeftButtonUp"
Opacity="0.7" /> Opacity="0.7" />
</UniformGrid> </UniformGrid>
</WrapPanel> </WrapPanel>
<userctrls:UcWindowBoxes <userctrls:UcWindowBoxes
x:Name="ucWindowBox" x:Name="ucWindowBox"
Grid.Column="4" Grid.Column="4"
Margin="4" Margin="4"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Top" VerticalAlignment="Top"
MaxBoxVisibility="Visible" /> MaxBoxVisibility="Visible" />
</Grid> </Grid>
<Grid <Grid
x:Name="gridContainer" x:Name="gridContainer"
Grid.Row="1" Grid.Row="1"
Margin="2,0,2,2" Margin="2,0,2,2"
Background="White"> Background="White">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" /> <ColumnDefinition Width="auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<orderctrls:OrderListCtrl <orderctrls:OrderListCtrl
x:Name="orderListCtrl" x:Name="orderListCtrl"
Grid.Column="1" Grid.Column="1"
d:Visibility="Visible" d:Visibility="Visible"
Visibility="Collapsed" /> Visibility="Collapsed" />
<Grid <Grid
x:Name="gridTbBuyerOrderReadCtrl" x:Name="gridTbBuyerOrderReadCtrl"
Grid.ColumnSpan="2" Grid.ColumnSpan="2"
d:Visibility="Visible" d:Visibility="Visible"
Background="#7F000000" Background="#7F000000"
MouseLeftButtonUp="gridPddBuyerOrderReadCtrl_MouseLeftButtonUp" MouseLeftButtonUp="gridPddBuyerOrderReadCtrl_MouseLeftButtonUp"
Visibility="Hidden"> Visibility="Hidden">
<Border <Border
Height="auto" Height="auto"
Margin="0,50,0,0" Margin="0,50,0,0"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Background="White" Background="White"
CornerRadius="10"> CornerRadius="10">
<Grid> <Grid>
<buyerctrls:PDDBuyerOrderReadCtrl <buyerctrls:PDDBuyerOrderReadCtrl
x:Name="pddBuyerOrderReadCtrl" x:Name="pddBuyerOrderReadCtrl"
Grid.Column="1" Grid.Column="1"
Width="1200" Width="1200"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Panel.ZIndex="-1" Panel.ZIndex="-1"
d:Visibility="Visible" d:Visibility="Visible"
Visibility="Collapsed" /> Visibility="Collapsed" />
<buyerctrls:TbBuyerOrderReadCtrl <buyerctrls:TbBuyerOrderReadCtrl
x:Name="tbBuyerOrderReadCtrl" x:Name="tbBuyerOrderReadCtrl"
Grid.Column="1" Grid.Column="1"
Width="1200" Width="1200"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Panel.ZIndex="-1" Panel.ZIndex="-1"
d:Visibility="Collapsed" d:Visibility="Collapsed"
Visibility="Collapsed" /> Visibility="Collapsed" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
<buyerctrls:PDDBuyerSelectCtrl <buyerctrls:PDDBuyerSelectCtrl
x:Name="pddBuyerSelectCtrl" x:Name="pddBuyerSelectCtrl"
Grid.Column="1" Grid.Column="1"
Margin="8" Margin="8"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Top" VerticalAlignment="Top"
d:Visibility="Visible" d:Visibility="Visible"
Visibility="Collapsed" /> Visibility="Collapsed" />
<Grid <Grid
x:Name="gridUser" x:Name="gridUser"
Grid.ColumnSpan="2" Grid.ColumnSpan="2"
Background="#7F000000" Background="#7F000000"
Visibility="Hidden"> Visibility="Hidden">
<Border <Border
Width="500" Width="500"
Height="600" Height="600"
Background="White" Background="White"
CornerRadius="4"> CornerRadius="4">
<shopctrls:ShopListCtrl <shopctrls:ShopListCtrl
x:Name="shopListCtrl" x:Name="shopListCtrl"
Grid.ColumnSpan="2" Grid.ColumnSpan="2"
Margin="2" Margin="2"
Visibility="{Binding ElementName=gridUser, Mode=TwoWay, Path=Visibility}" /> Visibility="{Binding ElementName=gridUser, Mode=TwoWay, Path=Visibility}" />
</Border> </Border>
</Grid> </Grid>
</Grid> </Grid>
<Border <Border
x:Name="borderMessage" x:Name="borderMessage"
Grid.Row="1" Grid.Row="1"
Width="auto" Width="auto"
Height="auto" Height="auto"
MinWidth="120" MinWidth="120"
MinHeight="2" MinHeight="2"
MaxWidth="1200" MaxWidth="1200"
Margin="10,50" Margin="10,50"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Top" VerticalAlignment="Top"
Panel.ZIndex="1000000" Panel.ZIndex="1000000"
Background="White" Background="White"
BorderBrush="{StaticResource pageColorBrush}" BorderBrush="{StaticResource pageColorBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="6" CornerRadius="6"
IsHitTestVisible="False" IsHitTestVisible="False"
Opacity="0"> Opacity="0">
<Border.Effect> <Border.Effect>
<DropShadowEffect <DropShadowEffect
BlurRadius="40" BlurRadius="40"
Opacity="0.2" Opacity="0.2"
ShadowDepth="5" ShadowDepth="5"
Color="#FF98638f" /> Color="#FF98638f" />
</Border.Effect> </Border.Effect>
<TextBlock <TextBlock
x:Name="tbMessage" x:Name="tbMessage"
Margin="10" Margin="10"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="这里是默认提示信息" Text="这里是默认提示信息"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</Border> </Border>
<!--<Image x:Name="imageBig" HorizontalAlignment="Left" Height="800" Width="800" VerticalAlignment="Center" Grid.Row="1" Margin="200,0,0,0"/>--> <!--<Image x:Name="imageBig" HorizontalAlignment="Left" Height="800" Width="800" VerticalAlignment="Center" Grid.Row="1" Margin="200,0,0,0"/>-->
</Grid> </Grid>
</basewindows:BaseWindow> </basewindows:BaseWindow>
+8 -1
View File
@@ -107,7 +107,7 @@ namespace _29WxOrderWpf
pddBuyerSelectCtrl.Visibility = Visibility.Visible; pddBuyerSelectCtrl.Visibility = Visibility.Visible;
Task.Run(async () => Task.Run(async () =>
{ {
GetServerShop(); GetServerShop();
await StartTicketRealtimeMonitor();//启动工单监控 await StartTicketRealtimeMonitor();//启动工单监控
GetShopSupplier(GlobalConfig.ShopLoginList.ToList()); GetShopSupplier(GlobalConfig.ShopLoginList.ToList());
}); });
@@ -239,7 +239,10 @@ namespace _29WxOrderWpf
private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e) private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{ {
if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true) if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true)
{
_ticketMonitor.Dispose();
Process.GetCurrentProcess().Kill(); Process.GetCurrentProcess().Kill();
}
else else
e.Cancel = true; e.Cancel = true;
} }
@@ -611,6 +614,10 @@ namespace _29WxOrderWpf
await _ticketMonitor.Start(options); await _ticketMonitor.Start(options);
UpdateOrderSupportBadge(); UpdateOrderSupportBadge();
_ = LoadHistoricalTicketUnreadAsync(options); _ = LoadHistoricalTicketUnreadAsync(options);
this.Dispatcher.Invoke(() =>
{
btOrderSupport.IsEnabled = true;
});
} }
catch (Exception ex) catch (Exception ex)
{ {
+2 -2
View File
@@ -415,7 +415,7 @@ namespace AllBase
bitmap.Freeze(); // 防止后续更改,提高性能 bitmap.Freeze(); // 防止后续更改,提高性能
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
// 处理异常,例如打印错误信息或设置一个默认图片等 // 处理异常,例如打印错误信息或设置一个默认图片等
@@ -425,7 +425,7 @@ namespace AllBase
return bitmap; return bitmap;
} }
public static async Task<BitmapImage> LoadBitmipImageFromUrlAsync(string url, int? width = null, int? height = null) public static async Task<BitmapImage> LoadBitmipImageFromUrlAsync(string url, int? width = null, int? height = null)
{ {
BitmapImage bitmap = new BitmapImage(); BitmapImage bitmap = new BitmapImage();
@@ -177,6 +177,7 @@ namespace LaunchDarkly.EventSource
} }
catch (Exception e) catch (Exception e)
{ {
_logger.Error(e.Message);
CancelCurrentRequest(); CancelCurrentRequest();
// If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant.
@@ -200,7 +201,6 @@ namespace LaunchDarkly.EventSource
realException = new TimeoutException(); realException = new TimeoutException();
} }
} }
if (realException != null) if (realException != null)
{ {
OnError(new ExceptionEventArgs(realException)); OnError(new ExceptionEventArgs(realException));
@@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace OrderSupportTicket.Models.Api; namespace OrderSupportTicket.Models.Api;
@@ -139,10 +139,16 @@ internal sealed class SendMessageRequest
public string ClientMessageId { get; set; } public string ClientMessageId { get; set; }
/// <summary> /// <summary>
/// 留言正文,长度 1~4000 字。 /// 留言正文;有图片附件时可为空。
/// </summary> /// </summary>
////[JsonProperty("text")] ////[JsonProperty("text")]
public string Text { get; set; } public string Text { get; set; }
/// <summary>
/// 图片上传回执,最多 3 个;无图片时为 null,序列化时省略。
/// </summary>
////[JsonProperty("attachments")]
public IList<MessageAttachmentReceipt> Attachments { get; set; }
} }
/// <summary> /// <summary>
@@ -169,6 +175,176 @@ internal sealed class MarkReadRequest
public int ThroughSequenceNo { get; set; } 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>
/// 工单摘要分页响应。 /// 工单摘要分页响应。
/// </summary> /// </summary>
@@ -371,6 +547,12 @@ public sealed class TicketMessageDto
/// </summary> /// </summary>
////[JsonProperty("createdAt")] ////[JsonProperty("createdAt")]
public string CreatedAt { get; set; } public string CreatedAt { get; set; }
/// <summary>
/// 公开图片附件;无图片时为空数组。
/// </summary>
////[JsonProperty("attachments")]
public List<TicketAttachmentDto> Attachments { get; set; }
} }
/// <summary> /// <summary>
+1 -1
View File
@@ -16,7 +16,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AllBase\29WxOrderAllBase.csproj" /> <ProjectReference Include="..\AllBase\29WxOrderAllBase.csproj" />
<ProjectReference Include="..\WxOrder.Framework\WxOrder.Framework.csproj" /> <ProjectReference Include="..\Work\29WxOrderWork.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -33,7 +33,7 @@ public sealed class EventSourceTicketRealtimeService
/// </summary> /// </summary>
public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected; public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected;
public async Task Start(TicketClientOptions options) public Task Start(TicketClientOptions options)
{ {
if (_disposed) if (_disposed)
throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService)); throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService));
@@ -56,7 +56,8 @@ public sealed class EventSourceTicketRealtimeService
} }
SetState(TicketRealtimeState.Connecting, "连接中…"); SetState(TicketRealtimeState.Connecting, "连接中…");
await RunAsync(source); _= RunAsync(source);
return Task.FromResult(0);
} }
public Task StopAsync() public Task StopAsync()
@@ -220,6 +221,7 @@ public sealed class EventSourceTicketRealtimeService
ConfigurationBuilder builder = Configuration.Builder(uri); ConfigurationBuilder builder = Configuration.Builder(uri);
builder.RequestHeader("X-AppIds", $"[\"{string.Join("\",\"", appIds)}\"]"); builder.RequestHeader("X-AppIds", $"[\"{string.Join("\",\"", appIds)}\"]");
builder.Logger(NLogger.Instance); builder.Logger(NLogger.Instance);
//builder.LastEventId("");
//var proxy = new WebProxy("http://127.0.0.1:8081"); // //var proxy = new WebProxy("http://127.0.0.1:8081"); //
//var handler = new HttpClientHandler //var handler = new HttpClientHandler
//{ //{
@@ -1,4 +1,4 @@
using System; using System;
namespace OrderSupportTicket.Services; namespace OrderSupportTicket.Services;
@@ -13,11 +13,13 @@ public sealed class TicketApiException : Exception
public TicketApiException( public TicketApiException(
int statusCode, int statusCode,
string message, string message,
string responseBody = null) string responseBody = null,
string errorCode = null)
: base(message) : base(message)
{ {
StatusCode = statusCode; StatusCode = statusCode;
ResponseBody = responseBody; ResponseBody = responseBody;
ErrorCode = errorCode;
} }
/// <summary> /// <summary>
@@ -29,4 +31,9 @@ public sealed class TicketApiException : Exception
/// 服务端返回的原始响应正文。 /// 服务端返回的原始响应正文。
/// </summary> /// </summary>
public string ResponseBody { get; } public string ResponseBody { get; }
}
/// <summary>
/// 服务端返回的错误码,例如 client_api_image_expired。
/// </summary>
public string ErrorCode { get; }
}
+342 -20
View File
@@ -1,11 +1,14 @@
using AllBase; using AllBase;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using OrderSupportTicket.Models; using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api; using OrderSupportTicket.Models.Api;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using WxOrder.Framework.Commons; using WxOrder.Framework.Commons;
@@ -19,6 +22,16 @@ public sealed class TicketApiService
{ {
internal static string ServerUrl = "https://ai.e57.cn"; internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders"; private 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 = private static readonly Regex AppIdRegex =
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex OrderIdRegex = private static readonly Regex OrderIdRegex =
@@ -27,6 +40,14 @@ public sealed class TicketApiService
new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled); new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
private static readonly Regex OperationIdRegex = private static readonly Regex OperationIdRegex =
new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); 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 internal static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{ {
NullValueHandling = NullValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore,
@@ -158,22 +179,25 @@ public sealed class TicketApiService
TicketClientOptions options, TicketClientOptions options,
WorkOrderSummaryDto workOrder, WorkOrderSummaryDto workOrder,
string text, string text,
string clientMessageId) string clientMessageId,
IList<MessageAttachmentReceipt> attachments = null)
{ {
ValidateOptions(options); ValidateOptions(options);
if (workOrder == null) if (workOrder == null)
throw new ArgumentNullException(nameof(workOrder)); throw new ArgumentNullException(nameof(workOrder));
ValidateAppId(workOrder.AppId); ValidateAppId(workOrder.AppId);
ValidateWechatOrderId(workOrder.WechatOrderId); ValidateWechatOrderId(workOrder.WechatOrderId);
ValidateMessageText(text); ValidateMessageContent(text, attachments);
ValidateOperationId(clientMessageId, "clientMessageId", required: true); ValidateOperationId(clientMessageId, "clientMessageId", required: true);
var hasAttachments = attachments != null && attachments.Count > 0;
var request = new SendMessageRequest var request = new SendMessageRequest
{ {
PurchaseAccount = options.UserName, PurchaseAccount = options.UserName,
AppId = workOrder.AppId, AppId = workOrder.AppId,
ClientMessageId = clientMessageId, ClientMessageId = clientMessageId,
Text = text Text = string.IsNullOrWhiteSpace(text) ? null : text,
Attachments = hasAttachments ? attachments.ToList() : null
}; };
var path = ApiRoot var path = ApiRoot
+ "/orders/" + "/orders/"
@@ -182,6 +206,139 @@ public sealed class TicketApiService
return await PostAsync<SendMessageResponse>(path, request); 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( public async Task MarkReadAsync(
TicketClientOptions options, TicketClientOptions options,
string appId, string appId,
@@ -342,25 +499,52 @@ public sealed class TicketApiService
return new TicketApiException( return new TicketApiException(
ExtractStatusCode(responseBody), ExtractStatusCode(responseBody),
message, 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) private static int ExtractStatusCode(string responseBody)
{ {
if (string.IsNullOrWhiteSpace(responseBody)) var error = ExtractErrorResponse(responseBody);
if (error == null || string.IsNullOrWhiteSpace(error.Code))
return 0; 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); case "client_api_image_expired":
if (error != null && int.TryParse(error.Code, out var code)) return 410;
return code; 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) 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) var attachmentCount = attachments?.Count ?? 0;
|| text.Length < 1 if (attachmentCount > MaxImageCount)
|| text.Length > 4000)
{ {
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( private static void ValidateOperationId(
+39 -3
View File
@@ -1,4 +1,4 @@
using OrderSupportTicket.Models; using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api; using OrderSupportTicket.Models.Api;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -137,14 +137,50 @@ public sealed class TicketService : IDisposable
TicketClientOptions options, TicketClientOptions options,
WorkOrderSummaryDto workOrder, WorkOrderSummaryDto workOrder,
string text, string text,
string clientMessageId) string clientMessageId,
IList<MessageAttachmentReceipt> attachments = null)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
return _api.SendMessageAsync( return _api.SendMessageAsync(
options, options,
workOrder, workOrder,
text, 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> /// <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;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using WPFCommon.Models; using WPFCommon.Models;
namespace OrderSupportTicket.ViewModels; namespace OrderSupportTicket.ViewModels;
@@ -22,6 +26,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
{ {
if (dto == null) if (dto == null)
throw new ArgumentNullException(nameof(dto)); throw new ArgumentNullException(nameof(dto));
Attachments = new ObservableCollection<ChatAttachmentViewModel>();
MessageId = dto.MessageId; MessageId = dto.MessageId;
SequenceNo = dto.SequenceNo; SequenceNo = dto.SequenceNo;
SenderType = dto.SenderType; SenderType = dto.SenderType;
@@ -33,6 +38,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
? "该消息已撤回" ? "该消息已撤回"
: (dto.Text ?? string.Empty); : (dto.Text ?? string.Empty);
CreatedAt = ParseTimestamp(dto.CreatedAt); CreatedAt = ParseTimestamp(dto.CreatedAt);
ReplaceAttachments(dto.Attachments);
} }
/// <summary> /// <summary>
@@ -81,9 +87,18 @@ public sealed class ChatMessageViewModel : BindableBaseEx
public string Content public string Content
{ {
get => _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>
/// 消息是否已撤回。 /// 消息是否已撤回。
/// </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>
/// 当前消息是否由客户发送。 /// 当前消息是否由客户发送。
/// </summary> /// </summary>
@@ -120,6 +145,50 @@ public sealed class ChatMessageViewModel : BindableBaseEx
? string.Empty ? string.Empty
: CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture); : 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) private static DateTimeOffset ParseTimestamp(string value)
{ {
if (DateTimeOffset.TryParse( if (DateTimeOffset.TryParse(
@@ -1,5 +1,6 @@
using AllBase.Helper; using AllBase.Helper;
using AllBase.Model.DBModel; using AllBase.Model.DBModel;
using Microsoft.Win32;
using OrderSupportTicket.Models; using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api; using OrderSupportTicket.Models.Api;
using OrderSupportTicket.Services; using OrderSupportTicket.Services;
@@ -8,8 +9,10 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Data; using System.Windows.Data;
using System.Windows.Media; using System.Windows.Media;
@@ -17,6 +20,7 @@ using System.Windows.Threading;
using WPFCommon.Models; using WPFCommon.Models;
using WxOrder.Framework.Commons; using WxOrder.Framework.Commons;
using WxOrder.Framework.Commons.Dialogs; using WxOrder.Framework.Commons.Dialogs;
using WxOrder.Framework.Controls;
namespace OrderSupportTicket.ViewModels; namespace OrderSupportTicket.ViewModels;
@@ -36,8 +40,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand; private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand;
private readonly AsyncDelegateCommand _createTicketCommand; private readonly AsyncDelegateCommand _createTicketCommand;
private readonly AsyncDelegateCommand _sendMessageCommand; private readonly AsyncDelegateCommand _sendMessageCommand;
private readonly AsyncDelegateCommand<PendingAttachmentViewModel> _removePendingAttachmentCommand;
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand; private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
private readonly AsyncDelegateCommand _retryRealtimeCommand; private readonly AsyncDelegateCommand _retryRealtimeCommand;
private readonly DelegateCommand<FrameworkElement> _addImagesCommand;
private readonly HashSet<string> _seenEventIds = private readonly HashSet<string> _seenEventIds =
new HashSet<string>(StringComparer.Ordinal); new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, int> _lastReadSequences = private readonly Dictionary<string, int> _lastReadSequences =
@@ -57,6 +63,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private string _chatText; private string _chatText;
private string _draftText; private string _draftText;
private string _ticketSearchText = string.Empty; private string _ticketSearchText = string.Empty;
private int _selectedTicketStatusIndex;
private bool _isDraft; private bool _isDraft;
private bool _isBusy; private bool _isBusy;
private bool _isLoading; private bool _isLoading;
@@ -88,6 +95,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
Tickets = new ObservableCollection<WorkOrderSummaryViewModel>(); Tickets = new ObservableCollection<WorkOrderSummaryViewModel>();
Messages = new ObservableCollection<ChatMessageViewModel>(); Messages = new ObservableCollection<ChatMessageViewModel>();
PendingAttachments = new ObservableCollection<PendingAttachmentViewModel>();
TicketView = CollectionViewSource.GetDefaultView(Tickets); TicketView = CollectionViewSource.GetDefaultView(Tickets);
TicketView.Filter = FilterTicket; TicketView.Filter = FilterTicket;
_selectTicketCommand = new AsyncDelegateCommand<WorkOrderSummaryViewModel>( _selectTicketCommand = new AsyncDelegateCommand<WorkOrderSummaryViewModel>(
@@ -99,12 +107,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
_sendMessageCommand = new AsyncDelegateCommand( _sendMessageCommand = new AsyncDelegateCommand(
SendMessageAsync, SendMessageAsync,
CanSendMessage); CanSendMessage);
_removePendingAttachmentCommand =
new AsyncDelegateCommand<PendingAttachmentViewModel>(
RemovePendingAttachmentAsync,
attachment => attachment != null && !IsBusy);
_loadEarlierMessagesCommand = new AsyncDelegateCommand( _loadEarlierMessagesCommand = new AsyncDelegateCommand(
LoadEarlierMessagesAsync, LoadEarlierMessagesAsync,
() => !IsBusy && SelectedTicket != null && HasMoreMessages); () => !IsBusy && SelectedTicket != null && HasMoreMessages);
_retryRealtimeCommand = new AsyncDelegateCommand( _retryRealtimeCommand = new AsyncDelegateCommand(
RestartRealtimeAsync, RestartRealtimeAsync,
() => !IsBusy && _configured); () => _configured);
_addImagesCommand = new DelegateCommand<FrameworkElement>(
AddImages,
_ => CanAddImage);
_ticketService.NotificationReceived += OnNotificationReceived; _ticketService.NotificationReceived += OnNotificationReceived;
_ticketService.StateChanged += OnRealtimeStateChanged; _ticketService.StateChanged += OnRealtimeStateChanged;
SseState = _ticketService.RealtimeState; SseState = _ticketService.RealtimeState;
@@ -144,6 +159,40 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
/// </summary> /// </summary>
public ICommand SendMessageCommand => _sendMessageCommand; 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>
/// 加载当前工单更早留言的命令。 /// 加载当前工单更早留言的命令。
/// </summary> /// </summary>
@@ -154,10 +203,23 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
/// </summary> /// </summary>
public ICommand RetryRealtimeCommand => _retryRealtimeCommand; public ICommand RetryRealtimeCommand => _retryRealtimeCommand;
/// <summary> /// <summary>
/// 当前正在查看的历史工单。 /// 显示图片
/// </summary> /// </summary>
public WorkOrderSummaryViewModel SelectedTicket 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; get => _selectedTicket;
private set private set
@@ -167,6 +229,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
RaisePropertyChanged(nameof(IsChatVisible)); RaisePropertyChanged(nameof(IsChatVisible));
RaisePropertyChanged(nameof(IsEmptyVisible)); RaisePropertyChanged(nameof(IsEmptyVisible));
RaisePropertyChanged(nameof(ComposerPlaceholder)); RaisePropertyChanged(nameof(ComposerPlaceholder));
RaisePropertyChanged(nameof(CanAddImage));
_addImagesCommand.RaiseCanExecuteChanged();
RaiseSendCanExecuteChanged(); RaiseSendCanExecuteChanged();
RaiseLoadEarlierCanExecuteChanged(); 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>
/// 当前是否处于新建工单草稿状态。 /// 当前是否处于新建工单草稿状态。
/// </summary> /// </summary>
@@ -253,6 +330,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
RaisePropertyChanged(nameof(IsChatVisible)); RaisePropertyChanged(nameof(IsChatVisible));
RaisePropertyChanged(nameof(IsEmptyVisible)); RaisePropertyChanged(nameof(IsEmptyVisible));
RaisePropertyChanged(nameof(IsDraftVisible)); RaisePropertyChanged(nameof(IsDraftVisible));
RaisePropertyChanged(nameof(CanAddImage));
_addImagesCommand.RaiseCanExecuteChanged();
RaiseCreateCanExecuteChanged(); RaiseCreateCanExecuteChanged();
} }
} }
@@ -269,11 +348,14 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
if (SetValue(ref _isBusy, value)) if (SetValue(ref _isBusy, value))
{ {
RaisePropertyChanged(nameof(IsNotBusy)); RaisePropertyChanged(nameof(IsNotBusy));
RaisePropertyChanged(nameof(CanAddImage));
RaiseSendCanExecuteChanged(); RaiseSendCanExecuteChanged();
RaiseCreateCanExecuteChanged(); RaiseCreateCanExecuteChanged();
_removePendingAttachmentCommand.RaiseCanExecuteChanged();
RaiseLoadEarlierCanExecuteChanged(); RaiseLoadEarlierCanExecuteChanged();
_retryRealtimeCommand.RaiseCanExecuteChanged(); _retryRealtimeCommand.RaiseCanExecuteChanged();
_selectTicketCommand.RaiseCanExecuteChanged(); _selectTicketCommand.RaiseCanExecuteChanged();
_addImagesCommand.RaiseCanExecuteChanged();
if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey)) if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey))
_ = SelectPendingTicketAsync(); _ = SelectPendingTicketAsync();
} }
@@ -624,6 +706,146 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
CancelDraft(); 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) public void Configure(TicketClientOptions options)
{ {
if (_disposed) if (_disposed)
@@ -652,6 +874,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
DraftText = string.Empty; DraftText = string.Empty;
Tickets.Clear(); Tickets.Clear();
Messages.Clear(); Messages.Clear();
ClearPendingAttachments();
if (_options.AppIds == null || _options.AppIds.Count == 0) if (_options.AppIds == null || _options.AppIds.Count == 0)
{ {
@@ -826,6 +1049,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
return; return;
SaveWorkOrderList(); SaveWorkOrderList();
_disposed = true; _disposed = true;
ClearPendingAttachments();
_ticketService.NotificationReceived -= OnNotificationReceived; _ticketService.NotificationReceived -= OnNotificationReceived;
_ticketService.StateChanged -= OnRealtimeStateChanged; _ticketService.StateChanged -= OnRealtimeStateChanged;
_ticketService.Dispose(); _ticketService.Dispose();
@@ -842,6 +1066,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsDraft = true; IsDraft = true;
SelectedTicket = null; SelectedTicket = null;
Messages.Clear(); Messages.Clear();
ClearPendingAttachments();
HasMoreMessages = false; HasMoreMessages = false;
_nextBefore = null; _nextBefore = null;
DraftText = string.Empty; DraftText = string.Empty;
@@ -861,6 +1086,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsDraft = false; IsDraft = false;
SelectedOrder = null; SelectedOrder = null;
DraftText = string.Empty; DraftText = string.Empty;
ClearPendingAttachments();
_pendingCreateFingerprint = null; _pendingCreateFingerprint = null;
_pendingCreateRequestId = null; _pendingCreateRequestId = null;
ClearNotice(); ClearNotice();
@@ -904,6 +1130,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
TicketSearchText = string.Empty; TicketSearchText = string.Empty;
CancelDraft(); CancelDraft();
ClearPendingAttachments();
ApplyKnownProductInfo(ticket); ApplyKnownProductInfo(ticket);
SelectedTicket = ticket; SelectedTicket = ticket;
await LoadMessagesAsync(ticket, replace: true); await LoadMessagesAsync(ticket, replace: true);
@@ -987,7 +1214,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var ticket = SelectedTicket; var ticket = SelectedTicket;
var text = (ChatText ?? string.Empty).Trim(); 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( if (!string.Equals(
fingerprint, fingerprint,
_pendingMessageFingerprint, _pendingMessageFingerprint,
@@ -1001,32 +1232,88 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
ClearNotice(); ClearNotice();
try 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( var result = await _ticketService.SendMessageAsync(
_options, _options,
ToSummaryDto(ticket), ToSummaryDto(ticket),
text, string.IsNullOrWhiteSpace(text) ? null : text,
_pendingMessageId); _pendingMessageId,
receipts);
if (result.Message != null) if (result.Message != null)
AppendMessage(result.Message); AppendMessage(result.Message);
ticket.ApplySendResult(result.Status, result.Version); ticket.ApplySendResult(result.Status, result.Version);
MoveTicketToTop(ticket); MoveTicketToTop(ticket);
TicketView.Refresh();
ChatText = string.Empty; ChatText = string.Empty;
_pendingMessageFingerprint = null; ClearPendingAttachments();
_pendingMessageId = null;
SetNotice( SetNotice(
result.Reopened result.Reopened
? "留言已发送,已完结工单已重新开启。" ? "留言已发送,已完结工单已重新开启。"
: "留言已发送。", : (pendingAttachments.Count > 0
? "图片已发送。"
: "留言已发送。"),
false); false);
await MarkReadBestEffortAsync(ticket); await MarkReadBestEffortAsync(ticket);
SaveWorkOrderList(); SaveWorkOrderList();
MessageScrollVersion++; MessageScrollVersion++;
} }
catch (TicketApiException ex) 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; _pendingMessageFingerprint = null;
_pendingMessageId = null; _pendingMessageId = null;
await RefreshAllWorkOrdersAsync( await RefreshAllWorkOrdersAsync(
@@ -1256,6 +1543,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
ApplyKnownProductInfo(existing); ApplyKnownProductInfo(existing);
SaveWorkOrderList(); SaveWorkOrderList();
ReorderTickets(); ReorderTickets();
TicketView.Refresh();
if (SelectedTicket != null if (SelectedTicket != null
&& string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal) && string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal)
&& !IsDraft) && !IsDraft)
@@ -1276,7 +1564,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var id = dto.MessageId; var id = dto.MessageId;
if (string.IsNullOrWhiteSpace(id)) if (string.IsNullOrWhiteSpace(id))
id = "sequence:" + dto.SequenceNo; id = "sequence:" + dto.SequenceNo;
if (!byId.ContainsKey(id)) if (byId.TryGetValue(id, out var existing))
existing.UpdateFrom(dto);
else
byId[id] = new ChatMessageViewModel(dto); byId[id] = new ChatMessageViewModel(dto);
} }
@@ -1450,6 +1740,13 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
if (!(item is WorkOrderSummaryViewModel ticket)) if (!(item is WorkOrderSummaryViewModel ticket))
return false; return false;
var expectedStatus = GetStatusFilter(SelectedTicketStatusIndex);
if (expectedStatus != null
&& !string.Equals(ticket.Status, expectedStatus, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var keyword = (TicketSearchText ?? string.Empty).Trim(); var keyword = (TicketSearchText ?? string.Empty).Trim();
if (keyword.Length == 0) if (keyword.Length == 0)
return true; return true;
@@ -1461,6 +1758,21 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|| ContainsIgnoreCase(ticket.PreviewText, keyword); || 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) private static bool ContainsIgnoreCase(string source, string keyword)
{ {
return !string.IsNullOrWhiteSpace(source) return !string.IsNullOrWhiteSpace(source)
@@ -1469,10 +1781,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private bool CanSendMessage() private bool CanSendMessage()
{ {
var text = (ChatText ?? string.Empty).Trim();
return !IsBusy return !IsBusy
&& SelectedTicket != null && SelectedTicket != null
&& !string.IsNullOrWhiteSpace(ChatText) && (!string.IsNullOrWhiteSpace(text) || PendingAttachments.Count > 0)
&& ChatText.Trim().Length <= 4000; && text.Length <= 4000;
} }
private void RaiseSendCanExecuteChanged() private void RaiseSendCanExecuteChanged()
@@ -1768,9 +2081,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
{ {
400 => "请求格式错误。", 400 => "请求格式错误。",
404 => "该 APPID 和订单下尚无工单。", 404 => "该 APPID 和订单下尚无工单。",
409 => "数据版本或幂等编号冲突。", 409 => "数据版本、幂等编号或图片回执冲突。",
410 => "图片上传凭证已过期。",
413 => "请求内容超过大小限制。", 413 => "请求内容超过大小限制。",
422 => "参数或订单归属校验失败。", 422 => "参数、图片格式或订单归属校验失败。",
429 => "请求过于频繁,请稍后重试。", 429 => "请求过于频繁,请稍后重试。",
503 => "工单服务暂未开启或数据库繁忙。", 503 => "工单服务暂未开启或数据库繁忙。",
_ => null _ => 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.Models;
using OrderSupportTicket.Services; using OrderSupportTicket.Services;
using OrderSupportTicket.ViewModels; using OrderSupportTicket.ViewModels;
using System.Runtime.InteropServices; using System;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input;
namespace OrderSupportTicket.Views; namespace OrderSupportTicket.Views;
/// <summary> /// <summary>
/// 工单对话页面。页面生命周期只负责激活/停用 ViewModel,业务交互均在 ViewModel 中。 /// 工单页面外壳,只负责组合子视图以及管理页面生命周期。
/// </summary> /// </summary>
public partial class OrderSupportTicketView : UserControl public partial class OrderSupportTicketView : UserControl
{ {
private bool _active; private bool _active;
private Window _hostWindow; private Window _hostWindow;
public OrderSupportTicketView() public OrderSupportTicketView() : this(new TicketService(), CreateDefaultOptions()) { }
: this(new TicketService(), CreateDefaultOptions())
{
}
/// <summary> public OrderSupportTicketView(TicketService ticketService, TicketClientOptions options = null)
/// 使用工单业务服务创建工单页。 {
/// </summary> InitializeComponent();
public OrderSupportTicketView( ViewModel = new OrderSupportTicketViewModel(ticketService, options ?? CreateDefaultOptions());
TicketService ticketService, DataContext = ViewModel;
TicketClientOptions options = null) Loaded += OnLoaded;
{ Unloaded += OnUnloaded;
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, "复制失败");
}
} }
private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e) public static TicketClientOptions CreateDefaultOptions(string fallbackUserName = null)
{ {
if (_active) var configured = TicketClientOptions.FromApplicationSettings();
return; var userName = !string.IsNullOrWhiteSpace(configured.UserName)
_active = true; ? configured.UserName
AttachHostWindow(Window.GetWindow(this)); : (!string.IsNullOrWhiteSpace(fallbackUserName) ? fallbackUserName.Trim() : "18990991234");
UpdateWindowActiveState(); var appIds = configured.AppIds != null && configured.AppIds.Count > 0
await ViewModel.InitializeAsync(); ? configured.AppIds
} : new List<string> { "wx316b1291351558aa", "wx7999743c3d64c05c" };
private async void OnUnloaded(object sender, System.Windows.RoutedEventArgs e) return new TicketClientOptions
{ {
if (!_active) UserName = userName,
return; AppIds = appIds,
_active = false; Orders =
ViewModel.IsWindowActive = false; [
DetachHostWindow(); new TicketOrderOption { Amount = 109m, AppId = "wx316b1291351558aa", GoodsName = "梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤", OrderId = "9000000000000000001", OrderedAt = new DateTime(2020, 1, 1) },
await ViewModel.DeactivateAsync(); new TicketOrderOption { Amount = 109m, AppId = "wx7999743c3d64c05c", GoodsName = "洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸", OrderId = "9000000000000000002", OrderedAt = new DateTime(2020, 1, 1) }
} ]
};
}
private void AttachHostWindow(Window window) public OrderSupportTicketViewModel ViewModel { get; }
{ public void Configure(TicketClientOptions options) => ViewModel.Configure(options);
DetachHostWindow(); public void OpenNewTicket(TicketOrderOption order) => ViewModel.OpenNewTicket(order);
_hostWindow = window; public void OpenTicket(string appId, string wechatOrderId) => ViewModel.OpenTicket(appId, wechatOrderId);
if (_hostWindow == null)
return;
_hostWindow.Activated += OnHostWindowActivated; private async void OnLoaded(object sender, RoutedEventArgs e)
_hostWindow.Deactivated += OnHostWindowDeactivated; {
_hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged; if (_active) return;
_hostWindow.StateChanged += OnHostWindowStateChanged; _active = true;
_hostWindow.Closed += OnHostWindowClosed; AttachHostWindow(Window.GetWindow(this));
} UpdateWindowActiveState();
await ViewModel.InitializeAsync();
}
private void DetachHostWindow() private async void OnUnloaded(object sender, RoutedEventArgs e)
{ {
if (_hostWindow == null) if (!_active) return;
return; _active = false;
ViewModel.IsWindowActive = false;
DetachHostWindow();
await ViewModel.DeactivateAsync();
}
_hostWindow.Activated -= OnHostWindowActivated; private void AttachHostWindow(Window window)
_hostWindow.Deactivated -= OnHostWindowDeactivated; {
_hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged; DetachHostWindow();
_hostWindow.StateChanged -= OnHostWindowStateChanged; _hostWindow = window;
_hostWindow.Closed -= OnHostWindowClosed; if (_hostWindow == null) return;
_hostWindow = null; _hostWindow.Activated += OnHostWindowStateChanged;
} _hostWindow.Deactivated += OnHostWindowStateChanged;
_hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
_hostWindow.StateChanged += OnHostWindowStateChanged;
_hostWindow.Closed += OnHostWindowClosed;
}
private void UpdateWindowActiveState() private void DetachHostWindow()
{ {
var window = _hostWindow; if (_hostWindow == null) return;
ViewModel.IsWindowActive = window != null _hostWindow.Activated -= OnHostWindowStateChanged;
&& window.IsVisible _hostWindow.Deactivated -= OnHostWindowStateChanged;
&& window.IsActive _hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
&& window.WindowState != WindowState.Minimized; _hostWindow.StateChanged -= OnHostWindowStateChanged;
} _hostWindow.Closed -= OnHostWindowClosed;
_hostWindow = null;
}
private void OnHostWindowActivated(object sender, EventArgs e) private void UpdateWindowActiveState()
{ {
UpdateWindowActiveState(); ViewModel.IsWindowActive = _hostWindow != null && _hostWindow.IsVisible && _hostWindow.IsActive && _hostWindow.WindowState != WindowState.Minimized;
} }
private void OnHostWindowDeactivated(object sender, EventArgs e) private void OnHostWindowStateChanged(object sender, EventArgs e) => UpdateWindowActiveState();
{ private void OnHostWindowIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) => UpdateWindowActiveState();
UpdateWindowActiveState(); private void OnHostWindowClosed(object sender, EventArgs e) { ViewModel.IsWindowActive = false; DetachHostWindow(); }
}
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();
}
} }
@@ -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
}
+1 -1
View File
@@ -34,7 +34,7 @@ namespace WxOrder.Framework.Helpers
/// </summary> /// </summary>
public sealed class NLogger : IDisposable, ILogger 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 readonly object _lock = new object();
private static NLogger? _instance; private static NLogger? _instance;
@@ -46,7 +46,6 @@
<ItemGroup> <ItemGroup>
<Folder Include="Services\" /> <Folder Include="Services\" />
<Folder Include="Controls\" />
</ItemGroup> </ItemGroup>
</Project> </Project>