Files

343 lines
9.1 KiB
C#

using OrderSupportTicket.Models.Api;
using OrderSupportTicket.Services;
using System;
using System.Globalization;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using WPFCommon.Models;
namespace OrderSupportTicket.ViewModels;
/// <summary>
/// 发送留言前已选择、待上传的本地图片。
/// </summary>
public sealed class PendingAttachmentViewModel : BindableBaseEx, IDisposable
{
private const long MaxImageBytes = TicketApiService.MaxImageBytes;
private ImageSource _preview;
private MessageAttachmentReceipt _receipt;
private string _receiptFingerprint;
private readonly bool _deleteFileOnDispose;
private bool _disposed;
private PendingAttachmentViewModel(
string filePath,
string fileName,
string mimeType,
long byteSize,
DateTime lastWriteTimeUtc,
bool deleteFileOnDispose = false)
{
FilePath = filePath;
FileName = fileName;
MimeType = mimeType;
ByteSize = byteSize;
_deleteFileOnDispose = deleteFileOnDispose;
Fingerprint = filePath.ToLowerInvariant()
+ "|"
+ byteSize.ToString(CultureInfo.InvariantCulture)
+ "|"
+ lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
Preview = LoadPreview(filePath);
}
/// <summary>
/// 本地绝对路径,仅用于读取并直传,不提交给业务接口。
/// </summary>
public string FilePath { get; }
/// <summary>
/// 原始文件名。
/// </summary>
public string FileName { get; }
/// <summary>
/// 图片 MIME 类型。
/// </summary>
public string MimeType { get; }
/// <summary>
/// 图片字节数。
/// </summary>
public long ByteSize { get; }
/// <summary>
/// 本地文件内容标识,用于判断是否需要重新上传。
/// </summary>
public string Fingerprint { get; }
/// <summary>
/// 本地缩略图;WebP 等当前 WPF 解码器不支持时为空。
/// </summary>
public ImageSource Preview
{
get => _preview;
private set
{
if (SetValue(ref _preview, value))
RaisePropertyChanged(nameof(HasPreview));
}
}
/// <summary>
/// 是否已加载本地缩略图。
/// </summary>
public bool HasPreview => Preview != null;
/// <summary>
/// 当前文件是否已有可复用的上传回执。
/// </summary>
public bool HasReceipt =>
Receipt != null
&& string.Equals(_receiptFingerprint, Fingerprint, StringComparison.Ordinal);
/// <summary>
/// 上传回执;仅在本类内部更新。
/// </summary>
public MessageAttachmentReceipt Receipt
{
get => _receipt;
private set
{
if (SetValue(ref _receipt, value))
RaisePropertyChanged(nameof(HasReceipt));
}
}
/// <summary>
/// 图片大小展示文本。
/// </summary>
public string SizeText => FormatSize(ByteSize);
/// <summary>
/// 保存本次上传成功的回执,后续网络重试可直接复用。
/// </summary>
public void SetReceipt(MessageAttachmentReceipt receipt)
{
if (receipt == null)
throw new ArgumentNullException(nameof(receipt));
_receiptFingerprint = Fingerprint;
Receipt = receipt;
}
/// <summary>
/// 清除已失效的上传回执,下次发送时重新申请凭证。
/// </summary>
public void ClearReceipt()
{
_receiptFingerprint = null;
Receipt = null;
}
/// <summary>
/// 校验并创建本地待发送图片。
/// </summary>
public static bool TryCreate(
string filePath,
out PendingAttachmentViewModel attachment,
out string error)
{
attachment = null;
error = null;
if (string.IsNullOrWhiteSpace(filePath))
{
error = "图片路径为空。";
return false;
}
string fullPath;
FileInfo file;
try
{
fullPath = Path.GetFullPath(filePath);
file = new FileInfo(fullPath);
if (!file.Exists)
{
error = "图片文件不存在:" + Path.GetFileName(filePath);
return false;
}
}
catch (Exception ex)
{
error = "无法读取图片文件:" + ex.Message;
return false;
}
if (!TryGetMimeType(file.Extension, out var mimeType))
{
error = "仅支持 JPG、PNG、WebP 图片:" + file.Name;
return false;
}
if (file.Length < 1 || file.Length > MaxImageBytes)
{
error = "单张图片必须大于 0 且不超过 5 MiB:" + file.Name;
return false;
}
attachment = new PendingAttachmentViewModel(
fullPath,
file.Name,
mimeType,
file.Length,
file.LastWriteTimeUtc);
return true;
}
/// <summary>
/// 将剪贴板位图保存为临时 PNG,并创建可上传的附件。
/// </summary>
public static bool TryCreateTemporary(
BitmapSource bitmap,
out PendingAttachmentViewModel attachment,
out string error)
{
attachment = null;
error = null;
if (bitmap == null)
{
error = "剪贴板中没有可用的图片。";
return false;
}
string filePath = null;
try
{
filePath = Path.Combine(
Path.GetTempPath(),
"WxOrder-Clipboard-" + Guid.NewGuid().ToString("N") + ".png");
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(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);
}
/// <summary>
/// 根据扩展名映射文档要求的精确 MIME 类型。
/// </summary>
public static bool TryGetMimeType(string extension, out string mimeType)
{
switch ((extension ?? string.Empty).Trim().ToLowerInvariant())
{
case ".jpg":
case ".jpeg":
mimeType = "image/jpeg";
return true;
case ".png":
mimeType = "image/png";
return true;
case ".webp":
mimeType = "image/webp";
return true;
default:
mimeType = null;
return false;
}
}
private static ImageSource LoadPreview(string filePath)
{
try
{
var image = new BitmapImage();
using (var stream = File.OpenRead(filePath))
{
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
image.DecodePixelWidth = 160;
image.StreamSource = stream;
image.EndInit();
}
image.Freeze();
return image;
}
catch
{
return null;
}
}
private static void TryDelete(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return;
try
{
if (File.Exists(filePath))
File.Delete(filePath);
}
catch
{
// 临时文件清理失败不影响留言发送流程。
}
}
private static string FormatSize(long byteSize)
{
if (byteSize >= 1024 * 1024)
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
if (byteSize >= 1024)
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
}
}