工单 优化 ,增加图片

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

@@ -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