From 4094450dcfebaa4b2d1801bee499ede6029b4986 Mon Sep 17 00:00:00 2001 From: tdx Date: Fri, 5 Sep 2025 03:39:29 +0000 Subject: [PATCH] SVN r965 SVN-Revision: r965 --- 插件库/Lskj.Control/Model/LabPicUrlPreview.cs | 506 +++++++++++++----- 1 file changed, 377 insertions(+), 129 deletions(-) diff --git a/插件库/Lskj.Control/Model/LabPicUrlPreview.cs b/插件库/Lskj.Control/Model/LabPicUrlPreview.cs index d40cb42..30d0183 100644 --- a/插件库/Lskj.Control/Model/LabPicUrlPreview.cs +++ b/插件库/Lskj.Control/Model/LabPicUrlPreview.cs @@ -1,9 +1,8 @@ // ============================================================================ // LabPicUrlPreview.cs (C# 7.3 / DevExpress 15.2 兼容) -// - 公用方法封装在一个类里 -// - 事件绑定在一个地方 Attach(view) -// - 线程安全缓存 + 并发限流 + LRU 清理 -// - 只在同步阶段写 e.Value;异步完成后刷新单元格 +// - 公用方法封装在一个类里:InitPicColumn / Attach / AttachPreview +// - 线程安全缓存:缩略图 & 原图 分离缓存;并发限流 + LRU 清理 +// - 只在同步阶段写 e.Value;异步完成后刷新单元格或更新预览窗体 // ============================================================================ using System; using System.Collections.Generic; @@ -24,9 +23,11 @@ using DevExpress.XtraGrid.Columns; using DevExpress.XtraGrid.Views.Base; using DevExpress.XtraGrid.Views.Grid; using DevExpress.XtraGrid.Views.BandedGrid; -using Lskj.Business; +using DevExpress.XtraGrid.Views.Grid.ViewInfo; +using DevExpress.XtraGrid.Views.BandedGrid.ViewInfo; using Lskj.Control.Model; -// 若命名空间不同,请改成正确的 using +using Lskj.Control; +using Lskj.Business; public static class LabPicUrlPreview { @@ -53,7 +54,7 @@ public static class LabPicUrlPreview gridColumn.ColumnEdit = pictureEdit; } - // ====== 对外:统一事件绑定入口(只需调用一次) ====== + // ====== 对外:绑定“取缩略图”的事件(只需调用一次) ====== public static void Attach(ColumnView view) { if (view == null) return; @@ -61,7 +62,15 @@ public static class LabPicUrlPreview view.CustomUnboundColumnData += OnCustomUnboundColumnData; } - // ====== 事件实现(只在同步阶段写 e.Value;异步完成后刷新该单元格) ====== + // ====== 对外:绑定“双击大图预览”的事件(只需调用一次) ====== + public static void AttachPreview(ColumnView view) + { + if (view == null) return; + view.DoubleClick -= OnViewDoubleClick; + view.DoubleClick += OnViewDoubleClick; + } + + // ===================== 缩略图事件实现 ===================== private static void OnCustomUnboundColumnData(object sender, CustomColumnDataEventArgs e) { ColumnView view = sender as ColumnView; @@ -97,7 +106,7 @@ public static class LabPicUrlPreview // 5) 缓存命中:当次赋值(只在同步阶段写 e.Value) Image img; - if (UrlImageCacheCompat.TryGet(url, targetHeight, out img)) + if (ImageCache.TryGetThumb(url, targetHeight, out img)) { e.Value = img; return; @@ -105,12 +114,105 @@ public static class LabPicUrlPreview // 6) 未命中:安排异步下载;完成后刷新该单元格(不要在异步线程里改 e.Value) System.Windows.Forms.Control invoker = view.GridControl; // UI 回调对象 - UrlImageCacheCompat.GetOrCreateAsync(url, targetHeight, invoker, delegate () + ImageCache.GetThumbAsync(url, targetHeight, invoker, delegate () { RefreshCellCompat(view, rowHandle, e.Column); }); } + // ===================== 双击预览(原图) ===================== + private static void OnViewDoubleClick(object sender, EventArgs e) + { + ColumnView view = sender as ColumnView; + if (view == null || view.GridControl == null) return; + + // 命中测试(GridView / BandedGridView 兼容) + GridColumn hitColumn = null; + int rowHandle = -1; + + Point pt = view.GridControl.PointToClient(Control.MousePosition); + + GridView gv = view as GridView; + if (gv != null) + { + GridHitInfo hi = gv.CalcHitInfo(pt); + if (hi == null || !hi.InRowCell || hi.Column == null) return; + hitColumn = hi.Column; + rowHandle = hi.RowHandle; + } + else + { + BandedGridView bgv = view as BandedGridView; + if (bgv == null) return; + BandedGridHitInfo hi2 = bgv.CalcHitInfo(pt); + if (hi2 == null || !hi2.InRowCell || hi2.Column == null) return; + hitColumn = hi2.Column; + rowHandle = hi2.RowHandle; + } + + // 只处理 LabPicUrl 列 + GridColumnModel m = hitColumn.Tag as GridColumnModel; + if (m == null || m.FieldType != ControlType.LabPicUrl) return; + + // 取当前行的 URL(优先 RowHandle 取值) + string raw = null; + try + { + object val = view.GetRowCellValue(rowHandle, m.FieldName); + raw = val == null ? null : Convert.ToString(val); + } + catch + { + // 兜底:通过 ListSourceRowIndex + int listIndex = view.GetDataSourceRowIndex(rowHandle); + try + { + object val2 = view.GetListSourceRowCellValue(listIndex, m.FieldName); + raw = val2 == null ? null : Convert.ToString(val2); + } + catch { } + } + if (string.IsNullOrWhiteSpace(raw)) return; + + string url = ResolveFullUrl(raw); + + // —— 打开你的预览窗体(先以缩略图占位) —— + Image thumb = null; + ImageCache.TryGetThumb(url, NormalizeHeight(m), out thumb); + + FrmPicture frm = new FrmPicture(); + try + { + // 这些属性基于你原有窗体接口 + frm.PicUrlColumns = view.Columns + .Where(c => c.Tag is GridColumnModel cm && cm.FieldType == ControlType.LabPicUrl) + .ToList(); + frm.FocusedRow = (view as GridView) != null ? (view as GridView).GetDataRow(rowHandle) : null; + frm.FocusedColumn = hitColumn; + frm.Text = "预览"; + frm.Picture = thumb; // 先用缩略图(可能为 null) + frm.WindowState = FormWindowState.Maximized; + frm.StartPosition = FormStartPosition.CenterScreen; + + // 异步拉原图,完成后更新窗体 + ImageCache.GetFullAsync(url, frm, delegate (Image full) + { + if (full == null) return; + try + { + if (!frm.IsDisposed) frm.Picture = full; + } + catch { } + }); + + frm.ShowDialog(); + } + finally + { + if (frm != null && !frm.IsDisposed) frm.Dispose(); + } + } + // ====== 视图安全刷新:GridView/BandedGridView 定向刷新,其他降级为 RefreshData ====== private static void RefreshCellCompat(ColumnView view, int rowHandle, GridColumn column) { @@ -175,7 +277,6 @@ public static class LabPicUrlPreview try { - // 直接使用你项目里的 SystemInfo(若没有可替换为你自己的配置) string oa = SystemInfo.Instance.OAUrl; if (string.IsNullOrEmpty(oa)) return raw.TrimStart('/'); if (!oa.EndsWith("/")) oa += "/"; @@ -187,28 +288,268 @@ public static class LabPicUrlPreview } } - // ====== 线程安全缓存(WebClient + Dictionary + Semaphore,C# 7.3 兼容) ====== - private static class UrlImageCacheCompat + // ====== 图片缓存(缩略图 & 原图)—— WebClient + Dictionary + Semaphore,C# 7.3 兼容 ====== + private static class ImageCache { - private class CacheEntry + // —— 公共接口(缩略图) —— + public static bool TryGetThumb(string url, int targetHeight, out Image img) { - public Image Image; - public DateTime LastHitUtc; + return ThumbCache.TryGet(url, targetHeight, out img); + } + public static void GetThumbAsync(string url, int targetHeight, System.Windows.Forms.Control invoker, Action onReady) + { + ThumbCache.GetOrCreateAsync(url, targetHeight, invoker, onReady); } - private static readonly object _lock = new object(); - private static readonly Dictionary _cache = - new Dictionary(StringComparer.OrdinalIgnoreCase); - private static readonly Dictionary> _tasks = - new Dictionary>(StringComparer.OrdinalIgnoreCase); + // —— 公共接口(原图) —— + public static bool TryGetFull(string url, out Image img) + { + return FullCache.TryGet(url, out img); + } + public static void GetFullAsync(string url, System.Windows.Forms.Control invoker, Action onReady) + { + FullCache.GetOrCreateAsync(url, invoker, onReady); + } - // 旧框架用传统 Semaphore,限制并发下载数量 - private static readonly Semaphore _throttle = new Semaphore(4, 4); // 同时最多 4 个下载 - private const int Capacity = 300; // 最多缓存 300 张缩略图 + // ===== 缩略图缓存实现 ===== + private static class ThumbCache + { + private class CacheEntry + { + public Image Image; + public DateTime LastHitUtc; + } - static UrlImageCacheCompat() + private static readonly object _lock = new object(); + private static readonly Dictionary _cache = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary> _tasks = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private static readonly Semaphore _throttle = new Semaphore(4, 4); // 并发 4 + private const int Capacity = 300; + + static ThumbCache() + { + EnableTls12(); + } + + private static string MakeKey(string url, int h) + { + return url + "#h=" + h.ToString(); + } + + public static bool TryGet(string url, int targetHeight, out Image image) + { + string key = MakeKey(url, targetHeight); + lock (_lock) + { + CacheEntry e; + if (_cache.TryGetValue(key, out e) && e != null && e.Image != null) + { + e.LastHitUtc = DateTime.UtcNow; + _cache[key] = e; + image = e.Image; + return true; + } + } + image = null; + return false; + } + + public static void GetOrCreateAsync(string url, int targetHeight, System.Windows.Forms.Control uiInvoker, Action onReady) + { + string key = MakeKey(url, targetHeight); + + lock (_lock) + { + if (_cache.ContainsKey(key)) + { + BeginInvokeSafe(uiInvoker, onReady); + return; + } + + Task exist; + if (_tasks.TryGetValue(key, out exist)) + { + exist.ContinueWith(delegate { BeginInvokeSafe(uiInvoker, onReady); }, TaskScheduler.Default); + return; + } + + Task task = Task.Factory.StartNew(delegate + { + _throttle.WaitOne(); + try + { + byte[] bytes = DownloadBytes(url); + if (bytes == null) return null; + using (Bitmap src = BytesToBitmap(bytes)) + { + return ScaleBitmap(src, targetHeight); + } + } + catch { return null; } + finally { try { _throttle.Release(); } catch { } } + }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default).ContinueWith(delegate (Task t) + { + Image result = t.Result; + lock (_lock) + { + _tasks.Remove(key); + if (result != null) + { + _cache[key] = new CacheEntry { Image = result, LastHitUtc = DateTime.UtcNow }; + TrimIfNeededUnsafe(); + } + } + return result; + }, TaskScheduler.Default); + + _tasks[key] = task; + task.ContinueWith(delegate { BeginInvokeSafe(uiInvoker, onReady); }, TaskScheduler.Default); + } + } + + private static void TrimIfNeededUnsafe() + { + if (_cache.Count <= Capacity) return; + List> list = _cache.ToList(); + list.Sort(delegate (KeyValuePair a, KeyValuePair b) + { + return a.Value.LastHitUtc.CompareTo(b.Value.LastHitUtc); + }); + int removeCount = Math.Max(1, Capacity / 10); + for (int i = 0; i < removeCount && i < list.Count; i++) + { + string k = list[i].Key; + CacheEntry e; + if (_cache.TryGetValue(k, out e)) + { + _cache.Remove(k); + try { if (e.Image != null) e.Image.Dispose(); } catch { } + } + } + } + } + + // ===== 原图缓存实现(容量更小) ===== + private static class FullCache + { + private class CacheEntry + { + public Image Image; + public DateTime LastHitUtc; + } + + private static readonly object _lock = new object(); + private static readonly Dictionary _cache = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary> _tasks = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private static readonly Semaphore _throttle = new Semaphore(2, 2); // 原图并发更小 + private const int Capacity = 50; + + static FullCache() + { + EnableTls12(); + } + + public static bool TryGet(string url, out Image image) + { + lock (_lock) + { + CacheEntry e; + if (_cache.TryGetValue(url, out e) && e != null && e.Image != null) + { + e.LastHitUtc = DateTime.UtcNow; + _cache[url] = e; + image = e.Image; + return true; + } + } + image = null; + return false; + } + + public static void GetOrCreateAsync(string url, System.Windows.Forms.Control uiInvoker, Action onReady) + { + lock (_lock) + { + Image cached; + if (TryGet(url, out cached)) + { + BeginInvokeSafe(uiInvoker, delegate { onReady(cached); }); + return; + } + + Task exist; + if (_tasks.TryGetValue(url, out exist)) + { + exist.ContinueWith(t => BeginInvokeSafe(uiInvoker, delegate { onReady(t.Result); }), TaskScheduler.Default); + return; + } + + Task task = Task.Factory.StartNew(delegate + { + _throttle.WaitOne(); + try + { + byte[] bytes = DownloadBytes(url); + if (bytes == null) return null; + using (Bitmap bmp = BytesToBitmap(bytes)) + { + // 原图:不缩放,复制一份避免流依赖 + return CopyBitmap(bmp); + } + } + catch { return null; } + finally { try { _throttle.Release(); } catch { } } + }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default).ContinueWith(delegate (Task t) + { + Image result = t.Result; + lock (_lock) + { + _tasks.Remove(url); + if (result != null) + { + _cache[url] = new CacheEntry { Image = result, LastHitUtc = DateTime.UtcNow }; + TrimIfNeededUnsafe(); + } + } + return result; + }, TaskScheduler.Default); + + _tasks[url] = task; + task.ContinueWith(t => BeginInvokeSafe(uiInvoker, delegate { onReady(t.Result); }), TaskScheduler.Default); + } + } + + private static void TrimIfNeededUnsafe() + { + if (_cache.Count <= Capacity) return; + List> list = _cache.ToList(); + list.Sort(delegate (KeyValuePair a, KeyValuePair b) + { + return a.Value.LastHitUtc.CompareTo(b.Value.LastHitUtc); + }); + int removeCount = Math.Max(1, Capacity / 10); + for (int i = 0; i < removeCount && i < list.Count; i++) + { + string k = list[i].Key; + CacheEntry e; + if (_cache.TryGetValue(k, out e)) + { + _cache.Remove(k); + try { if (e.Image != null) e.Image.Dispose(); } catch { } + } + } + } + } + + // ===== 共享底层工具 ===== + private static void EnableTls12() { - // 尽量启用 TLS1.2(老系统需要) try { const System.Security.Authentication.SslProtocols _Tls12 = @@ -218,112 +559,19 @@ public static class LabPicUrlPreview catch { } } - private static string MakeKey(string url, int h) + private static byte[] DownloadBytes(string url) { - return url + "#h=" + h.ToString(); - } - - public static bool TryGet(string url, int targetHeight, out Image image) - { - string key = MakeKey(url, targetHeight); - lock (_lock) + using (WebClient wc = new WebClient()) { - CacheEntry e; - if (_cache.TryGetValue(key, out e) && e != null && e.Image != null) - { - e.LastHitUtc = DateTime.UtcNow; - _cache[key] = e; - image = e.Image; - return true; - } - } - image = null; - return false; - } - - public static void GetOrCreateAsync(string url, int targetHeight, System.Windows.Forms.Control uiInvoker, Action onReady) - { - string key = MakeKey(url, targetHeight); - - lock (_lock) - { - // 已缓存:直接回调 - if (_cache.ContainsKey(key)) - { - BeginInvokeSafe(uiInvoker, onReady); - return; - } - - // 同 key 只下载一次:已有任务则挂回调 - Task exist; - if (_tasks.TryGetValue(key, out exist)) - { - exist.ContinueWith(delegate { BeginInvokeSafe(uiInvoker, onReady); }, TaskScheduler.Default); - return; - } - - // 启动新任务(受限流) - Task task = Task.Factory.StartNew(delegate - { - _throttle.WaitOne(); - try - { - using (WebClient wc = new WebClient()) - { - byte[] bytes = wc.DownloadData(url); - using (MemoryStream ms = new MemoryStream(bytes)) - using (Bitmap bmp = new Bitmap(ms)) - { - return ScaleBitmap(bmp, targetHeight); - } - } - } - catch - { - return null; - } - finally - { - try { _throttle.Release(); } catch { } - } - }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default).ContinueWith(delegate (Task t) - { - Image result = t.Result; - lock (_lock) - { - _tasks.Remove(key); - if (result != null) - { - _cache[key] = new CacheEntry { Image = result, LastHitUtc = DateTime.UtcNow }; - TrimIfNeededUnsafe(); - } - } - return result; - }, TaskScheduler.Default); - - _tasks[key] = task; - - task.ContinueWith(delegate { BeginInvokeSafe(uiInvoker, onReady); }, TaskScheduler.Default); + return wc.DownloadData(url); } } - private static Image ScaleBitmap(Bitmap bmp, int targetHeight) + private static Bitmap BytesToBitmap(byte[] bytes) { - int h = targetHeight <= 0 ? 30 : targetHeight; - if (h < 18) h = 18; - - double ratio = (double)h / (double)bmp.Height; - int w = (int)Math.Round((double)bmp.Width * ratio); - if (w < 1) w = 1; - - // 只缓存缩略图,避免内存暴涨 - return new Bitmap(bmp, new Size(w, h)); - } - - private static void TrimIfNeededUnsafe() - { - if (_cache.Count <= Capacity) return; - - // 简单 LRU:移除最早命中的 10% - List> list = _cache.ToList(); - list.Sort(delegate (KeyValuePair a, KeyValuePair