工单 优化 ,增加图片
This commit is contained in:
1 parent
bf5a70597c
commit
cf2efbcb16
27 files changed
+3555
-1538
No files matched your search
@@ -0,0 +1,169 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 留言气泡中的公开图片附件。
|
||||
/// </summary>
|
||||
public sealed class ChatAttachmentViewModel : BindableBaseEx
|
||||
{
|
||||
private bool _isLoading;
|
||||
private bool _isLoadFailed;
|
||||
private ImageSource _image;
|
||||
|
||||
public ChatAttachmentViewModel(TicketAttachmentDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
throw new ArgumentNullException(nameof(dto));
|
||||
Id = dto.Id;
|
||||
OriginalName = dto.OriginalName;
|
||||
ContentType = dto.ContentType;
|
||||
ByteSize = dto.ByteSize;
|
||||
ReadUrl = dto.ReadUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附件唯一 ID。
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string OriginalName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片 MIME 类型。
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片字节数。
|
||||
/// </summary>
|
||||
public long ByteSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端生成的短时读取地址。
|
||||
/// </summary>
|
||||
public string ReadUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 已下载并解码的图片;加载完成前为空。
|
||||
/// </summary>
|
||||
public ImageSource Image
|
||||
{
|
||||
get => _image;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _image, value))
|
||||
RaisePropertyChanged(nameof(HasImage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已成功显示图片。
|
||||
/// </summary>
|
||||
public bool HasImage => Image != null;
|
||||
|
||||
/// <summary>
|
||||
/// 图片是否已经确认加载失败。加载完成前保持为 false。
|
||||
/// </summary>
|
||||
public bool IsLoadFailed
|
||||
{
|
||||
get => _isLoadFailed;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _isLoadFailed, value))
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 附件名称、大小与加载状态提示。
|
||||
/// </summary>
|
||||
public string ToolTipText
|
||||
{
|
||||
get
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(OriginalName) ? "图片" : OriginalName;
|
||||
var size = FormatSize(ByteSize);
|
||||
if (string.IsNullOrWhiteSpace(ReadUrl))
|
||||
return name + "(" + size + ",暂不可用)";
|
||||
if (_isLoading)
|
||||
return name + "(" + size + ",加载中…)";
|
||||
if (IsLoadFailed)
|
||||
return name + "(" + size + ",加载失败)";
|
||||
return name + "(" + size + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载并解码短时读取地址。失败时保留占位提示,不阻断文字留言展示。
|
||||
/// </summary>
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
if (Image != null || _isLoading)
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(ReadUrl))
|
||||
{
|
||||
IsLoadFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoadFailed = false;
|
||||
_isLoading = true;
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
try
|
||||
{
|
||||
var image = await ProductImageLoader.LoadAsync(ReadUrl);
|
||||
if (image == null)
|
||||
IsLoadFailed = true;
|
||||
else
|
||||
await SetImageAsync(image);
|
||||
}
|
||||
catch
|
||||
{
|
||||
IsLoadFailed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
RaisePropertyChanged(nameof(ToolTipText));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetImageAsync(ImageSource image)
|
||||
{
|
||||
if (image == null)
|
||||
return;
|
||||
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null || dispatcher.CheckAccess())
|
||||
{
|
||||
Image = image;
|
||||
IsLoadFailed = false;
|
||||
return;
|
||||
}
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Image = image;
|
||||
IsLoadFailed = false;
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatSize(long byteSize)
|
||||
{
|
||||
if (byteSize >= 1024 * 1024)
|
||||
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
|
||||
if (byteSize >= 1024)
|
||||
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
|
||||
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
@@ -22,6 +26,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
{
|
||||
if (dto == null)
|
||||
throw new ArgumentNullException(nameof(dto));
|
||||
Attachments = new ObservableCollection<ChatAttachmentViewModel>();
|
||||
MessageId = dto.MessageId;
|
||||
SequenceNo = dto.SequenceNo;
|
||||
SenderType = dto.SenderType;
|
||||
@@ -33,6 +38,7 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
? "该消息已撤回"
|
||||
: (dto.Text ?? string.Empty);
|
||||
CreatedAt = ParseTimestamp(dto.CreatedAt);
|
||||
ReplaceAttachments(dto.Attachments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,9 +87,18 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
public string Content
|
||||
{
|
||||
get => _content;
|
||||
private set => SetValue(ref _content, value);
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _content, value))
|
||||
RaisePropertyChanged(nameof(HasText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否有可展示的文字内容。
|
||||
/// </summary>
|
||||
public bool HasText => !string.IsNullOrWhiteSpace(Content);
|
||||
|
||||
/// <summary>
|
||||
/// 消息是否已撤回。
|
||||
/// </summary>
|
||||
@@ -106,6 +121,16 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 消息中的公开图片附件。
|
||||
/// </summary>
|
||||
public ObservableCollection<ChatAttachmentViewModel> Attachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含图片附件。
|
||||
/// </summary>
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 当前消息是否由客户发送。
|
||||
/// </summary>
|
||||
@@ -120,6 +145,50 @@ public sealed class ChatMessageViewModel : BindableBaseEx
|
||||
? string.Empty
|
||||
: CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// 使用服务端最新数据更新消息。短时图片地址会随每次查询变化,因此需要刷新附件。
|
||||
/// </summary>
|
||||
public void UpdateFrom(TicketMessageDto dto)
|
||||
{
|
||||
if (dto == null)
|
||||
return;
|
||||
|
||||
SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName)
|
||||
? (IsCustomer ? "我" : "客服")
|
||||
: dto.SenderName;
|
||||
IsRecalled = dto.Recalled;
|
||||
Content = dto.Recalled
|
||||
? "该消息已撤回"
|
||||
: (dto.Text ?? string.Empty);
|
||||
CreatedAt = ParseTimestamp(dto.CreatedAt);
|
||||
ReplaceAttachments(dto.Attachments);
|
||||
}
|
||||
|
||||
private void ReplaceAttachments(IEnumerable<TicketAttachmentDto> attachments)
|
||||
{
|
||||
Attachments.Clear();
|
||||
foreach (var dto in attachments ?? Enumerable.Empty<TicketAttachmentDto>())
|
||||
{
|
||||
if (dto == null)
|
||||
continue;
|
||||
Attachments.Add(new ChatAttachmentViewModel(dto));
|
||||
}
|
||||
RaisePropertyChanged(nameof(HasAttachments));
|
||||
_ = LoadAttachmentsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAttachmentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(Attachments.Select(item => item.LoadAsync()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 单张图片加载失败时保留占位提示,不影响文字留言显示。
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset ParseTimestamp(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AllBase.Helper;
|
||||
using AllBase.Helper;
|
||||
using AllBase.Model.DBModel;
|
||||
using Microsoft.Win32;
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
@@ -8,8 +9,10 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
@@ -17,6 +20,7 @@ using System.Windows.Threading;
|
||||
using WPFCommon.Models;
|
||||
using WxOrder.Framework.Commons;
|
||||
using WxOrder.Framework.Commons.Dialogs;
|
||||
using WxOrder.Framework.Controls;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
@@ -36,8 +40,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private readonly AsyncDelegateCommand<WorkOrderSummaryViewModel> _selectTicketCommand;
|
||||
private readonly AsyncDelegateCommand _createTicketCommand;
|
||||
private readonly AsyncDelegateCommand _sendMessageCommand;
|
||||
private readonly AsyncDelegateCommand<PendingAttachmentViewModel> _removePendingAttachmentCommand;
|
||||
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
|
||||
private readonly AsyncDelegateCommand _retryRealtimeCommand;
|
||||
private readonly DelegateCommand<FrameworkElement> _addImagesCommand;
|
||||
private readonly HashSet<string> _seenEventIds =
|
||||
new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _lastReadSequences =
|
||||
@@ -57,6 +63,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
private string _chatText;
|
||||
private string _draftText;
|
||||
private string _ticketSearchText = string.Empty;
|
||||
private int _selectedTicketStatusIndex;
|
||||
private bool _isDraft;
|
||||
private bool _isBusy;
|
||||
private bool _isLoading;
|
||||
@@ -88,6 +95,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
Tickets = new ObservableCollection<WorkOrderSummaryViewModel>();
|
||||
Messages = new ObservableCollection<ChatMessageViewModel>();
|
||||
PendingAttachments = new ObservableCollection<PendingAttachmentViewModel>();
|
||||
TicketView = CollectionViewSource.GetDefaultView(Tickets);
|
||||
TicketView.Filter = FilterTicket;
|
||||
_selectTicketCommand = new AsyncDelegateCommand<WorkOrderSummaryViewModel>(
|
||||
@@ -99,12 +107,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
_sendMessageCommand = new AsyncDelegateCommand(
|
||||
SendMessageAsync,
|
||||
CanSendMessage);
|
||||
_removePendingAttachmentCommand =
|
||||
new AsyncDelegateCommand<PendingAttachmentViewModel>(
|
||||
RemovePendingAttachmentAsync,
|
||||
attachment => attachment != null && !IsBusy);
|
||||
_loadEarlierMessagesCommand = new AsyncDelegateCommand(
|
||||
LoadEarlierMessagesAsync,
|
||||
() => !IsBusy && SelectedTicket != null && HasMoreMessages);
|
||||
_retryRealtimeCommand = new AsyncDelegateCommand(
|
||||
RestartRealtimeAsync,
|
||||
() => !IsBusy && _configured);
|
||||
() => _configured);
|
||||
_addImagesCommand = new DelegateCommand<FrameworkElement>(
|
||||
AddImages,
|
||||
_ => CanAddImage);
|
||||
_ticketService.NotificationReceived += OnNotificationReceived;
|
||||
_ticketService.StateChanged += OnRealtimeStateChanged;
|
||||
SseState = _ticketService.RealtimeState;
|
||||
@@ -144,6 +159,40 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
/// </summary>
|
||||
public ICommand SendMessageCommand => _sendMessageCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 移除待发送图片的命令。
|
||||
/// </summary>
|
||||
public ICommand RemovePendingAttachmentCommand => _removePendingAttachmentCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 当前留言待发送的本地图片,最多 3 张。
|
||||
/// </summary>
|
||||
public ObservableCollection<PendingAttachmentViewModel> PendingAttachments { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已选择待发送图片。
|
||||
/// </summary>
|
||||
public bool HasPendingAttachments => PendingAttachments.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 是否还可以继续选择图片。
|
||||
/// </summary>
|
||||
public bool CanAddImage =>
|
||||
!IsBusy
|
||||
&& SelectedTicket != null
|
||||
&& PendingAttachments.Count < TicketApiService.MaxImageCount;
|
||||
|
||||
/// <summary>
|
||||
/// 选择本地图片并加入当前留言的待发送列表。
|
||||
/// </summary>
|
||||
public ICommand AddImagesCommand => _addImagesCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 待发送图片数量展示文本。
|
||||
/// </summary>
|
||||
public string AttachmentCountText =>
|
||||
PendingAttachments.Count + " / " + TicketApiService.MaxImageCount;
|
||||
|
||||
/// <summary>
|
||||
/// 加载当前工单更早留言的命令。
|
||||
/// </summary>
|
||||
@@ -154,10 +203,23 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
/// </summary>
|
||||
public ICommand RetryRealtimeCommand => _retryRealtimeCommand;
|
||||
|
||||
/// <summary>
|
||||
/// 当前正在查看的历史工单。
|
||||
/// </summary>
|
||||
public WorkOrderSummaryViewModel SelectedTicket
|
||||
/// <summary>
|
||||
/// 显示图片
|
||||
/// </summary>
|
||||
public ICommand ShowImageWindowCommand => new DelegateCommand<ChatAttachmentViewModel>(ShowImageWindow);
|
||||
/// <summary>
|
||||
/// 显示图片
|
||||
/// </summary>
|
||||
/// <param name="chatAttachment"></param>
|
||||
private void ShowImageWindow(ChatAttachmentViewModel chatAttachment)
|
||||
{
|
||||
ImageViewerWindow.OpenDialog(chatAttachment.Image);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前正在查看的历史工单。
|
||||
/// </summary>
|
||||
public WorkOrderSummaryViewModel SelectedTicket
|
||||
{
|
||||
get => _selectedTicket;
|
||||
private set
|
||||
@@ -167,6 +229,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
RaisePropertyChanged(nameof(IsChatVisible));
|
||||
RaisePropertyChanged(nameof(IsEmptyVisible));
|
||||
RaisePropertyChanged(nameof(ComposerPlaceholder));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
RaiseSendCanExecuteChanged();
|
||||
RaiseLoadEarlierCanExecuteChanged();
|
||||
}
|
||||
@@ -240,6 +304,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 历史工单状态页签索引:0 全部、1 待客服回复、2 客服已回复、3 已完结。
|
||||
/// </summary>
|
||||
public int SelectedTicketStatusIndex
|
||||
{
|
||||
get => _selectedTicketStatusIndex;
|
||||
set
|
||||
{
|
||||
if (SetValue(ref _selectedTicketStatusIndex, value))
|
||||
TicketView?.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前是否处于新建工单草稿状态。
|
||||
/// </summary>
|
||||
@@ -253,6 +330,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
RaisePropertyChanged(nameof(IsChatVisible));
|
||||
RaisePropertyChanged(nameof(IsEmptyVisible));
|
||||
RaisePropertyChanged(nameof(IsDraftVisible));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
RaiseCreateCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -269,11 +348,14 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
if (SetValue(ref _isBusy, value))
|
||||
{
|
||||
RaisePropertyChanged(nameof(IsNotBusy));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
RaiseSendCanExecuteChanged();
|
||||
RaiseCreateCanExecuteChanged();
|
||||
_removePendingAttachmentCommand.RaiseCanExecuteChanged();
|
||||
RaiseLoadEarlierCanExecuteChanged();
|
||||
_retryRealtimeCommand.RaiseCanExecuteChanged();
|
||||
_selectTicketCommand.RaiseCanExecuteChanged();
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
if (!value && !string.IsNullOrWhiteSpace(_pendingOpenKey))
|
||||
_ = SelectPendingTicketAsync();
|
||||
}
|
||||
@@ -624,6 +706,146 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
CancelDraft();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把文件选择器返回的本地图片加入待发送列表。
|
||||
/// 每次留言最多 3 张,单张不超过 5 MiB。
|
||||
/// </summary>
|
||||
public void AddPendingImages(IEnumerable<string> filePaths)
|
||||
{
|
||||
if (filePaths == null || IsBusy || IsDraft || SelectedTicket == null)
|
||||
return;
|
||||
|
||||
var errors = new List<string>();
|
||||
var added = 0;
|
||||
foreach (var filePath in filePaths)
|
||||
{
|
||||
if (PendingAttachments.Count >= TicketApiService.MaxImageCount)
|
||||
{
|
||||
errors.Add("每次留言最多上传 3 张图片。");
|
||||
break;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
continue;
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
errors.Add("图片路径无效:" + filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PendingAttachments.Any(item =>
|
||||
string.Equals(item.FilePath, fullPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!PendingAttachmentViewModel.TryCreate(
|
||||
fullPath,
|
||||
out var attachment,
|
||||
out var error))
|
||||
{
|
||||
errors.Add(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
PendingAttachments.Add(attachment);
|
||||
added++;
|
||||
}
|
||||
|
||||
OnPendingAttachmentsChanged();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SetNotice(
|
||||
string.Join(" ", errors.Distinct(StringComparer.Ordinal)),
|
||||
true);
|
||||
}
|
||||
else if (added > 0)
|
||||
{
|
||||
ClearNotice();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将剪贴板中的位图加入待发送列表。
|
||||
/// </summary>
|
||||
public void AddPendingImage(System.Windows.Media.Imaging.BitmapSource bitmap)
|
||||
{
|
||||
if (IsBusy || IsDraft || SelectedTicket == null)
|
||||
return;
|
||||
if (PendingAttachments.Count >= TicketApiService.MaxImageCount)
|
||||
{
|
||||
SetNotice("每次留言最多上传 3 张图片。", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PendingAttachmentViewModel.TryCreateTemporary(
|
||||
bitmap, out var attachment, out var error))
|
||||
{
|
||||
SetNotice(error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
PendingAttachments.Add(attachment);
|
||||
OnPendingAttachmentsChanged();
|
||||
ClearNotice();
|
||||
}
|
||||
|
||||
private void AddImages(FrameworkElement commandSource)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择图片",
|
||||
Filter = "图片文件 (*.jpg;*.jpeg;*.png;*.webp)|*.jpg;*.jpeg;*.png;*.webp",
|
||||
CheckFileExists = true,
|
||||
Multiselect = true
|
||||
};
|
||||
|
||||
var owner = commandSource == null ? null : Window.GetWindow(commandSource);
|
||||
var selected = owner == null
|
||||
? dialog.ShowDialog()
|
||||
: dialog.ShowDialog(owner);
|
||||
if (selected == true)
|
||||
AddPendingImages(dialog.FileNames);
|
||||
}
|
||||
|
||||
private Task RemovePendingAttachmentAsync(
|
||||
PendingAttachmentViewModel attachment)
|
||||
{
|
||||
if (attachment == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (PendingAttachments.Remove(attachment))
|
||||
attachment.Dispose();
|
||||
OnPendingAttachmentsChanged();
|
||||
ClearNotice();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ClearPendingAttachments()
|
||||
{
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.Dispose();
|
||||
if (PendingAttachments.Count > 0)
|
||||
PendingAttachments.Clear();
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
OnPendingAttachmentsChanged();
|
||||
}
|
||||
|
||||
private void OnPendingAttachmentsChanged()
|
||||
{
|
||||
RaisePropertyChanged(nameof(HasPendingAttachments));
|
||||
RaisePropertyChanged(nameof(CanAddImage));
|
||||
RaisePropertyChanged(nameof(AttachmentCountText));
|
||||
RaiseSendCanExecuteChanged();
|
||||
_removePendingAttachmentCommand.RaiseCanExecuteChanged();
|
||||
_addImagesCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
public void Configure(TicketClientOptions options)
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -652,6 +874,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
DraftText = string.Empty;
|
||||
Tickets.Clear();
|
||||
Messages.Clear();
|
||||
ClearPendingAttachments();
|
||||
|
||||
if (_options.AppIds == null || _options.AppIds.Count == 0)
|
||||
{
|
||||
@@ -826,6 +1049,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
return;
|
||||
SaveWorkOrderList();
|
||||
_disposed = true;
|
||||
ClearPendingAttachments();
|
||||
_ticketService.NotificationReceived -= OnNotificationReceived;
|
||||
_ticketService.StateChanged -= OnRealtimeStateChanged;
|
||||
_ticketService.Dispose();
|
||||
@@ -842,6 +1066,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
IsDraft = true;
|
||||
SelectedTicket = null;
|
||||
Messages.Clear();
|
||||
ClearPendingAttachments();
|
||||
HasMoreMessages = false;
|
||||
_nextBefore = null;
|
||||
DraftText = string.Empty;
|
||||
@@ -861,6 +1086,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
IsDraft = false;
|
||||
SelectedOrder = null;
|
||||
DraftText = string.Empty;
|
||||
ClearPendingAttachments();
|
||||
_pendingCreateFingerprint = null;
|
||||
_pendingCreateRequestId = null;
|
||||
ClearNotice();
|
||||
@@ -904,6 +1130,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
TicketSearchText = string.Empty;
|
||||
CancelDraft();
|
||||
ClearPendingAttachments();
|
||||
ApplyKnownProductInfo(ticket);
|
||||
SelectedTicket = ticket;
|
||||
await LoadMessagesAsync(ticket, replace: true);
|
||||
@@ -987,7 +1214,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
var ticket = SelectedTicket;
|
||||
var text = (ChatText ?? string.Empty).Trim();
|
||||
var fingerprint = ticket.Key + "|" + text;
|
||||
var pendingAttachments = PendingAttachments.ToList();
|
||||
var attachmentFingerprint = string.Join(
|
||||
"|",
|
||||
pendingAttachments.Select(item => item.Fingerprint));
|
||||
var fingerprint = ticket.Key + "|" + text + "|" + attachmentFingerprint;
|
||||
if (!string.Equals(
|
||||
fingerprint,
|
||||
_pendingMessageFingerprint,
|
||||
@@ -1001,32 +1232,88 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
ClearNotice();
|
||||
try
|
||||
{
|
||||
var receipts = new List<MessageAttachmentReceipt>();
|
||||
for (var index = 0; index < pendingAttachments.Count; index++)
|
||||
{
|
||||
var attachment = pendingAttachments[index];
|
||||
if (!attachment.HasReceipt)
|
||||
{
|
||||
SetNotice(
|
||||
"正在上传图片 "
|
||||
+ (index + 1)
|
||||
+ "/"
|
||||
+ pendingAttachments.Count
|
||||
+ "…",
|
||||
false);
|
||||
var uploadTicket =
|
||||
await _ticketService.CreateImageUploadTicketAsync(
|
||||
_options,
|
||||
ticket.AppId,
|
||||
ticket.WechatOrderId,
|
||||
attachment.FileName,
|
||||
attachment.MimeType,
|
||||
attachment.ByteSize);
|
||||
var receipt = await _ticketService.UploadImageAsync(
|
||||
uploadTicket.Upload,
|
||||
attachment.FilePath);
|
||||
attachment.SetReceipt(receipt);
|
||||
}
|
||||
receipts.Add(attachment.Receipt);
|
||||
}
|
||||
|
||||
if (pendingAttachments.Count > 0)
|
||||
SetNotice("正在发送留言…", false);
|
||||
|
||||
var result = await _ticketService.SendMessageAsync(
|
||||
_options,
|
||||
ToSummaryDto(ticket),
|
||||
text,
|
||||
_pendingMessageId);
|
||||
string.IsNullOrWhiteSpace(text) ? null : text,
|
||||
_pendingMessageId,
|
||||
receipts);
|
||||
|
||||
if (result.Message != null)
|
||||
AppendMessage(result.Message);
|
||||
ticket.ApplySendResult(result.Status, result.Version);
|
||||
MoveTicketToTop(ticket);
|
||||
TicketView.Refresh();
|
||||
ChatText = string.Empty;
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
ClearPendingAttachments();
|
||||
SetNotice(
|
||||
result.Reopened
|
||||
? "留言已发送,已完结工单已重新开启。"
|
||||
: "留言已发送。",
|
||||
: (pendingAttachments.Count > 0
|
||||
? "图片已发送。"
|
||||
: "留言已发送。"),
|
||||
false);
|
||||
await MarkReadBestEffortAsync(ticket);
|
||||
SaveWorkOrderList();
|
||||
MessageScrollVersion++;
|
||||
}
|
||||
catch (TicketApiException ex)
|
||||
when (ex.StatusCode == 409)
|
||||
when (ex.StatusCode == 410
|
||||
|| string.Equals(
|
||||
ex.ErrorCode,
|
||||
"client_api_image_expired",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetNotice("工单状态或数据版本发生冲突,正在刷新最新内容。", true);
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.ClearReceipt();
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
SetNotice(
|
||||
"图片上传凭证已过期,已重置上传状态,请重新点击发送。",
|
||||
true);
|
||||
}
|
||||
catch (TicketApiException ex)
|
||||
when (ex.StatusCode == 409
|
||||
|| string.Equals(
|
||||
ex.ErrorCode,
|
||||
"client_api_image_already_used",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var attachment in PendingAttachments)
|
||||
attachment.ClearReceipt();
|
||||
SetNotice("工单状态或图片回执发生冲突,正在刷新最新内容。", true);
|
||||
_pendingMessageFingerprint = null;
|
||||
_pendingMessageId = null;
|
||||
await RefreshAllWorkOrdersAsync(
|
||||
@@ -1256,6 +1543,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
ApplyKnownProductInfo(existing);
|
||||
SaveWorkOrderList();
|
||||
ReorderTickets();
|
||||
TicketView.Refresh();
|
||||
if (SelectedTicket != null
|
||||
&& string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal)
|
||||
&& !IsDraft)
|
||||
@@ -1276,7 +1564,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
var id = dto.MessageId;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
id = "sequence:" + dto.SequenceNo;
|
||||
if (!byId.ContainsKey(id))
|
||||
if (byId.TryGetValue(id, out var existing))
|
||||
existing.UpdateFrom(dto);
|
||||
else
|
||||
byId[id] = new ChatMessageViewModel(dto);
|
||||
}
|
||||
|
||||
@@ -1450,6 +1740,13 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
if (!(item is WorkOrderSummaryViewModel ticket))
|
||||
return false;
|
||||
|
||||
var expectedStatus = GetStatusFilter(SelectedTicketStatusIndex);
|
||||
if (expectedStatus != null
|
||||
&& !string.Equals(ticket.Status, expectedStatus, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keyword = (TicketSearchText ?? string.Empty).Trim();
|
||||
if (keyword.Length == 0)
|
||||
return true;
|
||||
@@ -1461,6 +1758,21 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|| ContainsIgnoreCase(ticket.PreviewText, keyword);
|
||||
}
|
||||
|
||||
private static string GetStatusFilter(int selectedIndex)
|
||||
{
|
||||
switch (selectedIndex)
|
||||
{
|
||||
case 1:
|
||||
return "waiting_reply";
|
||||
case 2:
|
||||
return "replied";
|
||||
case 3:
|
||||
return "completed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string source, string keyword)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(source)
|
||||
@@ -1469,10 +1781,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
|
||||
private bool CanSendMessage()
|
||||
{
|
||||
var text = (ChatText ?? string.Empty).Trim();
|
||||
return !IsBusy
|
||||
&& SelectedTicket != null
|
||||
&& !string.IsNullOrWhiteSpace(ChatText)
|
||||
&& ChatText.Trim().Length <= 4000;
|
||||
&& (!string.IsNullOrWhiteSpace(text) || PendingAttachments.Count > 0)
|
||||
&& text.Length <= 4000;
|
||||
}
|
||||
|
||||
private void RaiseSendCanExecuteChanged()
|
||||
@@ -1768,9 +2081,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
|
||||
{
|
||||
400 => "请求格式错误。",
|
||||
404 => "该 APPID 和订单下尚无工单。",
|
||||
409 => "数据版本或幂等编号冲突。",
|
||||
409 => "数据版本、幂等编号或图片回执冲突。",
|
||||
410 => "图片上传凭证已过期。",
|
||||
413 => "请求内容超过大小限制。",
|
||||
422 => "参数或订单归属校验失败。",
|
||||
422 => "参数、图片格式或订单归属校验失败。",
|
||||
429 => "请求过于频繁,请稍后重试。",
|
||||
503 => "工单服务暂未开启或数据库繁忙。",
|
||||
_ => null
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using OrderSupportTicket.Services;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using WPFCommon.Models;
|
||||
|
||||
namespace OrderSupportTicket.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 发送留言前已选择、待上传的本地图片。
|
||||
/// </summary>
|
||||
public sealed class PendingAttachmentViewModel : BindableBaseEx, IDisposable
|
||||
{
|
||||
private const long MaxImageBytes = TicketApiService.MaxImageBytes;
|
||||
private ImageSource _preview;
|
||||
private MessageAttachmentReceipt _receipt;
|
||||
private string _receiptFingerprint;
|
||||
private readonly bool _deleteFileOnDispose;
|
||||
private bool _disposed;
|
||||
|
||||
private PendingAttachmentViewModel(
|
||||
string filePath,
|
||||
string fileName,
|
||||
string mimeType,
|
||||
long byteSize,
|
||||
DateTime lastWriteTimeUtc,
|
||||
bool deleteFileOnDispose = false)
|
||||
{
|
||||
FilePath = filePath;
|
||||
FileName = fileName;
|
||||
MimeType = mimeType;
|
||||
ByteSize = byteSize;
|
||||
_deleteFileOnDispose = deleteFileOnDispose;
|
||||
Fingerprint = filePath.ToLowerInvariant()
|
||||
+ "|"
|
||||
+ byteSize.ToString(CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
Preview = LoadPreview(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 本地绝对路径,仅用于读取并直传,不提交给业务接口。
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片 MIME 类型。
|
||||
/// </summary>
|
||||
public string MimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 图片字节数。
|
||||
/// </summary>
|
||||
public long ByteSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 本地文件内容标识,用于判断是否需要重新上传。
|
||||
/// </summary>
|
||||
public string Fingerprint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 本地缩略图;WebP 等当前 WPF 解码器不支持时为空。
|
||||
/// </summary>
|
||||
public ImageSource Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _preview, value))
|
||||
RaisePropertyChanged(nameof(HasPreview));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否已加载本地缩略图。
|
||||
/// </summary>
|
||||
public bool HasPreview => Preview != null;
|
||||
|
||||
/// <summary>
|
||||
/// 当前文件是否已有可复用的上传回执。
|
||||
/// </summary>
|
||||
public bool HasReceipt =>
|
||||
Receipt != null
|
||||
&& string.Equals(_receiptFingerprint, Fingerprint, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 上传回执;仅在本类内部更新。
|
||||
/// </summary>
|
||||
public MessageAttachmentReceipt Receipt
|
||||
{
|
||||
get => _receipt;
|
||||
private set
|
||||
{
|
||||
if (SetValue(ref _receipt, value))
|
||||
RaisePropertyChanged(nameof(HasReceipt));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图片大小展示文本。
|
||||
/// </summary>
|
||||
public string SizeText => FormatSize(ByteSize);
|
||||
|
||||
/// <summary>
|
||||
/// 保存本次上传成功的回执,后续网络重试可直接复用。
|
||||
/// </summary>
|
||||
public void SetReceipt(MessageAttachmentReceipt receipt)
|
||||
{
|
||||
if (receipt == null)
|
||||
throw new ArgumentNullException(nameof(receipt));
|
||||
_receiptFingerprint = Fingerprint;
|
||||
Receipt = receipt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除已失效的上传回执,下次发送时重新申请凭证。
|
||||
/// </summary>
|
||||
public void ClearReceipt()
|
||||
{
|
||||
_receiptFingerprint = null;
|
||||
Receipt = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并创建本地待发送图片。
|
||||
/// </summary>
|
||||
public static bool TryCreate(
|
||||
string filePath,
|
||||
out PendingAttachmentViewModel attachment,
|
||||
out string error)
|
||||
{
|
||||
attachment = null;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
error = "图片路径为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
FileInfo file;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(filePath);
|
||||
file = new FileInfo(fullPath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
error = "图片文件不存在:" + Path.GetFileName(filePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = "无法读取图片文件:" + ex.Message;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetMimeType(file.Extension, out var mimeType))
|
||||
{
|
||||
error = "仅支持 JPG、PNG、WebP 图片:" + file.Name;
|
||||
return false;
|
||||
}
|
||||
if (file.Length < 1 || file.Length > MaxImageBytes)
|
||||
{
|
||||
error = "单张图片必须大于 0 且不超过 5 MiB:" + file.Name;
|
||||
return false;
|
||||
}
|
||||
|
||||
attachment = new PendingAttachmentViewModel(
|
||||
fullPath,
|
||||
file.Name,
|
||||
mimeType,
|
||||
file.Length,
|
||||
file.LastWriteTimeUtc);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将剪贴板位图保存为临时 PNG,并创建可上传的附件。
|
||||
/// </summary>
|
||||
public static bool TryCreateTemporary(
|
||||
BitmapSource bitmap,
|
||||
out PendingAttachmentViewModel attachment,
|
||||
out string error)
|
||||
{
|
||||
attachment = null;
|
||||
error = null;
|
||||
if (bitmap == null)
|
||||
{
|
||||
error = "剪贴板中没有可用的图片。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string filePath = null;
|
||||
try
|
||||
{
|
||||
filePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"WxOrder-Clipboard-" + Guid.NewGuid().ToString("N") + ".png");
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using (var stream = File.Create(filePath))
|
||||
encoder.Save(stream);
|
||||
|
||||
var file = new FileInfo(filePath);
|
||||
if (file.Length < 1 || file.Length > MaxImageBytes)
|
||||
{
|
||||
error = "粘贴的图片必须大于 0 且不超过 5 MiB。";
|
||||
TryDelete(filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
attachment = new PendingAttachmentViewModel(
|
||||
file.FullName,
|
||||
"粘贴图片.png",
|
||||
"image/png",
|
||||
file.Length,
|
||||
file.LastWriteTimeUtc,
|
||||
deleteFileOnDispose: true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TryDelete(filePath);
|
||||
error = "无法读取剪贴板图片:" + ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
if (_deleteFileOnDispose)
|
||||
TryDelete(FilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据扩展名映射文档要求的精确 MIME 类型。
|
||||
/// </summary>
|
||||
public static bool TryGetMimeType(string extension, out string mimeType)
|
||||
{
|
||||
switch ((extension ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
return true;
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
return true;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
return true;
|
||||
default:
|
||||
mimeType = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ImageSource LoadPreview(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var image = new BitmapImage();
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
|
||||
image.DecodePixelWidth = 160;
|
||||
image.StreamSource = stream;
|
||||
image.EndInit();
|
||||
}
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 临时文件清理失败不影响留言发送流程。
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long byteSize)
|
||||
{
|
||||
if (byteSize >= 1024 * 1024)
|
||||
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
|
||||
if (byteSize >= 1024)
|
||||
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
|
||||
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user