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(NormalizeClipboardBitmap(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; } } private static BitmapSource NormalizeClipboardBitmap(BitmapSource bitmap) { if (bitmap.Format != PixelFormats.Bgra32 && bitmap.Format != PixelFormats.Pbgra32) return bitmap; var stride = checked(bitmap.PixelWidth * 4); var pixels = new byte[checked(stride * bitmap.PixelHeight)]; bitmap.CopyPixels(pixels, stride, 0); for (var i = 3; i < pixels.Length; i += 4) { if (pixels[i] != 0) return bitmap; } // 微信等截图工具可能将 DIB 的保留字节全部置零,WPF 将其解释为 Alpha。 // 直接按 Bgr32 使用原始颜色,避免预乘 Alpha 转换时丢失 RGB;正常透明图保持原样。 var normalized = BitmapSource.Create( bitmap.PixelWidth, bitmap.PixelHeight, bitmap.DpiX, bitmap.DpiY, PixelFormats.Bgr32, null, pixels, stride); normalized.Freeze(); return normalized; } 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"; } }