From cf2efbcb16d4a22c554980332b22289b18221ac2 Mon Sep 17 00:00:00 2001 From: chfei Date: Tue, 20 Oct 2026 17:03:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=8D=95=20=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 29WxOrderWpf/MainWindow.xaml | 529 ++++---- 29WxOrderWpf/MainWindow.xaml.cs | 9 +- AllBase/ImageBuild.cs | 4 +- .../LaunchDarkly.EventSource/EventSource.cs | 2 +- .../Models/Api/WorkOrderContracts.cs | 186 ++- OrderSupportTicket/OrderSupportTicket.csproj | 2 +- .../EventSourceTicketRealtimeService.cs | 6 +- .../Services/TicketApiException.cs | 13 +- .../Services/TicketApiService.cs | 362 +++++- OrderSupportTicket/Services/TicketService.cs | 42 +- .../ViewModels/ChatAttachmentViewModel.cs | 169 +++ .../ViewModels/ChatMessageViewModel.cs | 73 +- .../ViewModels/OrderSupportTicketViewModel.cs | 352 +++++- .../ViewModels/PendingAttachmentViewModel.cs | 319 +++++ .../Views/OrderSupportTicketStyles.xaml | 290 +++++ .../Views/OrderSupportTicketView.xaml | 1085 ++--------------- .../Views/OrderSupportTicketView.xaml.cs | 289 ++--- .../Views/TicketComposerView.xaml | 197 +++ .../Views/TicketComposerView.xaml.cs | 29 + .../Views/TicketConversationView.xaml | 272 +++++ .../Views/TicketConversationView.xaml.cs | 18 + .../Views/TicketSidebarView.xaml | 289 +++++ .../Views/TicketSidebarView.xaml.cs | 22 + .../Controls/ImageViewerWindow.xaml | 79 ++ .../Controls/ImageViewerWindow.xaml.cs | 452 +++++++ WxOrder.Framework/Helpers/NLogger.cs | 2 +- WxOrder.Framework/WxOrder.Framework.csproj | 1 - 27 files changed, 3555 insertions(+), 1538 deletions(-) create mode 100644 OrderSupportTicket/ViewModels/ChatAttachmentViewModel.cs create mode 100644 OrderSupportTicket/ViewModels/PendingAttachmentViewModel.cs create mode 100644 OrderSupportTicket/Views/OrderSupportTicketStyles.xaml create mode 100644 OrderSupportTicket/Views/TicketComposerView.xaml create mode 100644 OrderSupportTicket/Views/TicketComposerView.xaml.cs create mode 100644 OrderSupportTicket/Views/TicketConversationView.xaml create mode 100644 OrderSupportTicket/Views/TicketConversationView.xaml.cs create mode 100644 OrderSupportTicket/Views/TicketSidebarView.xaml create mode 100644 OrderSupportTicket/Views/TicketSidebarView.xaml.cs create mode 100644 WxOrder.Framework/Controls/ImageViewerWindow.xaml create mode 100644 WxOrder.Framework/Controls/ImageViewerWindow.xaml.cs diff --git a/29WxOrderWpf/MainWindow.xaml b/29WxOrderWpf/MainWindow.xaml index ce03a0a..2ff15c3 100644 --- a/29WxOrderWpf/MainWindow.xaml +++ b/29WxOrderWpf/MainWindow.xaml @@ -1,273 +1,274 @@  + x:Class="_29WxOrderWpf.MainWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:basewindows="clr-namespace:_29WxOrderWpf.BaseWindows" + xmlns:buyerctrls="clr-namespace:_29WxOrderWpf.Ctrls.BuyerCtrls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="clr-namespace:_29WxOrderWpf" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:orderctrls="clr-namespace:_29WxOrderWpf.Ctrls.OrderCtrls" + xmlns:shopctrls="clr-namespace:_29WxOrderWpf.Ctrls.ShopCtrls" + xmlns:userctrls="clr-namespace:_29WxOrderWpf.UserControls" + x:Name="mainWindow" + Title="MainWindow" + Width="1600" + Height="900" + Closing="BaseWindow_Closing" + Loaded="Window_Loaded" + Style="{StaticResource BaseNoneBorderWindowStyle}" + mc:Ignorable="d"> - - - - - - - - - - - - - - - + + + + + + + + diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs index 8aa8dde..0f96ae1 100644 --- a/29WxOrderWpf/MainWindow.xaml.cs +++ b/29WxOrderWpf/MainWindow.xaml.cs @@ -107,7 +107,7 @@ namespace _29WxOrderWpf pddBuyerSelectCtrl.Visibility = Visibility.Visible; Task.Run(async () => { - GetServerShop(); + GetServerShop(); await StartTicketRealtimeMonitor();//启动工单监控 GetShopSupplier(GlobalConfig.ShopLoginList.ToList()); }); @@ -239,7 +239,10 @@ namespace _29WxOrderWpf private void BaseWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e) { if (BaseMsgWindow.ShowConfirm("是否关闭软件?", "退出微信小店订单管理").DialogResult == true) + { + _ticketMonitor.Dispose(); Process.GetCurrentProcess().Kill(); + } else e.Cancel = true; } @@ -611,6 +614,10 @@ namespace _29WxOrderWpf await _ticketMonitor.Start(options); UpdateOrderSupportBadge(); _ = LoadHistoricalTicketUnreadAsync(options); + this.Dispatcher.Invoke(() => + { + btOrderSupport.IsEnabled = true; + }); } catch (Exception ex) { diff --git a/AllBase/ImageBuild.cs b/AllBase/ImageBuild.cs index d4bd1c6..3a617a0 100644 --- a/AllBase/ImageBuild.cs +++ b/AllBase/ImageBuild.cs @@ -415,7 +415,7 @@ namespace AllBase bitmap.Freeze(); // 防止后续更改,提高性能 } } - } + } catch (Exception ex) { // 处理异常,例如打印错误信息或设置一个默认图片等 @@ -425,7 +425,7 @@ namespace AllBase return bitmap; } - + public static async Task LoadBitmipImageFromUrlAsync(string url, int? width = null, int? height = null) { BitmapImage bitmap = new BitmapImage(); diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs index 51516bd..6a75045 100644 --- a/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs +++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs @@ -177,6 +177,7 @@ namespace LaunchDarkly.EventSource } catch (Exception e) { + _logger.Error(e.Message); CancelCurrentRequest(); // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. @@ -200,7 +201,6 @@ namespace LaunchDarkly.EventSource realException = new TimeoutException(); } } - if (realException != null) { OnError(new ExceptionEventArgs(realException)); diff --git a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs index e2ee8b9..bed459c 100644 --- a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs +++ b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; namespace OrderSupportTicket.Models.Api; @@ -139,10 +139,16 @@ internal sealed class SendMessageRequest public string ClientMessageId { get; set; } /// - /// 留言正文,长度 1~4000 字。 + /// 留言正文;有图片附件时可为空。 /// ////[JsonProperty("text")] public string Text { get; set; } + + /// + /// 图片上传回执,最多 3 个;无图片时为 null,序列化时省略。 + /// + ////[JsonProperty("attachments")] + public IList Attachments { get; set; } } /// @@ -169,6 +175,176 @@ internal sealed class MarkReadRequest public int ThroughSequenceNo { get; set; } } +/// +/// 申请图片直传凭证请求。 +/// +internal sealed class CreateImageUploadTicketRequest +{ + /// + /// 拍单账号。 + /// + public string PurchaseAccount { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + public string AppId { get; set; } + + /// + /// 原始文件名,1~180 字符。 + /// + public string FileName { get; set; } + + /// + /// 精确的图片 MIME 类型:image/jpeg、image/png 或 image/webp。 + /// + public string MimeType { get; set; } + + /// + /// 图片实际字节数,1~5,242,880。 + /// + public long Size { get; set; } + + /// + /// 可选的上传标识;省略时由服务端生成。 + /// + public string RequestId { get; set; } +} + +/// +/// 图片直传凭证响应。 +/// +public sealed class ImageUploadTicketResponse +{ + /// + /// 请求是否成功。 + /// + public bool Success { get; set; } + + /// + /// 本次上传的凭证信息。 + /// + public ImageUploadTicketDto Upload { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + public string AppId { get; set; } + + /// + /// 微信订单号。 + /// + public string WechatOrderId { get; set; } + + /// + /// 接口返回的身份校验标记,当前恒为 false。 + /// + public bool IdentityVerified { get; set; } + + /// + /// 本次 HTTP 请求的排查编号;不是 upload.requestId。 + /// + public string RequestId { get; set; } +} + +/// +/// 服务端签发的单次图片直传凭证。 +/// +public sealed class ImageUploadTicketDto +{ + /// + /// 上传模式,文档约定为 single。 + /// + public string Mode { get; set; } + + /// + /// 上传 HTTP 方法,文档约定为 PUT。 + /// + public string Method { get; set; } + + /// + /// 本次图片上传标识。 + /// + public string RequestId { get; set; } + + /// + /// 服务端签发的上传凭证,需原样提交留言接口。 + /// + public string Ticket { get; set; } + + /// + /// 带短时签名的 COS 上传地址。 + /// + public string UploadUrl { get; set; } + + /// + /// 直传时必须原样携带的请求头。 + /// + public Dictionary Headers { get; set; } + + /// + /// 上传凭证过期时间。 + /// + public string ExpiresAt { get; set; } +} + +/// +/// 图片直传完成后提交给留言接口的回执。 +/// +public sealed class MessageAttachmentReceipt +{ + /// + /// 原样使用 upload.requestId。 + /// + public string RequestId { get; set; } + + /// + /// 原样使用 upload.ticket。 + /// + public string Ticket { get; set; } + + /// + /// 可选的 COS 响应 ETag。 + /// + public string Etag { get; set; } +} + +/// +/// 留言中的公开图片附件。 +/// +public sealed class TicketAttachmentDto +{ + /// + /// 附件唯一 ID。 + /// + public string Id { get; set; } + + /// + /// 原始文件名。 + /// + public string OriginalName { get; set; } + + /// + /// 图片 MIME 类型。 + /// + public string ContentType { get; set; } + + /// + /// 图片字节数。 + /// + public long ByteSize { get; set; } + + /// + /// 图片创建时间。 + /// + public string CreatedAt { get; set; } + + /// + /// 短时读取地址;暂不可用时为 null。 + /// + public string ReadUrl { get; set; } +} + /// /// 工单摘要分页响应。 /// @@ -371,6 +547,12 @@ public sealed class TicketMessageDto /// ////[JsonProperty("createdAt")] public string CreatedAt { get; set; } + + /// + /// 公开图片附件;无图片时为空数组。 + /// + ////[JsonProperty("attachments")] + public List Attachments { get; set; } } /// diff --git a/OrderSupportTicket/OrderSupportTicket.csproj b/OrderSupportTicket/OrderSupportTicket.csproj index cfaaea6..fa898cb 100644 --- a/OrderSupportTicket/OrderSupportTicket.csproj +++ b/OrderSupportTicket/OrderSupportTicket.csproj @@ -16,7 +16,7 @@ - + diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index 6e4e04a..99b1446 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -33,7 +33,7 @@ public sealed class EventSourceTicketRealtimeService /// public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected; - public async Task Start(TicketClientOptions options) + public Task Start(TicketClientOptions options) { if (_disposed) throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService)); @@ -56,7 +56,8 @@ public sealed class EventSourceTicketRealtimeService } SetState(TicketRealtimeState.Connecting, "连接中…"); - await RunAsync(source); + _= RunAsync(source); + return Task.FromResult(0); } public Task StopAsync() @@ -220,6 +221,7 @@ public sealed class EventSourceTicketRealtimeService ConfigurationBuilder builder = Configuration.Builder(uri); builder.RequestHeader("X-AppIds", $"[\"{string.Join("\",\"", appIds)}\"]"); builder.Logger(NLogger.Instance); + //builder.LastEventId(""); //var proxy = new WebProxy("http://127.0.0.1:8081"); // //var handler = new HttpClientHandler //{ diff --git a/OrderSupportTicket/Services/TicketApiException.cs b/OrderSupportTicket/Services/TicketApiException.cs index e78e14e..cbcc825 100644 --- a/OrderSupportTicket/Services/TicketApiException.cs +++ b/OrderSupportTicket/Services/TicketApiException.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace OrderSupportTicket.Services; @@ -13,11 +13,13 @@ public sealed class TicketApiException : Exception public TicketApiException( int statusCode, string message, - string responseBody = null) + string responseBody = null, + string errorCode = null) : base(message) { StatusCode = statusCode; ResponseBody = responseBody; + ErrorCode = errorCode; } /// @@ -29,4 +31,9 @@ public sealed class TicketApiException : Exception /// 服务端返回的原始响应正文。 /// public string ResponseBody { get; } -} \ No newline at end of file + + /// + /// 服务端返回的错误码,例如 client_api_image_expired。 + /// + public string ErrorCode { get; } +} diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs index 2740135..af77607 100644 --- a/OrderSupportTicket/Services/TicketApiService.cs +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -1,11 +1,14 @@ -using AllBase; +using AllBase; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net; +using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; using WxOrder.Framework.Commons; @@ -19,6 +22,16 @@ public sealed class TicketApiService { internal static string ServerUrl = "https://ai.e57.cn"; private const string ApiRoot = "/api/open/v2/customer-work-orders"; + + /// + /// 每条留言最多可携带的图片数量。 + /// + public const int MaxImageCount = 3; + + /// + /// 单张图片允许的最大字节数(5 MiB)。 + /// + public const long MaxImageBytes = 5 * 1024 * 1024; private static readonly Regex AppIdRegex = new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex OrderIdRegex = @@ -27,6 +40,14 @@ public sealed class TicketApiService new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled); private static readonly Regex OperationIdRegex = new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly HashSet SupportedImageMimeTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", + "image/png", + "image/webp" + }; + private static readonly HttpClient ImageUploadClient = CreateImageUploadClient(); internal static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, @@ -158,22 +179,25 @@ public sealed class TicketApiService TicketClientOptions options, WorkOrderSummaryDto workOrder, string text, - string clientMessageId) + string clientMessageId, + IList attachments = null) { ValidateOptions(options); if (workOrder == null) throw new ArgumentNullException(nameof(workOrder)); ValidateAppId(workOrder.AppId); ValidateWechatOrderId(workOrder.WechatOrderId); - ValidateMessageText(text); + ValidateMessageContent(text, attachments); ValidateOperationId(clientMessageId, "clientMessageId", required: true); + var hasAttachments = attachments != null && attachments.Count > 0; var request = new SendMessageRequest { PurchaseAccount = options.UserName, AppId = workOrder.AppId, ClientMessageId = clientMessageId, - Text = text + Text = string.IsNullOrWhiteSpace(text) ? null : text, + Attachments = hasAttachments ? attachments.ToList() : null }; var path = ApiRoot + "/orders/" @@ -182,6 +206,139 @@ public sealed class TicketApiService return await PostAsync(path, request); } + /// + /// 申请单张图片的短时直传凭证。每张图片单独申请。 + /// + public async Task 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(path, request); + ValidateImageUploadTicket(response?.Upload); + return response; + } + + /// + /// 使用服务端签发的凭证把图片二进制直接 PUT 到 COS。 + /// 返回的 requestId/ticket/etag 需原样提交到留言接口。 + /// + public async Task 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()) + { + if (string.IsNullOrWhiteSpace(header.Key)) + continue; + if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + continue; + if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value)) + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + request.Content = content; + + HttpResponseMessage response; + try + { + response = await ImageUploadClient + .SendAsync(request) + .ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is HttpRequestException + || ex is TaskCanceledException) + { + throw new TicketApiException(0, "图片直传网络异常,请稍后重试。"); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + var statusCode = (int)response.StatusCode; + string body = null; + try + { + body = await response.Content + .ReadAsStringAsync() + .ConfigureAwait(false); + } + catch + { + // 存储端错误正文仅用于提取可读提示,失败时使用通用文案。 + } + throw new TicketApiException( + statusCode, + ExtractUploadErrorMessage(body, statusCode)); + } + + var etag = response.Headers.TryGetValues("ETag", out var values) + ? values.FirstOrDefault() + : null; + return new MessageAttachmentReceipt + { + RequestId = upload.RequestId, + Ticket = upload.Ticket, + Etag = string.IsNullOrWhiteSpace(etag) ? null : etag + }; + } + } + } + public async Task MarkReadAsync( TicketClientOptions options, string appId, @@ -342,25 +499,52 @@ public sealed class TicketApiService return new TicketApiException( ExtractStatusCode(responseBody), message, - responseBody); + responseBody, + ExtractErrorCode(responseBody)); + } + + private static ApiErrorResponse ExtractErrorResponse(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return null; + try + { + return JsonConvert.DeserializeObject(responseBody, JsonSettings); + } + catch (JsonException) + { + return null; + } + } + + private static string ExtractErrorCode(string responseBody) + { + return ExtractErrorResponse(responseBody)?.Code; } private static int ExtractStatusCode(string responseBody) { - if (string.IsNullOrWhiteSpace(responseBody)) + var error = ExtractErrorResponse(responseBody); + if (error == null || string.IsNullOrWhiteSpace(error.Code)) return 0; + if (int.TryParse(error.Code, out var code)) + return code; - try + switch (error.Code.Trim().ToLowerInvariant()) { - var error = JsonConvert.DeserializeObject(responseBody, JsonSettings); - if (error != null && int.TryParse(error.Code, out var code)) - return code; + case "client_api_image_expired": + return 410; + case "client_api_image_already_used": + return 409; + case "client_api_image_invalid": + return 422; + case "client_api_image_upload_failed": + return 400; + case "client_api_images_unavailable": + return 503; + default: + return 0; } - catch (JsonException) - { - // GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。 - } - return 0; } private static List NormalizeAppIds(IEnumerable appIds) @@ -389,14 +573,152 @@ public sealed class TicketApiService } } - private static void ValidateMessageText(string text) + + private static void ValidateMessageContent( + string text, + IList attachments) { - if (string.IsNullOrWhiteSpace(text) - || text.Length < 1 - || text.Length > 4000) + var attachmentCount = attachments?.Count ?? 0; + if (attachmentCount > MaxImageCount) { - throw new ArgumentException("text 必须为 1~4000 字。", nameof(text)); + throw new ArgumentException( + "每条留言最多提交 " + MaxImageCount + " 张图片。", + nameof(attachments)); } + if (attachmentCount > 0) + ValidateAttachmentReceipts(attachments); + + if (!string.IsNullOrWhiteSpace(text)) + { + if (text.Length > 4000) + throw new ArgumentException("text 不能超过 4000 字。", nameof(text)); + } + else if (attachmentCount == 0) + { + throw new ArgumentException("text 与图片附件不能同时为空。", nameof(text)); + } + } + + private static void ValidateAttachmentReceipts( + IList attachments) + { + var requestIds = new HashSet(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 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( diff --git a/OrderSupportTicket/Services/TicketService.cs b/OrderSupportTicket/Services/TicketService.cs index 504eddf..e373de4 100644 --- a/OrderSupportTicket/Services/TicketService.cs +++ b/OrderSupportTicket/Services/TicketService.cs @@ -1,4 +1,4 @@ -using OrderSupportTicket.Models; +using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Threading.Tasks; @@ -137,14 +137,50 @@ public sealed class TicketService : IDisposable TicketClientOptions options, WorkOrderSummaryDto workOrder, string text, - string clientMessageId) + string clientMessageId, + IList attachments = null) { ThrowIfDisposed(); return _api.SendMessageAsync( options, workOrder, text, - clientMessageId); + clientMessageId, + attachments); + } + + /// + /// 申请单张图片的短时直传凭证。 + /// + public Task 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); + } + + /// + /// 将图片二进制直接上传到 COS,返回留言接口需要的回执。 + /// + public Task UploadImageAsync( + ImageUploadTicketDto upload, + string filePath) + { + ThrowIfDisposed(); + return _api.UploadImageAsync(upload, filePath); } /// diff --git a/OrderSupportTicket/ViewModels/ChatAttachmentViewModel.cs b/OrderSupportTicket/ViewModels/ChatAttachmentViewModel.cs new file mode 100644 index 0000000..5d5131c --- /dev/null +++ b/OrderSupportTicket/ViewModels/ChatAttachmentViewModel.cs @@ -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; + +/// +/// 留言气泡中的公开图片附件。 +/// +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; + } + + /// + /// 附件唯一 ID。 + /// + public string Id { get; } + + /// + /// 原始文件名。 + /// + public string OriginalName { get; } + + /// + /// 图片 MIME 类型。 + /// + public string ContentType { get; } + + /// + /// 图片字节数。 + /// + public long ByteSize { get; } + + /// + /// 服务端生成的短时读取地址。 + /// + public string ReadUrl { get; } + + /// + /// 已下载并解码的图片;加载完成前为空。 + /// + public ImageSource Image + { + get => _image; + private set + { + if (SetValue(ref _image, value)) + RaisePropertyChanged(nameof(HasImage)); + } + } + + /// + /// 是否已成功显示图片。 + /// + public bool HasImage => Image != null; + + /// + /// 图片是否已经确认加载失败。加载完成前保持为 false。 + /// + public bool IsLoadFailed + { + get => _isLoadFailed; + private set + { + if (SetValue(ref _isLoadFailed, value)) + RaisePropertyChanged(nameof(ToolTipText)); + } + } + + /// + /// 附件名称、大小与加载状态提示。 + /// + 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 + ")"; + } + } + + /// + /// 下载并解码短时读取地址。失败时保留占位提示,不阻断文字留言展示。 + /// + 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"; + } +} diff --git a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs index fb93383..4458e16 100644 --- a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs +++ b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs @@ -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(); 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); } /// @@ -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)); + } } + /// + /// 是否有可展示的文字内容。 + /// + public bool HasText => !string.IsNullOrWhiteSpace(Content); + /// /// 消息是否已撤回。 /// @@ -106,6 +121,16 @@ public sealed class ChatMessageViewModel : BindableBaseEx } } + /// + /// 消息中的公开图片附件。 + /// + public ObservableCollection Attachments { get; } + + /// + /// 是否包含图片附件。 + /// + public bool HasAttachments => Attachments.Count > 0; + /// /// 当前消息是否由客户发送。 /// @@ -120,6 +145,50 @@ public sealed class ChatMessageViewModel : BindableBaseEx ? string.Empty : CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture); + /// + /// 使用服务端最新数据更新消息。短时图片地址会随每次查询变化,因此需要刷新附件。 + /// + 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 attachments) + { + Attachments.Clear(); + foreach (var dto in attachments ?? Enumerable.Empty()) + { + 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( diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs index 0953b06..7046e34 100644 --- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs +++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs @@ -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 _selectTicketCommand; private readonly AsyncDelegateCommand _createTicketCommand; private readonly AsyncDelegateCommand _sendMessageCommand; + private readonly AsyncDelegateCommand _removePendingAttachmentCommand; private readonly AsyncDelegateCommand _loadEarlierMessagesCommand; private readonly AsyncDelegateCommand _retryRealtimeCommand; + private readonly DelegateCommand _addImagesCommand; private readonly HashSet _seenEventIds = new HashSet(StringComparer.Ordinal); private readonly Dictionary _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(); Messages = new ObservableCollection(); + PendingAttachments = new ObservableCollection(); TicketView = CollectionViewSource.GetDefaultView(Tickets); TicketView.Filter = FilterTicket; _selectTicketCommand = new AsyncDelegateCommand( @@ -99,12 +107,19 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, _sendMessageCommand = new AsyncDelegateCommand( SendMessageAsync, CanSendMessage); + _removePendingAttachmentCommand = + new AsyncDelegateCommand( + RemovePendingAttachmentAsync, + attachment => attachment != null && !IsBusy); _loadEarlierMessagesCommand = new AsyncDelegateCommand( LoadEarlierMessagesAsync, () => !IsBusy && SelectedTicket != null && HasMoreMessages); _retryRealtimeCommand = new AsyncDelegateCommand( RestartRealtimeAsync, - () => !IsBusy && _configured); + () => _configured); + _addImagesCommand = new DelegateCommand( + AddImages, + _ => CanAddImage); _ticketService.NotificationReceived += OnNotificationReceived; _ticketService.StateChanged += OnRealtimeStateChanged; SseState = _ticketService.RealtimeState; @@ -144,6 +159,40 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, /// public ICommand SendMessageCommand => _sendMessageCommand; + /// + /// 移除待发送图片的命令。 + /// + public ICommand RemovePendingAttachmentCommand => _removePendingAttachmentCommand; + + /// + /// 当前留言待发送的本地图片,最多 3 张。 + /// + public ObservableCollection PendingAttachments { get; } + + /// + /// 是否已选择待发送图片。 + /// + public bool HasPendingAttachments => PendingAttachments.Count > 0; + + /// + /// 是否还可以继续选择图片。 + /// + public bool CanAddImage => + !IsBusy + && SelectedTicket != null + && PendingAttachments.Count < TicketApiService.MaxImageCount; + + /// + /// 选择本地图片并加入当前留言的待发送列表。 + /// + public ICommand AddImagesCommand => _addImagesCommand; + + /// + /// 待发送图片数量展示文本。 + /// + public string AttachmentCountText => + PendingAttachments.Count + " / " + TicketApiService.MaxImageCount; + /// /// 加载当前工单更早留言的命令。 /// @@ -154,10 +203,23 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, /// public ICommand RetryRealtimeCommand => _retryRealtimeCommand; - /// - /// 当前正在查看的历史工单。 - /// - public WorkOrderSummaryViewModel SelectedTicket + /// + /// 显示图片 + /// + public ICommand ShowImageWindowCommand => new DelegateCommand(ShowImageWindow); + /// + /// 显示图片 + /// + /// + private void ShowImageWindow(ChatAttachmentViewModel chatAttachment) + { + ImageViewerWindow.OpenDialog(chatAttachment.Image); + } + + /// + /// 当前正在查看的历史工单。 + /// + 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, } } + /// + /// 历史工单状态页签索引:0 全部、1 待客服回复、2 客服已回复、3 已完结。 + /// + public int SelectedTicketStatusIndex + { + get => _selectedTicketStatusIndex; + set + { + if (SetValue(ref _selectedTicketStatusIndex, value)) + TicketView?.Refresh(); + } + } + /// /// 当前是否处于新建工单草稿状态。 /// @@ -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(); } + /// + /// 把文件选择器返回的本地图片加入待发送列表。 + /// 每次留言最多 3 张,单张不超过 5 MiB。 + /// + public void AddPendingImages(IEnumerable filePaths) + { + if (filePaths == null || IsBusy || IsDraft || SelectedTicket == null) + return; + + var errors = new List(); + 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(); + } + } + + /// + /// 将剪贴板中的位图加入待发送列表。 + /// + 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(); + 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 diff --git a/OrderSupportTicket/ViewModels/PendingAttachmentViewModel.cs b/OrderSupportTicket/ViewModels/PendingAttachmentViewModel.cs new file mode 100644 index 0000000..110673f --- /dev/null +++ b/OrderSupportTicket/ViewModels/PendingAttachmentViewModel.cs @@ -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; + +/// +/// 发送留言前已选择、待上传的本地图片。 +/// +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); + } + + /// + /// 本地绝对路径,仅用于读取并直传,不提交给业务接口。 + /// + public string FilePath { get; } + + /// + /// 原始文件名。 + /// + public string FileName { get; } + + /// + /// 图片 MIME 类型。 + /// + public string MimeType { get; } + + /// + /// 图片字节数。 + /// + public long ByteSize { get; } + + /// + /// 本地文件内容标识,用于判断是否需要重新上传。 + /// + public string Fingerprint { get; } + + /// + /// 本地缩略图;WebP 等当前 WPF 解码器不支持时为空。 + /// + public ImageSource Preview + { + get => _preview; + private set + { + if (SetValue(ref _preview, value)) + RaisePropertyChanged(nameof(HasPreview)); + } + } + + /// + /// 是否已加载本地缩略图。 + /// + public bool HasPreview => Preview != null; + + /// + /// 当前文件是否已有可复用的上传回执。 + /// + public bool HasReceipt => + Receipt != null + && string.Equals(_receiptFingerprint, Fingerprint, StringComparison.Ordinal); + + /// + /// 上传回执;仅在本类内部更新。 + /// + public MessageAttachmentReceipt Receipt + { + get => _receipt; + private set + { + if (SetValue(ref _receipt, value)) + RaisePropertyChanged(nameof(HasReceipt)); + } + } + + /// + /// 图片大小展示文本。 + /// + public string SizeText => FormatSize(ByteSize); + + /// + /// 保存本次上传成功的回执,后续网络重试可直接复用。 + /// + public void SetReceipt(MessageAttachmentReceipt receipt) + { + if (receipt == null) + throw new ArgumentNullException(nameof(receipt)); + _receiptFingerprint = Fingerprint; + Receipt = receipt; + } + + /// + /// 清除已失效的上传回执,下次发送时重新申请凭证。 + /// + public void ClearReceipt() + { + _receiptFingerprint = null; + Receipt = null; + } + + /// + /// 校验并创建本地待发送图片。 + /// + 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; + } + + /// + /// 将剪贴板位图保存为临时 PNG,并创建可上传的附件。 + /// + 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); + } + + /// + /// 根据扩展名映射文档要求的精确 MIME 类型。 + /// + 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"; + } +} diff --git a/OrderSupportTicket/Views/OrderSupportTicketStyles.xaml b/OrderSupportTicket/Views/OrderSupportTicketStyles.xaml new file mode 100644 index 0000000..ce19e8b --- /dev/null +++ b/OrderSupportTicket/Views/OrderSupportTicketStyles.xaml @@ -0,0 +1,290 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml b/OrderSupportTicket/Views/OrderSupportTicketView.xaml index 369c08e..e11c1b6 100644 --- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml @@ -1,1009 +1,80 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OrderSupportTicket/Views/TicketConversationView.xaml.cs b/OrderSupportTicket/Views/TicketConversationView.xaml.cs new file mode 100644 index 0000000..6d34540 --- /dev/null +++ b/OrderSupportTicket/Views/TicketConversationView.xaml.cs @@ -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) ? "订单号已复制" : "复制失败"); + } +} diff --git a/OrderSupportTicket/Views/TicketSidebarView.xaml b/OrderSupportTicket/Views/TicketSidebarView.xaml new file mode 100644 index 0000000..8c5d051 --- /dev/null +++ b/OrderSupportTicket/Views/TicketSidebarView.xaml @@ -0,0 +1,289 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OrderSupportTicket/Views/TicketSidebarView.xaml.cs b/OrderSupportTicket/Views/TicketSidebarView.xaml.cs new file mode 100644 index 0000000..6871977 --- /dev/null +++ b/OrderSupportTicket/Views/TicketSidebarView.xaml.cs @@ -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) ? "订单号已复制" : "复制失败"); + } +} diff --git a/WxOrder.Framework/Controls/ImageViewerWindow.xaml b/WxOrder.Framework/Controls/ImageViewerWindow.xaml new file mode 100644 index 0000000..ebc0437 --- /dev/null +++ b/WxOrder.Framework/Controls/ImageViewerWindow.xaml @@ -0,0 +1,79 @@ + + + + + + + + + +