// ============================================================================ // LabPicUrlPreview_TreeList.cs (C# 7.3 / DevExpress 15.2 兼容) // - 适配 TreeList:InitPicColumn / Attach / AttachPreview // - 复用原线程安全缓存:缩略图 & 原图分离缓存、并发限流 + LRU 清理 // - 同步阶段写 e.Value,异步完成后刷新 TreeList 单元格或预览窗体 // ============================================================================ using System; using System.Collections.Generic; using System.Data; using System.Drawing; using System.IO; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using DevExpress.Data; using DevExpress.XtraEditors.Controls; using DevExpress.XtraEditors.Repository; using DevExpress.XtraTreeList; using DevExpress.XtraTreeList.Columns; using DevExpress.XtraTreeList.Nodes; using DevExpress.XtraTreeList.ViewInfo; using Lskj.Control.Model; using Lskj.Control; using Lskj.Business; public static class LabPicUrlPreview_TreeList { // ====== 对外:初始化图片列(创建 RepositoryItemPictureEdit、设为 Unbound) ====== public static void InitPicColumn(TreeList treeList, TreeListColumn treeListColumn, GridColumnModel model) { if (treeList == null || treeListColumn == null || model == null) return; treeListColumn.AppearanceCell.Options.UseTextOptions = true; treeListColumn.AppearanceCell.TextOptions.HAlignment = DevExpress.Utils.HorzAlignment.Center; treeListColumn.OptionsColumn.AllowEdit = false; // 唯一列名(保持原逻辑,避免列名重复) treeListColumn.FieldName = string.Format("{0}{1}", model.FieldName, Guid.NewGuid()); treeListColumn.Tag = model; // 事件中获取列模型 RepositoryItemPictureEdit pictureEdit = new RepositoryItemPictureEdit(); pictureEdit.ShowMenu = false; pictureEdit.NullText = " "; pictureEdit.SizeMode = PictureSizeMode.Zoom; treeList.RepositoryItems.Add(pictureEdit); treeListColumn.UnboundType = DevExpress.XtraTreeList.Data.UnboundColumnType.Object; treeListColumn.ColumnEdit = pictureEdit; } // ====== 对外:绑定“取缩略图”的事件(只需调用一次) ====== public static void Attach(TreeList treeList) { if (treeList == null) return; treeList.CustomUnboundColumnData -= OnCustomUnboundColumnData; treeList.CustomUnboundColumnData += OnCustomUnboundColumnData; } // ====== 对外:绑定“双击大图预览”的事件(只需调用一次) ====== public static void AttachPreview(TreeList treeList) { if (treeList == null) return; treeList.DoubleClick -= OnTreeListDoubleClick; treeList.DoubleClick += OnTreeListDoubleClick; } // ===================== 缩略图事件实现(核心改造) ===================== private static void OnCustomUnboundColumnData(object sender, TreeListCustomColumnDataEventArgs e) { TreeList treeList = sender as TreeList; if (treeList == null) return; GridColumnModel m = e.Column.Tag as GridColumnModel; if (m == null || m.FieldType != ControlType.LabPicUrl || !e.IsGetData) return; // 1) TreeList 用 Node 直接定位(替代 Grid 的 RowHandle/ListSourceRowIndex) TreeListNode node = e.Node; if (node == null) return; // 2) 读取原始 URL(优先 Node 数据,兜底数据源) string raw = null; try { // TreeList 直接从 Node 获取字段值(兼容绑定 DataTable/自定义对象) object val = node.GetValue(m.FieldName); raw = val == null ? null : Convert.ToString(val); } catch { // 兜底:从数据源(如 DataRow)获取 DataRow row = node.Tag as DataRow; if (row != null && row.Table.Columns.Contains(m.FieldName)) raw = Convert.ToString(row[m.FieldName]); } if (string.IsNullOrWhiteSpace(raw)) return; // 3) 拼接完整 URL(复用原逻辑) string url = ResolveFullUrl(raw); // 4) 目标高度(复用原逻辑) int targetHeight = NormalizeHeight(m); // 5) 缓存命中:同步赋值 e.Value Image img; if (ImageCache.TryGetThumb(url, targetHeight, out img)) { e.Value = img; return; } // 6) 未命中:异步下载,完成后刷新当前单元格(TreeList 刷新 API 改造) System.Windows.Forms.Control invoker = treeList; // UI 回调对象 ImageCache.GetThumbAsync(url, targetHeight, invoker, delegate () { RefreshCellCompat(treeList, node, e.Column); }); } // ===================== 双击预览(原图)(核心改造) ===================== private static void OnTreeListDoubleClick(object sender, EventArgs e) { TreeList treeList = sender as TreeList; if (treeList == null) return; // 1) TreeList 命中测试(替代 Grid 的 CalcHitInfo) TreeListHitInfo hitInfo = treeList.CalcHitInfo(treeList.PointToClient(Control.MousePosition)); //if (hitInfo == null || !hitInfo.InNodeCell || hitInfo.Column == null) return; if (hitInfo == null || hitInfo.HitInfoType != HitInfoType.Cell || hitInfo.Node == null || hitInfo.Column == null) return; TreeListNode hitNode = hitInfo.Node; TreeListColumn hitColumn = hitInfo.Column; // 2) 只处理 LabPicUrl 类型列 GridColumnModel m = hitColumn.Tag as GridColumnModel; if (m == null || m.FieldType != ControlType.LabPicUrl) return; // 3) 读取当前 Node 的 URL(TreeList 专属取值逻辑) string raw = null; try { object val = hitNode.GetValue(m.FieldName); raw = val == null ? null : Convert.ToString(val); } catch { // 兜底:从 Node.Tag(如 DataRow)获取 DataRow row = hitNode.Tag as DataRow; if (row != null && row.Table.Columns.Contains(m.FieldName)) raw = Convert.ToString(row[m.FieldName]); } if (string.IsNullOrWhiteSpace(raw)) return; string url = ResolveFullUrl(raw); // 4) 打开预览窗体(复用原逻辑,适配 TreeList 数据) Image thumb = null; ImageCache.TryGetThumb(url, NormalizeHeight(m), out thumb); FrmTreePicture frm = new FrmTreePicture(); try { // 适配 TreeList 列与节点数据(替换原 Grid 的 Column/Row) frm.PicUrlColumns = treeList.Columns.Where(c => c.Tag is GridColumnModel cm && cm.FieldType == ControlType.LabPicUrl).ToList(); // 兼容原窗体列集合类型 frm.FocusedRow = hitNode.Tag as DataRow; // 传递 Node 绑定的 DataRow frm.FocusedColumn = hitColumn; frm.Text = "预览"; frm.Picture = thumb; 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(); } } // ====== TreeList 单元格安全刷新(替代 Grid 的 RefreshRowCell) ====== private static void RefreshCellCompat(TreeList treeList, TreeListNode node, TreeListColumn column) { try { if (treeList.IsDisposed || node == null || column == null) return; // 整体刷新节点(避免单个单元格刷新失败) if (!treeList.IsDisposed && node != null) treeList.RefreshNode(node); } catch { try { // 刷新整个 TreeList(极端情况) if (!treeList.IsDisposed) treeList.RefreshDataSource(); } catch { } } } // ====== 以下方法完全复用原逻辑(无改造) ====== private static int NormalizeHeight(object model) { try { var t = model.GetType(); // 读取属性 var pi = t.GetProperty("LabPicUrlHeight"); if (pi != null) { int v = Convert.ToInt32(pi.GetValue(model, null)); return v <= 18 ? 30 : v; } // 读取无参方法 var mi = t.GetMethod("LabPicUrlHeight", Type.EmptyTypes); if (mi != null) { int v = Convert.ToInt32(mi.Invoke(model, null)); return v <= 18 ? 30 : v; } } catch { } return 30; } private static string ResolveFullUrl(string raw) { if (string.IsNullOrEmpty(raw)) return raw; if (raw.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return raw; try { string oa = SystemInfo.Instance.OAUrl; if (string.IsNullOrEmpty(oa)) return raw.TrimStart('/'); if (!oa.EndsWith("/")) oa += "/"; return oa + raw.TrimStart('/'); } catch { return raw.TrimStart('/'); } } // ====== 图片缓存(完全复用原逻辑,无任何改造) ====== private static class ImageCache { // —— 公共接口(缩略图) —— public static bool TryGetThumb(string url, int targetHeight, out Image img) { 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); } // —— 公共接口(原图) —— 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); } // ===== 缩略图缓存实现 ===== private static class ThumbCache { 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(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() { try { const System.Security.Authentication.SslProtocols _Tls12 = (System.Security.Authentication.SslProtocols)0x00000C00; ServicePointManager.SecurityProtocol |= (SecurityProtocolType)_Tls12; } catch { } } private static byte[] DownloadBytes(string url) { using (WebClient wc = new WebClient()) { return wc.DownloadData(url); } } private static Bitmap BytesToBitmap(byte[] bytes) { using (MemoryStream ms = new MemoryStream(bytes)) { using (Image img = Image.FromStream(ms)) { return new Bitmap(img); } } } private static Image ScaleBitmap(Bitmap src, int targetHeight) { int h = targetHeight <= 0 ? 30 : targetHeight; if (h < 18) h = 18; double ratio = (double)h / (double)src.Height; int w = (int)Math.Round((double)src.Width * ratio); if (w < 1) w = 1; return new Bitmap(src, new Size(w, h)); } private static Image CopyBitmap(Bitmap src) { return new Bitmap(src); } private static void BeginInvokeSafe(System.Windows.Forms.Control c, Action action) { if (action == null) return; try { if (c != null && !c.IsDisposed) c.BeginInvoke(action); else action(); } catch { try { action(); } catch { } } } } }