582 lines
22 KiB
C#
582 lines
22 KiB
C#
// ============================================================================
|
||
// 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<Image> 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<string, CacheEntry> _cache =
|
||
new Dictionary<string, CacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||
private static readonly Dictionary<string, Task<Image>> _tasks =
|
||
new Dictionary<string, Task<Image>>(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<Image> exist;
|
||
if (_tasks.TryGetValue(key, out exist))
|
||
{
|
||
exist.ContinueWith(delegate { BeginInvokeSafe(uiInvoker, onReady); }, TaskScheduler.Default);
|
||
return;
|
||
}
|
||
|
||
Task<Image> task = Task.Factory.StartNew<Image>(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<Image>(delegate (Task<Image> 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<KeyValuePair<string, CacheEntry>> list = _cache.ToList();
|
||
list.Sort(delegate (KeyValuePair<string, CacheEntry> a, KeyValuePair<string, CacheEntry> 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<string, CacheEntry> _cache =
|
||
new Dictionary<string, CacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||
private static readonly Dictionary<string, Task<Image>> _tasks =
|
||
new Dictionary<string, Task<Image>>(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<Image> onReady)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
Image cached;
|
||
if (TryGet(url, out cached))
|
||
{
|
||
BeginInvokeSafe(uiInvoker, delegate { onReady(cached); });
|
||
return;
|
||
}
|
||
|
||
Task<Image> exist;
|
||
if (_tasks.TryGetValue(url, out exist))
|
||
{
|
||
exist.ContinueWith(t => BeginInvokeSafe(uiInvoker, delegate { onReady(t.Result); }), TaskScheduler.Default);
|
||
return;
|
||
}
|
||
|
||
Task<Image> task = Task.Factory.StartNew<Image>(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<Image>(delegate (Task<Image> 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<KeyValuePair<string, CacheEntry>> list = _cache.ToList();
|
||
list.Sort(delegate (KeyValuePair<string, CacheEntry> a, KeyValuePair<string, CacheEntry> 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 { }
|
||
}
|
||
}
|
||
}
|
||
} |