// ============================================================================ // LabPicUrlPreview.cs (C# 7.3 / DevExpress 15.2 兼容) // - 公用方法封装在一个类里 // - 事件绑定在一个地方 Attach(view) // - 线程安全缓存 + 并发限流 + LRU 清理 // - 只在同步阶段写 e.Value;异步完成后刷新单元格 // ============================================================================ 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.XtraGrid; using DevExpress.XtraGrid.Columns; using DevExpress.XtraGrid.Views.Base; using DevExpress.XtraGrid.Views.Grid; using DevExpress.XtraGrid.Views.BandedGrid; using Lskj.Business; using Lskj.Control.Model; // 若命名空间不同,请改成正确的 using public static class LabPicUrlPreview { // ====== 对外:初始化图片列(创建 RepositoryItemPictureEdit、设为 Unbound) ====== public static void InitPicColumn(GridControl gridControl, GridColumn gridColumn, GridColumnModel model) { if (gridControl == null || gridColumn == null || model == null) return; gridColumn.AppearanceCell.Options.UseTextOptions = true; gridColumn.AppearanceCell.TextOptions.HAlignment = DevExpress.Utils.HorzAlignment.Center; gridColumn.OptionsColumn.AllowEdit = false; // 唯一列名(保持你原逻辑) gridColumn.FieldName = string.Format("{0}{1}", model.FieldName, Guid.NewGuid()); gridColumn.Tag = model; // 事件里取模型 RepositoryItemPictureEdit pictureEdit = new RepositoryItemPictureEdit(); pictureEdit.ShowMenu = false; pictureEdit.NullText = " "; pictureEdit.SizeMode = PictureSizeMode.Zoom; gridControl.RepositoryItems.Add(pictureEdit); gridColumn.UnboundType = UnboundColumnType.Object; gridColumn.ColumnEdit = pictureEdit; } // ====== 对外:统一事件绑定入口(只需调用一次) ====== public static void Attach(ColumnView view) { if (view == null) return; view.CustomUnboundColumnData -= OnCustomUnboundColumnData; view.CustomUnboundColumnData += OnCustomUnboundColumnData; } // ====== 事件实现(只在同步阶段写 e.Value;异步完成后刷新该单元格) ====== private static void OnCustomUnboundColumnData(object sender, CustomColumnDataEventArgs e) { ColumnView view = sender as ColumnView; if (view == null) return; GridColumnModel m = e.Column.Tag as GridColumnModel; if (m == null || m.FieldType != ControlType.LabPicUrl || !e.IsGetData) return; // 1) 用 ListSourceRowIndex → RowHandle(兼容 DevExpress 15.2) int listIndex = e.ListSourceRowIndex; int rowHandle = view.GetRowHandle(listIndex); // 2) 读取原始 URL(优先 ListSource;兜底 e.Row) string raw = null; try { object val = view.GetListSourceRowCellValue(listIndex, m.FieldName); raw = val == null ? null : Convert.ToString(val); } catch { DataRowView drv = e.Row as DataRowView; if (drv != null && drv.Row != null && drv.Row.Table != null && drv.Row.Table.Columns.Contains(m.FieldName)) raw = Convert.ToString(drv.Row[m.FieldName]); } if (string.IsNullOrWhiteSpace(raw)) return; // 3) 拼接完整 URL(相对路径 -> OA 根地址) string url = ResolveFullUrl(raw); // 4) 目标高(属性或方法都兼容;<=18 则用 30) int targetHeight = NormalizeHeight(m); // 5) 缓存命中:当次赋值(只在同步阶段写 e.Value) Image img; if (UrlImageCacheCompat.TryGet(url, targetHeight, out img)) { e.Value = img; return; } // 6) 未命中:安排异步下载;完成后刷新该单元格(不要在异步线程里改 e.Value) System.Windows.Forms.Control invoker = view.GridControl; // UI 回调对象 UrlImageCacheCompat.GetOrCreateAsync(url, targetHeight, invoker, delegate () { RefreshCellCompat(view, rowHandle, e.Column); }); } // ====== 视图安全刷新:GridView/BandedGridView 定向刷新,其他降级为 RefreshData ====== private static void RefreshCellCompat(ColumnView view, int rowHandle, GridColumn column) { try { GridView gv = view as GridView; if (gv != null) { if (rowHandle >= 0) gv.RefreshRowCell(rowHandle, column); else gv.RefreshData(); return; } BandedGridView bgv = view as BandedGridView; if (bgv != null) { if (rowHandle >= 0) bgv.RefreshRowCell(rowHandle, column); else bgv.RefreshData(); return; } view.RefreshData(); } catch { try { view.RefreshData(); } catch { } } } // ====== 兼容“属性或无参方法”的高度读取(避免 m.LabPicUrlHeight 被当作方法组出错) ====== 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; } // ====== 解析完整 URL:相对路径 -> SystemInfo.Instance.OAUrl + 相对 ====== private static string ResolveFullUrl(string raw) { if (string.IsNullOrEmpty(raw)) return raw; if (raw.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return raw; try { // 直接使用你项目里的 SystemInfo(若没有可替换为你自己的配置) string oa = SystemInfo.Instance.OAUrl; if (string.IsNullOrEmpty(oa)) return raw.TrimStart('/'); if (!oa.EndsWith("/")) oa += "/"; return oa + raw.TrimStart('/'); } catch { return raw.TrimStart('/'); } } // ====== 线程安全缓存(WebClient + Dictionary + Semaphore,C# 7.3 兼容) ====== private static class UrlImageCacheCompat { 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); // 旧框架用传统 Semaphore,限制并发下载数量 private static readonly Semaphore _throttle = new Semaphore(4, 4); // 同时最多 4 个下载 private const int Capacity = 300; // 最多缓存 300 张缩略图 static UrlImageCacheCompat() { // 尽量启用 TLS1.2(老系统需要) try { const System.Security.Authentication.SslProtocols _Tls12 = (System.Security.Authentication.SslProtocols)0x00000C00; ServicePointManager.SecurityProtocol |= (SecurityProtocolType)_Tls12; } catch { } } 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; } // 同 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); } } private static Image ScaleBitmap(Bitmap bmp, int targetHeight) { 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