320 lines
8.3 KiB
C#
320 lines
8.3 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(bitmap));
|
|
using (var stream = File.Create(filePath))
|
|
encoder.Save(stream);
|
|
|
|
var file = new FileInfo(filePath);
|
|
if (file.Length < 1 || file.Length > MaxImageBytes)
|
|
{
|
|
error = "粘贴的图片必须大于 0 且不超过 5 MiB。";
|
|
TryDelete(filePath);
|
|
return false;
|
|
}
|
|
|
|
attachment = new PendingAttachmentViewModel(
|
|
file.FullName,
|
|
"粘贴图片.png",
|
|
"image/png",
|
|
file.Length,
|
|
file.LastWriteTimeUtc,
|
|
deleteFileOnDispose: true);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TryDelete(filePath);
|
|
error = "无法读取剪贴板图片:" + ex.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_disposed = true;
|
|
if (_deleteFileOnDispose)
|
|
TryDelete(FilePath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 根据扩展名映射文档要求的精确 MIME 类型。
|
|
/// </summary>
|
|
public static bool TryGetMimeType(string extension, out string mimeType)
|
|
{
|
|
switch ((extension ?? string.Empty).Trim().ToLowerInvariant())
|
|
{
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
mimeType = "image/jpeg";
|
|
return true;
|
|
case ".png":
|
|
mimeType = "image/png";
|
|
return true;
|
|
case ".webp":
|
|
mimeType = "image/webp";
|
|
return true;
|
|
default:
|
|
mimeType = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static ImageSource LoadPreview(string filePath)
|
|
{
|
|
try
|
|
{
|
|
var image = new BitmapImage();
|
|
using (var stream = File.OpenRead(filePath))
|
|
{
|
|
image.BeginInit();
|
|
image.CacheOption = BitmapCacheOption.OnLoad;
|
|
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
|
|
image.DecodePixelWidth = 160;
|
|
image.StreamSource = stream;
|
|
image.EndInit();
|
|
}
|
|
image.Freeze();
|
|
return image;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void TryDelete(string filePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
return;
|
|
|
|
try
|
|
{
|
|
if (File.Exists(filePath))
|
|
File.Delete(filePath);
|
|
}
|
|
catch
|
|
{
|
|
// 临时文件清理失败不影响留言发送流程。
|
|
}
|
|
}
|
|
|
|
private static string FormatSize(long byteSize)
|
|
{
|
|
if (byteSize >= 1024 * 1024)
|
|
return (byteSize / 1024d / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " MB";
|
|
if (byteSize >= 1024)
|
|
return (byteSize / 1024d).ToString("0.0", CultureInfo.CurrentCulture) + " KB";
|
|
return byteSize.ToString(CultureInfo.CurrentCulture) + " B";
|
|
}
|
|
}
|