工单 优化 ,增加图片

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

@@ -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";
}
}