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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ x:Class="OrderSupportTicket.Views.OrderSupportTicketView"
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:fk="WxOrder.Framework"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:views="clr-namespace:OrderSupportTicket.Views"
+ d:Height="750"
+ d:Width="1000"
+ fk:DialogWindowProperty.Height="750"
+ fk:DialogWindowProperty.ResizeMode="CanResizeWithGrip"
+ fk:DialogWindowProperty.Width="1000"
+ fk:DialogWindowProperty.WindowStartupLocation="CenterScreen"
+ Background="#EEF1F4"
+ FontSize="14"
+ mc:Ignorable="d">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
index 123b8d4..9255a8f 100644
--- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
+++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs
@@ -1,234 +1,105 @@
-using _29WxOrderWpf.ToolCtrls;
using OrderSupportTicket.Models;
using OrderSupportTicket.Services;
using OrderSupportTicket.ViewModels;
-using System.Runtime.InteropServices;
+using System;
using System.Windows;
using System.Windows.Controls;
-using System.Windows.Input;
namespace OrderSupportTicket.Views;
///
-/// 工单对话页面。页面生命周期只负责激活/停用 ViewModel,业务交互均在 ViewModel 中。
+/// 工单页面外壳,只负责组合子视图以及管理页面生命周期。
///
public partial class OrderSupportTicketView : UserControl
{
- private bool _active;
- private Window _hostWindow;
+ private bool _active;
+ private Window _hostWindow;
- public OrderSupportTicketView()
- : this(new TicketService(), CreateDefaultOptions())
- {
- }
+ public OrderSupportTicketView() : this(new TicketService(), CreateDefaultOptions()) { }
- ///
- /// 使用工单业务服务创建工单页。
- ///
- public OrderSupportTicketView(
- TicketService ticketService,
- TicketClientOptions options = null)
- {
- InitializeComponent();
- ViewModel = new OrderSupportTicketViewModel(
- ticketService,
- options ?? CreateDefaultOptions());
- DataContext = ViewModel;
- Loaded += OnLoaded;
- Unloaded += OnUnloaded;
- }
-
- ///
- /// 当前联调使用的默认参数。正式接入时可由宿主替换为登录账号和店铺 APPID。
- ///
- public static TicketClientOptions CreateDefaultOptions(string fallbackUserName = null)
- {
- var configured = TicketClientOptions.FromApplicationSettings();
- var userName = !string.IsNullOrWhiteSpace(configured.UserName)
- ? configured.UserName
- : (!string.IsNullOrWhiteSpace(fallbackUserName)
- ? fallbackUserName.Trim()
- : "18990991234");
- var appIds = configured.AppIds != null && configured.AppIds.Count > 0
- ? configured.AppIds
- : new List
- {
- "wx316b1291351558aa",
- "wx7999743c3d64c05c"
- };
-
- return new TicketClientOptions
- {
- UserName = userName,
- AppIds = appIds,
- Orders =
- [
- new TicketOrderOption
- {
- Amount = 109m,
- AppId = "wx316b1291351558aa",
- GoodsName = "梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤",
- OrderId = "9000000000000000001",
- OrderedAt = new DateTime(2020, 1, 1),
- },
- new TicketOrderOption
- {
- Amount = 109m,
- AppId = "wx7999743c3d64c05c",
- GoodsName = "洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸",
- OrderId = "9000000000000000002",
- OrderedAt = new DateTime(2020, 1, 1),
- }
- ]
- };
- }
-
- ///
- /// 当前页面对应的工单 ViewModel。
- ///
- public OrderSupportTicketViewModel ViewModel { get; }
-
- ///
- /// 宿主可在页面加载前注入账号、APPID 与本地订单数据。
- ///
- public void Configure(TicketClientOptions options)
- {
- ViewModel.Configure(options);
- }
-
- ///
- /// 从订单页直接打开新工单草稿。
- ///
- public void OpenNewTicket(TicketOrderOption order)
- {
- ViewModel.OpenNewTicket(order);
- }
-
- ///
- /// 打开并定位到指定工单。
- ///
- public void OpenTicket(string appId, string wechatOrderId)
- {
- ViewModel.OpenTicket(appId, wechatOrderId);
- }
-
- private void TicketSearchBox_GotFocus(object sender, RoutedEventArgs e)
- {
- ViewModel.CancelNewTicketDraft();
- }
-
- ///
- /// 点击订单号复制到剪贴板,并使用宿主窗口的 Toast 提示结果。
- ///
- private void CopyOrderId_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
- {
- if (sender is not FrameworkElement element)
- return;
-
- var orderId = element.Tag as string;
- if (string.IsNullOrWhiteSpace(orderId)
- && element.DataContext is WorkOrderSummaryViewModel ticket)
- {
- orderId = ticket.WechatOrderId;
- }
- if (string.IsNullOrWhiteSpace(orderId))
- return;
-
- var window = Window.GetWindow(this) ?? _hostWindow;
- if (window == null)
- return;
-
- if (ClipboardHelper.TrySetText(orderId))
- {
- ToastHelper.Show(window, "订单号已复制");
- }
- else
- {
- ToastHelper.Show(window, "复制失败");
- }
+ public OrderSupportTicketView(TicketService ticketService, TicketClientOptions options = null)
+ {
+ InitializeComponent();
+ ViewModel = new OrderSupportTicketViewModel(ticketService, options ?? CreateDefaultOptions());
+ DataContext = ViewModel;
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
}
- private async void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
- {
- if (_active)
- return;
- _active = true;
- AttachHostWindow(Window.GetWindow(this));
- UpdateWindowActiveState();
- await ViewModel.InitializeAsync();
- }
+ public static TicketClientOptions CreateDefaultOptions(string fallbackUserName = null)
+ {
+ var configured = TicketClientOptions.FromApplicationSettings();
+ var userName = !string.IsNullOrWhiteSpace(configured.UserName)
+ ? configured.UserName
+ : (!string.IsNullOrWhiteSpace(fallbackUserName) ? fallbackUserName.Trim() : "18990991234");
+ var appIds = configured.AppIds != null && configured.AppIds.Count > 0
+ ? configured.AppIds
+ : new List { "wx316b1291351558aa", "wx7999743c3d64c05c" };
- private async void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
- {
- if (!_active)
- return;
- _active = false;
- ViewModel.IsWindowActive = false;
- DetachHostWindow();
- await ViewModel.DeactivateAsync();
- }
+ return new TicketClientOptions
+ {
+ UserName = userName,
+ AppIds = appIds,
+ Orders =
+ [
+ new TicketOrderOption { Amount = 109m, AppId = "wx316b1291351558aa", GoodsName = "梵客隆收腹内裤女新款高腰无痕磨毛暖融收小肚子提臀塑身平角裤", OrderId = "9000000000000000001", OrderedAt = new DateTime(2020, 1, 1) },
+ new TicketOrderOption { Amount = 109m, AppId = "wx7999743c3d64c05c", GoodsName = "洁柔抽纸粉Face家用实惠装批发纸巾大包面纸餐巾纸抽整箱可湿水纸", OrderId = "9000000000000000002", OrderedAt = new DateTime(2020, 1, 1) }
+ ]
+ };
+ }
- private void AttachHostWindow(Window window)
- {
- DetachHostWindow();
- _hostWindow = window;
- if (_hostWindow == null)
- return;
+ public OrderSupportTicketViewModel ViewModel { get; }
+ public void Configure(TicketClientOptions options) => ViewModel.Configure(options);
+ public void OpenNewTicket(TicketOrderOption order) => ViewModel.OpenNewTicket(order);
+ public void OpenTicket(string appId, string wechatOrderId) => ViewModel.OpenTicket(appId, wechatOrderId);
- _hostWindow.Activated += OnHostWindowActivated;
- _hostWindow.Deactivated += OnHostWindowDeactivated;
- _hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
- _hostWindow.StateChanged += OnHostWindowStateChanged;
- _hostWindow.Closed += OnHostWindowClosed;
- }
+ private async void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (_active) return;
+ _active = true;
+ AttachHostWindow(Window.GetWindow(this));
+ UpdateWindowActiveState();
+ await ViewModel.InitializeAsync();
+ }
- private void DetachHostWindow()
- {
- if (_hostWindow == null)
- return;
+ private async void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (!_active) return;
+ _active = false;
+ ViewModel.IsWindowActive = false;
+ DetachHostWindow();
+ await ViewModel.DeactivateAsync();
+ }
- _hostWindow.Activated -= OnHostWindowActivated;
- _hostWindow.Deactivated -= OnHostWindowDeactivated;
- _hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
- _hostWindow.StateChanged -= OnHostWindowStateChanged;
- _hostWindow.Closed -= OnHostWindowClosed;
- _hostWindow = null;
- }
+ private void AttachHostWindow(Window window)
+ {
+ DetachHostWindow();
+ _hostWindow = window;
+ if (_hostWindow == null) return;
+ _hostWindow.Activated += OnHostWindowStateChanged;
+ _hostWindow.Deactivated += OnHostWindowStateChanged;
+ _hostWindow.IsVisibleChanged += OnHostWindowIsVisibleChanged;
+ _hostWindow.StateChanged += OnHostWindowStateChanged;
+ _hostWindow.Closed += OnHostWindowClosed;
+ }
- private void UpdateWindowActiveState()
- {
- var window = _hostWindow;
- ViewModel.IsWindowActive = window != null
- && window.IsVisible
- && window.IsActive
- && window.WindowState != WindowState.Minimized;
- }
+ private void DetachHostWindow()
+ {
+ if (_hostWindow == null) return;
+ _hostWindow.Activated -= OnHostWindowStateChanged;
+ _hostWindow.Deactivated -= OnHostWindowStateChanged;
+ _hostWindow.IsVisibleChanged -= OnHostWindowIsVisibleChanged;
+ _hostWindow.StateChanged -= OnHostWindowStateChanged;
+ _hostWindow.Closed -= OnHostWindowClosed;
+ _hostWindow = null;
+ }
- private void OnHostWindowActivated(object sender, EventArgs e)
- {
- UpdateWindowActiveState();
- }
+ private void UpdateWindowActiveState()
+ {
+ ViewModel.IsWindowActive = _hostWindow != null && _hostWindow.IsVisible && _hostWindow.IsActive && _hostWindow.WindowState != WindowState.Minimized;
+ }
- private void OnHostWindowDeactivated(object sender, EventArgs e)
- {
- UpdateWindowActiveState();
- }
-
- private void OnHostWindowIsVisibleChanged(
- object sender,
- DependencyPropertyChangedEventArgs e)
- {
- UpdateWindowActiveState();
- }
-
- private void OnHostWindowStateChanged(object sender, EventArgs e)
- {
- UpdateWindowActiveState();
- }
-
- private void OnHostWindowClosed(object sender, EventArgs e)
- {
- ViewModel.IsWindowActive = false;
- DetachHostWindow();
- }
+ private void OnHostWindowStateChanged(object sender, EventArgs e) => UpdateWindowActiveState();
+ private void OnHostWindowIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) => UpdateWindowActiveState();
+ private void OnHostWindowClosed(object sender, EventArgs e) { ViewModel.IsWindowActive = false; DetachHostWindow(); }
}
diff --git a/OrderSupportTicket/Views/TicketComposerView.xaml b/OrderSupportTicket/Views/TicketComposerView.xaml
new file mode 100644
index 0000000..604bdbf
--- /dev/null
+++ b/OrderSupportTicket/Views/TicketComposerView.xaml
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OrderSupportTicket/Views/TicketComposerView.xaml.cs b/OrderSupportTicket/Views/TicketComposerView.xaml.cs
new file mode 100644
index 0000000..645a2e6
--- /dev/null
+++ b/OrderSupportTicket/Views/TicketComposerView.xaml.cs
@@ -0,0 +1,29 @@
+using OrderSupportTicket.ViewModels;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media.Imaging;
+
+namespace OrderSupportTicket.Views;
+
+public partial class TicketComposerView : UserControl
+{
+ public TicketComposerView() => InitializeComponent();
+
+ private void ChatTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ var isPaste = (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control) || (e.Key == Key.Insert && Keyboard.Modifiers == ModifierKeys.Shift);
+ if (!isPaste || DataContext is not OrderSupportTicketViewModel viewModel) return;
+ IDataObject data;
+ try { data = Clipboard.GetDataObject(); } catch (ExternalException) { return; }
+ if (data == null) return;
+ if (data.GetDataPresent(DataFormats.Bitmap) && data.GetData(DataFormats.Bitmap) is BitmapSource bitmap) { viewModel.AddPendingImage(bitmap); e.Handled = true; return; }
+ if (!data.GetDataPresent(DataFormats.FileDrop) || data.GetData(DataFormats.FileDrop) is not string[] files) return;
+ var images = files.Where(path => PendingAttachmentViewModel.TryGetMimeType(System.IO.Path.GetExtension(path), out _)).ToArray();
+ if (images.Length == 0) return;
+ viewModel.AddPendingImages(images);
+ e.Handled = true;
+ }
+}
diff --git a/OrderSupportTicket/Views/TicketConversationView.xaml b/OrderSupportTicket/Views/TicketConversationView.xaml
new file mode 100644
index 0000000..989ecf0
--- /dev/null
+++ b/OrderSupportTicket/Views/TicketConversationView.xaml
@@ -0,0 +1,272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/WxOrder.Framework/Controls/ImageViewerWindow.xaml.cs b/WxOrder.Framework/Controls/ImageViewerWindow.xaml.cs
new file mode 100644
index 0000000..ca43c93
--- /dev/null
+++ b/WxOrder.Framework/Controls/ImageViewerWindow.xaml.cs
@@ -0,0 +1,452 @@
+using System;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace WxOrder.Framework.Controls;
+
+///
+/// 图片查看弹窗,直接静态调用:
+/// ImageViewerWindow.Open(@"C:\a.jpg");
+/// ImageViewerWindow.OpenDialog("https://example.com/a.png", "预览");
+/// source 支持 string(路径/URL) / Uri / ImageSource / byte[] / Stream
+///
+public partial class ImageViewerWindow : Window
+{
+ private readonly object _source;
+
+ private ImageViewerWindow(object source, string title)
+ {
+ InitializeComponent();
+
+ // 保证 Img 位于 Canvas 中:
+ // 如果 Img 直接放在 Grid 里,图片比窗口大时会被 WPF 按窗口大小做布局裁剪,
+ // 缩放后只剩左上角一块,表现为“图片没居中”。
+ EnsureImageInCanvas();
+
+ var group = new TransformGroup();
+ group.Children.Add(_scaleT);
+ group.Children.Add(_translateT);
+ Img.RenderTransform = group;
+ Img.RenderTransformOrigin = new Point(0, 0);
+
+ _source = source;
+ Title = title ?? GuessTitle(source);
+
+ // 默认大小:屏幕工作区的 70%
+ Width = Math.Max(MinWidth, SystemParameters.WorkArea.Width * 0.7);
+ Height = Math.Max(MinHeight, SystemParameters.WorkArea.Height * 0.7);
+
+ ImageFailed += (s, ex) =>
+ {
+ MessageBox.Show(this, "图片加载失败:" + ex.Message, "提示",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ Close();
+ };
+
+ // 放到 Loaded 里设置,这样加载失败时可以安全地关闭窗口
+ Loaded += (s, e) => Source = _source;
+ }
+
+ /// 如果 Img 的父级不是 Canvas,就在原位置包一层 Canvas。
+ private void EnsureImageInCanvas()
+ {
+ if (Img.Parent is Canvas) return;
+ if (Img.Parent is not Panel parent) return;
+
+ int index = parent.Children.IndexOf(Img);
+ parent.Children.RemoveAt(index);
+
+ Img.Stretch = Stretch.None;
+ Img.HorizontalAlignment = HorizontalAlignment.Left;
+ Img.VerticalAlignment = VerticalAlignment.Top;
+ Canvas.SetLeft(Img, 0);
+ Canvas.SetTop(Img, 0);
+
+ var canvas = new Canvas { IsHitTestVisible = false };
+ canvas.Children.Add(Img);
+ parent.Children.Insert(index, canvas);
+ }
+
+ #region 静态调用入口
+
+ /// 非模态弹出(不阻塞调用方)
+ public static void Open(object source, string title = null, Window owner = null)
+ {
+ RunOnUi(() => Create(source, title, owner).Show());
+ }
+
+ /// 模态弹出(关闭前阻塞调用方)
+ public static void OpenDialog(object source, string title = null, Window owner = null)
+ {
+ RunOnUi(() => Create(source, title, owner).ShowDialog());
+ }
+
+ #endregion
+
+ private static ImageViewerWindow Create(object source, string title, Window owner)
+ {
+ var win = new ImageViewerWindow(source, title);
+
+ owner = owner ?? Application.Current?.MainWindow;
+ if (owner != null && owner.IsLoaded && owner != win)
+ {
+ win.Owner = owner;
+ win.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ }
+ else
+ {
+ win.WindowStartupLocation = WindowStartupLocation.CenterScreen;
+ }
+ return win;
+ }
+
+ private static void RunOnUi(Action action)
+ {
+ var dispatcher = Application.Current?.Dispatcher
+ ?? throw new InvalidOperationException("没有可用的 WPF Application。");
+ if (dispatcher.CheckAccess()) action();
+ else dispatcher.Invoke(action); // 支持从后台线程调用
+ }
+
+ private static string GuessTitle(object source)
+ {
+ try
+ {
+ if (source is string s && !string.IsNullOrWhiteSpace(s))
+ {
+ if (Uri.TryCreate(s, UriKind.Absolute, out var u))
+ return Path.GetFileName(u.IsFile ? u.LocalPath : u.AbsolutePath);
+ return Path.GetFileName(s);
+ }
+ if (source is Uri uri)
+ return Path.GetFileName(uri.IsFile ? uri.LocalPath : uri.AbsolutePath);
+ }
+ catch { /* 忽略,使用默认标题 */ }
+ return "图片查看";
+ }
+
+ private void Window_KeyDown(object sender, KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Escape: Close(); break;
+ case Key.Add:
+ case Key.OemPlus: ZoomIn(); break;
+ case Key.Subtract:
+ case Key.OemMinus: ZoomOut(); break;
+ case Key.D0:
+ case Key.NumPad0: FitToView(); break;
+ case Key.D1:
+ case Key.NumPad1: ActualSize(); break;
+ }
+ }
+
+ #region 依赖属性
+
+ /// 图片来源:string(路径或URL) / Uri / ImageSource / byte[] / Stream
+ public static readonly DependencyProperty SourceProperty =
+ DependencyProperty.Register(nameof(Source), typeof(object), typeof(ImageViewerWindow),
+ new PropertyMetadata(null, (d, e) => ((ImageViewerWindow)d).LoadSource(e.NewValue)));
+
+ public static readonly DependencyProperty ShowToolbarProperty =
+ DependencyProperty.Register(nameof(ShowToolbar), typeof(bool), typeof(ImageViewerWindow),
+ new PropertyMetadata(true, (d, e) =>
+ ((ImageViewerWindow)d).ToolBar.Visibility = (bool)e.NewValue ? Visibility.Visible : Visibility.Collapsed));
+
+ public object Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public bool ShowToolbar
+ {
+ get => (bool)GetValue(ShowToolbarProperty);
+ set => SetValue(ShowToolbarProperty, value);
+ }
+
+ #endregion
+
+ #region 公共属性 / 事件
+
+ public double MinZoom { get; set; } = 0.05;
+ public double MaxZoom { get; set; } = 32;
+
+ /// 当前缩放比例(1 = 100%)
+ public double Zoom
+ {
+ get => _scale;
+ set => ZoomAt(ViewCenter, value);
+ }
+
+ public event EventHandler ZoomChanged;
+ public event EventHandler ImageFailed;
+
+ #endregion
+
+ private readonly ScaleTransform _scaleT = new ScaleTransform();
+ private readonly TranslateTransform _translateT = new TranslateTransform();
+
+ private ImageSource _image;
+ private BitmapSource _pending; // 正在下载的网络图片
+ private double _scale = 1, _tx, _ty;
+ private bool _fit = true; // 是否处于"适应窗口"状态(窗口大小变化时自动重新适应)
+ private bool _dragging;
+ private Point _lastPos;
+
+ private Point ViewCenter => new Point(Root.ActualWidth / 2, Root.ActualHeight / 2);
+
+ #region 加载图片
+
+ private void LoadSource(object src)
+ {
+ DetachPending();
+ try
+ {
+ var img = CreateImage(src);
+ if (img is BitmapSource bs && bs.IsDownloading)
+ {
+ _pending = bs;
+ bs.DownloadCompleted += OnDownloadCompleted;
+ bs.DownloadFailed += OnDownloadFailed;
+ SetImage(null);
+ return;
+ }
+ SetImage(img);
+ }
+ catch (Exception ex)
+ {
+ SetImage(null);
+ ImageFailed?.Invoke(this, ex);
+ }
+ }
+
+ private void DetachPending()
+ {
+ if (_pending == null) return;
+ _pending.DownloadCompleted -= OnDownloadCompleted;
+ _pending.DownloadFailed -= OnDownloadFailed;
+ _pending = null;
+ }
+
+ private void OnDownloadCompleted(object sender, EventArgs e)
+ {
+ var bs = (BitmapSource)sender;
+ if (bs != _pending) return;
+ DetachPending();
+ if (bs.CanFreeze) bs.Freeze();
+ SetImage(bs);
+ }
+
+ private void OnDownloadFailed(object sender, ExceptionEventArgs e)
+ {
+ DetachPending();
+ SetImage(null);
+ ImageFailed?.Invoke(this, e.ErrorException);
+ }
+
+ private static ImageSource CreateImage(object src)
+ {
+ switch (src)
+ {
+ case null: return null;
+ case ImageSource s: return s;
+ case Uri u: return FromUri(u);
+ case string str:
+ var uri = ToUri(str);
+ return uri == null ? null : FromUri(uri);
+ case byte[] bytes:
+ using (var ms = new MemoryStream(bytes)) return FromStream(ms);
+ case Stream stream: return FromStream(stream);
+ default:
+ throw new NotSupportedException("不支持的图片来源类型:" + src.GetType().FullName);
+ }
+ }
+
+ private static Uri ToUri(string s)
+ {
+ if (string.IsNullOrWhiteSpace(s)) return null;
+ if (Uri.TryCreate(s, UriKind.Absolute, out var u)) return u; // C:\a.png / http://... / pack://...
+ return new Uri(Path.GetFullPath(s)); // 相对路径
+ }
+
+ private static BitmapImage FromUri(Uri uri)
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = uri;
+ bmp.CacheOption = BitmapCacheOption.OnLoad; // 加载后不占用文件
+ bmp.CreateOptions = BitmapCreateOptions.IgnoreImageCache; // 文件被替换后能刷新
+ bmp.EndInit();
+ if (!bmp.IsDownloading && bmp.CanFreeze) bmp.Freeze();
+ return bmp;
+ }
+
+ private static BitmapImage FromStream(Stream stream)
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.StreamSource = stream;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+ bmp.Freeze();
+ return bmp;
+ }
+
+ private void SetImage(ImageSource img)
+ {
+ _image = img;
+ Img.Source = img;
+ _fit = true;
+ FitToView();
+ }
+
+ #endregion
+
+ #region 缩放 / 平移
+
+ /// 适应窗口(只缩小,不放大小图)
+ public void FitToView()
+ {
+ _fit = true;
+ if (_image == null) return;
+ double cw = Root.ActualWidth, ch = Root.ActualHeight;
+ if (cw <= 0 || ch <= 0 || _image.Width <= 0 || _image.Height <= 0) return;
+
+ CenterAtScale(Math.Min(1.0, Math.Min(cw / _image.Width, ch / _image.Height)));
+ }
+
+ /// 按原始大小显示,并将图片几何中心放到视口中心。
+ public void ActualSize()
+ {
+ CenterAtScale(1.0);
+ _fit = false;
+ }
+
+ public void ZoomIn() => ZoomAt(ViewCenter, _scale * 1.25);
+ public void ZoomOut() => ZoomAt(ViewCenter, _scale / 1.25);
+
+ /// 以 p(控件坐标)为不动点缩放到 newScale
+ private void ZoomAt(Point p, double newScale)
+ {
+ if (_image == null) return;
+ newScale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
+ double ratio = newScale / _scale;
+ _tx = p.X - (p.X - _tx) * ratio;
+ _ty = p.Y - (p.Y - _ty) * ratio;
+ _scale = newScale;
+ _fit = false;
+ ApplyTransform();
+ }
+
+ /// 按指定比例显示,并将图片几何中心与视口中心对齐。
+ private void CenterAtScale(double newScale)
+ {
+ if (_image == null) return;
+
+ _scale = Math.Max(MinZoom, Math.Min(MaxZoom, newScale));
+ _tx = (Root.ActualWidth - _image.Width * _scale) / 2;
+ _ty = (Root.ActualHeight - _image.Height * _scale) / 2;
+ ApplyTransform();
+ }
+
+ private void ApplyTransform()
+ {
+ if (_image == null)
+ {
+ ZoomText.Text = "--";
+ return;
+ }
+
+ ClampOffset();
+ _scaleT.ScaleX = _scaleT.ScaleY = _scale;
+ _translateT.X = _tx;
+ _translateT.Y = _ty;
+
+ // 缩小用高质量,放大到像素级时用最近邻(能看清像素)
+ RenderOptions.SetBitmapScalingMode(Img,
+ _scale >= 3 ? BitmapScalingMode.NearestNeighbor :
+ _scale < 1 ? BitmapScalingMode.Fant : BitmapScalingMode.Linear);
+
+ ZoomText.Text = $"{_scale * 100:0}%";
+ ZoomChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// 图片小于视口时居中;大于视口时不允许拖出空白
+ private void ClampOffset()
+ {
+ double cw = Root.ActualWidth, ch = Root.ActualHeight;
+ double w = _image.Width * _scale, h = _image.Height * _scale;
+ _tx = w <= cw ? (cw - w) / 2 : Math.Min(0, Math.Max(cw - w, _tx));
+ _ty = h <= ch ? (ch - h) / 2 : Math.Min(0, Math.Max(ch - h, _ty));
+ }
+
+ #endregion
+
+ #region 事件处理
+
+ private void Root_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ if (_image == null) return;
+ if (_fit) FitToView();
+ else ApplyTransform();
+ }
+
+ private void Root_MouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (_image == null) return;
+ double factor = Math.Pow(1.001, e.Delta); // 一格滚轮约 ×1.13,触摸板更平滑
+ ZoomAt(e.GetPosition(Root), _scale * factor);
+ e.Handled = true;
+ }
+
+ private void Root_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (_image == null) return;
+
+ if (e.ClickCount == 2) // 双击:适应窗口 <-> 100%
+ {
+ if (_fit) ZoomAt(e.GetPosition(Root), 1.0);
+ else FitToView();
+ return;
+ }
+
+ _dragging = true;
+ _lastPos = e.GetPosition(Root);
+ Root.CaptureMouse();
+ Root.Cursor = Cursors.SizeAll;
+ }
+
+ private void Root_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!_dragging) return;
+ var pos = e.GetPosition(Root);
+ _tx += pos.X - _lastPos.X;
+ _ty += pos.Y - _lastPos.Y;
+ _lastPos = pos;
+ _fit = false;
+ ApplyTransform();
+ }
+
+ private void Root_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => EndDrag();
+ private void Root_LostMouseCapture(object sender, MouseEventArgs e) => EndDrag();
+
+ private void EndDrag()
+ {
+ if (!_dragging) return;
+ _dragging = false;
+ Root.ReleaseMouseCapture();
+ Root.Cursor = null;
+ }
+
+ private void ZoomIn_Click(object sender, RoutedEventArgs e) => ZoomIn();
+ private void ZoomOut_Click(object sender, RoutedEventArgs e) => ZoomOut();
+ private void Fit_Click(object sender, RoutedEventArgs e) => FitToView();
+ private void Actual_Click(object sender, RoutedEventArgs e) => ActualSize();
+
+ #endregion
+}
\ No newline at end of file
diff --git a/WxOrder.Framework/Helpers/NLogger.cs b/WxOrder.Framework/Helpers/NLogger.cs
index 65759e2..7392aad 100644
--- a/WxOrder.Framework/Helpers/NLogger.cs
+++ b/WxOrder.Framework/Helpers/NLogger.cs
@@ -34,7 +34,7 @@ namespace WxOrder.Framework.Helpers
///
public sealed class NLogger : IDisposable, ILogger
{
- private const string DefaultName = "Default";
+ private const string DefaultName = "App";
private static readonly object _lock = new object();
private static NLogger? _instance;
diff --git a/WxOrder.Framework/WxOrder.Framework.csproj b/WxOrder.Framework/WxOrder.Framework.csproj
index b273be2..8194697 100644
--- a/WxOrder.Framework/WxOrder.Framework.csproj
+++ b/WxOrder.Framework/WxOrder.Framework.csproj
@@ -46,7 +46,6 @@
-