阶段性批量提交
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using SHH.CameraDashboard.Services;
|
||||
using SHH.ProcessLaunchers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
@@ -8,10 +10,30 @@ namespace SHH.CameraDashboard
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
#region 定义全局单例
|
||||
|
||||
/// <summary>
|
||||
/// 进程管理器
|
||||
/// </summary>
|
||||
public static ProcessManager ProcManager { get; private set; }
|
||||
= new ProcessManager(new ProcessDashboardLogger());
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
protected override async void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
// 启动视频流接收 (Port 6002)
|
||||
StreamReceiverService.Instance.Start(6002);
|
||||
|
||||
// 启动指令服务 (Port 6001)
|
||||
CommandServer.Instance.Start(6001);
|
||||
|
||||
|
||||
// 现在我们来配置启动
|
||||
|
||||
// 1. 【核心代码】程序启动时,异步读取配置文件
|
||||
var savedNodes = await LocalStorageService.LoadAsync<ObservableCollection<ServiceNodeModel>>(AppPaths.ServiceNodesConfig);
|
||||
if (savedNodes != null)
|
||||
@@ -20,6 +42,43 @@ namespace SHH.CameraDashboard
|
||||
AppGlobal.ServiceNodes.Add(node);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 3. 构建启动参数 & 注册进程
|
||||
// =========================================================
|
||||
|
||||
// A. 获取当前 Dashboard 的 PID (用于父进程守护)
|
||||
int myPid = System.Environment.ProcessId;
|
||||
|
||||
// B. 构建参数字符串
|
||||
// --pid: 让 Service 知道谁是父进程
|
||||
// --uris: 告诉 Service 反向连接的目标 (注意顺序:视频端口, 指令端口)
|
||||
// --mode: 1 (主动连接模式)
|
||||
// --ports: Service 自身的 WebAPI 端口 (5005)
|
||||
string serviceArgs = $"" +
|
||||
$"--pid {myPid} " +
|
||||
$"--appid \"CameraApp_01\" " +
|
||||
$"--uris \"127.0.0.1,6002&6001;\" " +
|
||||
$"--mode 1 " +
|
||||
$"--ports \"5000,100\"";
|
||||
|
||||
// C. 注册进程配置 (复用 ProcManager)
|
||||
ProcManager.Register(new ProcessConfig
|
||||
{
|
||||
Id = "CameraService", // 内部标识
|
||||
DisplayName = "视频接入服务", // UI显示名称
|
||||
// 请确保路径正确,建议用相对路径 AppDomain.CurrentDomain.BaseDirectory + "SHH.CameraService.exe"
|
||||
ExePath = @"D:\Codes\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe",
|
||||
Arguments = serviceArgs, // ★★★ 核心:注入参数 ★★★
|
||||
StartupOrder = 1, // 优先级
|
||||
RestartDelayMs = 2000, // 崩溃后2秒重启
|
||||
Visible = false // 不显示黑框
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 4. 发射!启动所有注册的进程
|
||||
// =========================================================
|
||||
_ = ProcManager.StartAllAsync();
|
||||
|
||||
// 3. 启动主窗口
|
||||
// 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留,
|
||||
// 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。
|
||||
@@ -35,7 +94,13 @@ namespace SHH.CameraDashboard
|
||||
// 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接)
|
||||
// 2. 保存用户配置
|
||||
// 3. 彻底退出
|
||||
StreamReceiverService.Instance.Dispose();
|
||||
CommandServer.Instance.Dispose();
|
||||
// 停止所有子进程
|
||||
ProcManager.StopAll();
|
||||
|
||||
Current.Shutdown();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
45
SHH.CameraDashboard/Invokes/BitmapHelper.cs
Normal file
45
SHH.CameraDashboard/Invokes/BitmapHelper.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
/// <summary>
|
||||
/// [UI层] 图像数据转换助手
|
||||
/// 职责:将内存中的二进制 JPEG 数据高效转换为 WPF 可用的 BitmapImage
|
||||
/// 优化:使用 OnLoad 缓存策略和 Freeze 冻结对象,支持跨线程访问,防止内存泄漏
|
||||
/// </summary>
|
||||
public static class BitmapHelper
|
||||
{
|
||||
public static BitmapImage? ToBitmapImage(byte[] blob)
|
||||
{
|
||||
if (blob == null || blob.Length == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using (var stream = new MemoryStream(blob))
|
||||
{
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
|
||||
// 关键优化 1: 立即加载流到内存,允许 stream 在方法结束后被释放
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = stream;
|
||||
|
||||
// 关键优化 2: 忽略内嵌的色彩配置和缩略图,提升解码速度
|
||||
bitmap.CreateOptions = BitmapCreateOptions.IgnoreColorProfile | BitmapCreateOptions.IgnoreImageCache;
|
||||
|
||||
bitmap.EndInit();
|
||||
|
||||
// 关键优化 3: 冻结对象,使其变得线程安全(可以跨线程传递给 UI)
|
||||
bitmap.Freeze();
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解码失败(可能是坏帧),返回 null 忽略该帧
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
SHH.CameraDashboard/Invokes/CommandServer.cs
Normal file
167
SHH.CameraDashboard/Invokes/CommandServer.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SHH.CameraDashboard.Services;
|
||||
|
||||
/// <summary>
|
||||
/// [Dashboard端] 指令控制服务
|
||||
/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。
|
||||
/// 核心模式:ROUTER (Dashboard) <--> DEALER (Service)
|
||||
/// </summary>
|
||||
public class CommandServer : IDisposable
|
||||
{
|
||||
// 单例模式
|
||||
public static CommandServer Instance { get; } = new CommandServer();
|
||||
|
||||
// 事件:收到消息时触发 (ServiceId, MessageContent)
|
||||
public event Action<string, string>? OnMessageReceived;
|
||||
|
||||
private RouterSocket? _routerSocket;
|
||||
private NetMQPoller? _poller;
|
||||
|
||||
// 【关键新增】发送队列:用于解决跨线程发送的安全问题
|
||||
// UI线程 -> Enqueue -> Poller线程 -> Socket.Send
|
||||
private NetMQQueue<CommandPacket>? _sendQueue;
|
||||
|
||||
public int ListenPort { get; private set; }
|
||||
public bool IsRunning => _poller != null && _poller.IsRunning;
|
||||
|
||||
// 在线设备表 (可选,用于记录谁在线)
|
||||
// Key: ServiceId (Identity字符串)
|
||||
private readonly ConcurrentDictionary<string, DateTime> _onlineClients = new();
|
||||
|
||||
private CommandServer() { }
|
||||
|
||||
public void Start(int port)
|
||||
{
|
||||
ListenPort = port;
|
||||
if (IsRunning) return;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 初始化 Router Socket
|
||||
_routerSocket = new RouterSocket();
|
||||
_routerSocket.Bind($"tcp://*:{ListenPort}");
|
||||
_routerSocket.ReceiveReady += OnSocketReady;
|
||||
|
||||
// 2. 初始化发送队列
|
||||
_sendQueue = new NetMQQueue<CommandPacket>();
|
||||
_sendQueue.ReceiveReady += OnQueueReady;
|
||||
|
||||
// 3. 启动 Poller (同时监听 Socket 接收 和 队列发送)
|
||||
_poller = new NetMQPoller { _routerSocket, _sendQueue };
|
||||
|
||||
// RunAsync 会自动开启后台线程
|
||||
_poller.RunAsync();
|
||||
|
||||
Console.WriteLine($"[Dashboard] 指令服务启动,监听: tcp://*:{ListenPort}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dashboard] 指令端口绑定失败: {ex.Message}");
|
||||
throw; // 必须抛出,让 App 感知
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理来自 Service 的网络消息 (运行在 Poller 线程)
|
||||
/// </summary>
|
||||
private void OnSocketReady(object? sender, NetMQSocketEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 读取身份帧 (Identity)
|
||||
// 只要 Service 端 DealerSocket 设置了 Identity,这里收到就是那个 ID
|
||||
var identityBytes = e.Socket.ReceiveFrameBytes();
|
||||
string serviceId = Encoding.UTF8.GetString(identityBytes);
|
||||
|
||||
// 2. 读取内容帧 (假设 Dealer 直接发内容,中间无空帧)
|
||||
// 如果你使用了 REQ/REP 模式,中间可能会有空帧,需注意兼容
|
||||
string message = e.Socket.ReceiveFrameString();
|
||||
|
||||
// 3. 简单的心跳保活逻辑
|
||||
_onlineClients[serviceId] = DateTime.Now;
|
||||
|
||||
// 4. 触发业务事件
|
||||
// 注意:这依然在 Poller 线程,UI 处理时需 Invoke
|
||||
Console.WriteLine($"[指令] From {serviceId}: {message}");
|
||||
OnMessageReceived?.Invoke(serviceId, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[Command Receive Error] {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理发送队列 (运行在 Poller 线程)
|
||||
/// </summary>
|
||||
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_routerSocket == null) return;
|
||||
|
||||
// 从队列取出一个包
|
||||
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
|
||||
{
|
||||
// Router 发送标准三步走:
|
||||
// 1. 发送目标 Identity (More = true)
|
||||
// 2. 发送空帧 (可选,取决于协议约定,Router-Dealer 直连通常不需要空帧)
|
||||
// 3. 发送数据 (More = false)
|
||||
|
||||
// 这里我们采用最简协议:[Identity][Data]
|
||||
_routerSocket.SendMoreFrame(packet.TargetId)
|
||||
.SendFrame(packet.JsonData);
|
||||
|
||||
Console.WriteLine($"[指令] To {packet.TargetId}: {packet.JsonData}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[Command Send Error] {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送指令 (线程安全,可由 UI 线程调用)
|
||||
/// </summary>
|
||||
public void SendCommand(string targetServiceId, object commandData)
|
||||
{
|
||||
if (_sendQueue == null) return;
|
||||
|
||||
var json = JsonConvert.SerializeObject(commandData);
|
||||
|
||||
// ★★★ 核心修复:不直接操作 Socket,而是入队 ★★★
|
||||
_sendQueue.Enqueue(new CommandPacket
|
||||
{
|
||||
TargetId = targetServiceId,
|
||||
JsonData = json
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_poller?.Stop();
|
||||
_poller?.Dispose();
|
||||
_routerSocket?.Dispose();
|
||||
_sendQueue?.Dispose();
|
||||
|
||||
_poller = null;
|
||||
_routerSocket = null;
|
||||
_sendQueue = null;
|
||||
}
|
||||
|
||||
// 内部数据包结构
|
||||
private class CommandPacket
|
||||
{
|
||||
public string TargetId { get; set; } = "";
|
||||
public string JsonData { get; set; } = "";
|
||||
}
|
||||
}
|
||||
37
SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs
Normal file
37
SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
/// <summary>
|
||||
/// [Dashboard端] Service 启动参数构建器
|
||||
/// 职责:生成标准化的命令行参数字符串,告诉 Service 如何反向连接
|
||||
/// </summary>
|
||||
public static class ServiceLaunchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成启动参数
|
||||
/// </summary>
|
||||
/// <param name="serviceId">给子服务起的唯一ID (如 "CamService_01")</param>
|
||||
/// <param name="dashboardIp">Dashboard 的 IP (通常是 127.0.0.1)</param>
|
||||
/// <param name="streamPort">Dashboard 监听视频的端口 (如 6000)</param>
|
||||
/// <param name="serviceApiPort">指定子服务 WebAPI 监听的端口 (如 5005)</param>
|
||||
/// <returns>命令行参数字符串</returns>
|
||||
public static string BuildArguments(string serviceId, string dashboardIp, int streamPort, int serviceApiPort)
|
||||
{
|
||||
// 获取当前 Dashboard 进程 ID,传给子进程做“父进程守护”
|
||||
int parentPid = Environment.ProcessId;
|
||||
|
||||
// 拼接参数:
|
||||
// --pid: 父进程ID
|
||||
// --uris: 反向连接的目标地址 (Dashboard 的地址)
|
||||
// --mode: 1 (Active模式,代表 Service 主动连接 Dashboard)
|
||||
// --ports: Service 自身的 WebAPI 端口 (防止与 Dashboard 冲突)
|
||||
|
||||
return $"" +
|
||||
$"--pid {parentPid} " +
|
||||
$"--id \"{serviceId}\" " +
|
||||
$"--uris \"{dashboardIp},{streamPort}\" " +
|
||||
$"--mode 1 " +
|
||||
$"--ports \"{serviceApiPort},100\""; // 100 是保留位,暂不用
|
||||
}
|
||||
}
|
||||
133
SHH.CameraDashboard/Invokes/StreamReceiverService.cs
Normal file
133
SHH.CameraDashboard/Invokes/StreamReceiverService.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using System.Diagnostics; // 用于 Debug 输出
|
||||
|
||||
namespace SHH.CameraDashboard.Services;
|
||||
|
||||
public class StreamReceiverService : IDisposable
|
||||
{
|
||||
// 单例模式
|
||||
public static StreamReceiverService Instance { get; } = new StreamReceiverService();
|
||||
|
||||
public event Action<string, byte[]>? OnFrameReceived;
|
||||
|
||||
private SubscriberSocket? _subSocket;
|
||||
private Task? _receiveTask;
|
||||
|
||||
// 【修复1】不要在这里初始化,改为在 Start 中初始化
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public int ListenPort { get; private set; }
|
||||
|
||||
// 增加运行状态标记
|
||||
public bool IsRunning => _receiveTask != null && !_receiveTask.IsCompleted;
|
||||
|
||||
private StreamReceiverService() { }
|
||||
|
||||
public void Start(int port = 6000)
|
||||
{
|
||||
// 1. 防止重复启动
|
||||
if (IsRunning) return;
|
||||
|
||||
ListenPort = port;
|
||||
|
||||
// 【修复1】每次启动时创建新的 TokenSource
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 初始化 Socket
|
||||
_subSocket = new SubscriberSocket();
|
||||
|
||||
// 【优化】设置高水位限制 (HWM)
|
||||
// 如果 UI 处理不过来,积压超过 1000 帧直接丢弃,防止内存爆炸
|
||||
_subSocket.Options.ReceiveHighWatermark = 1000;
|
||||
|
||||
string bindAddr = $"tcp://*:{ListenPort}";
|
||||
_subSocket.Bind(bindAddr);
|
||||
_subSocket.Subscribe("");
|
||||
|
||||
Console.WriteLine($"[Dashboard] 视频流接收服务启动: {bindAddr}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dashboard] 致命错误 - 端口绑定失败: {ex.Message}");
|
||||
|
||||
// 清理资源
|
||||
_subSocket?.Dispose();
|
||||
_subSocket = null;
|
||||
|
||||
// 【修复4】抛出异常让上层知道启动失败了
|
||||
throw new Exception($"端口 {port} 绑定失败,可能被占用。", ex);
|
||||
}
|
||||
|
||||
// 3. 启动任务
|
||||
_receiveTask = Task.Run(ReceiveLoop, _cts.Token);
|
||||
}
|
||||
|
||||
private void ReceiveLoop()
|
||||
{
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 【修复2】线程安全检查
|
||||
if (_subSocket == null) break;
|
||||
|
||||
// 接收 Topic
|
||||
if (!_subSocket.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string cameraId))
|
||||
continue;
|
||||
|
||||
// 接收 Payload
|
||||
if (!_subSocket.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(100), out byte[] jpgBytes))
|
||||
continue;
|
||||
|
||||
// 触发事件
|
||||
OnFrameReceived?.Invoke(cameraId, jpgBytes);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 【修复2】这是正常的退出流程(Socket被Dispose了),优雅退出循环
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 记录日志,但不崩溃
|
||||
Debug.WriteLine($"[ReceiverLoop Error] {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine("[Dashboard] 接收循环已停止");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止服务(支持停止后重新 Start)
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
// 1. 发出取消信号
|
||||
if (_cts != null && !_cts.IsCancellationRequested)
|
||||
{
|
||||
_cts.Cancel();
|
||||
}
|
||||
|
||||
// 2. 销毁 Socket (这会触发 ReceiveLoop 中的 ObjectDisposedException 从而退出循环)
|
||||
if (_subSocket != null)
|
||||
{
|
||||
try { _subSocket.Dispose(); } catch { }
|
||||
_subSocket = null;
|
||||
}
|
||||
|
||||
// 3. 清理 Token
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
|
||||
_receiveTask = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
@@ -9,7 +10,7 @@ namespace SHH.CameraDashboard
|
||||
/// 它封装了主窗口的所有业务逻辑、状态和用户交互命令。
|
||||
/// 它还作为一个中心协调者,响应全局事件并管理子视图(如右侧编辑面板和蒙板)。
|
||||
/// </summary>
|
||||
public class MainWindowViewModel : INotifyPropertyChanged
|
||||
public class MainWindowViewModel : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
#region --- 构造函数 ---
|
||||
|
||||
@@ -520,5 +521,9 @@ namespace SHH.CameraDashboard
|
||||
get => _mainContent;
|
||||
set { _mainContent = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,76 @@
|
||||
using SHH.Contracts;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using SHH.CameraDashboard.Services; // 引用服务命名空间
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
public class VideoTileViewModel : ViewModelBase
|
||||
{
|
||||
public class VideoTileViewModel : ViewModelBase, IDisposable
|
||||
private readonly string _boundCameraId;
|
||||
|
||||
// --- 属性定义 ---
|
||||
private string _cameraName;
|
||||
public string CameraName
|
||||
{
|
||||
// --- 绑定属性 ---
|
||||
get => _cameraName;
|
||||
set { _cameraName = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private ImageSource _displayImage;
|
||||
public ImageSource DisplayImage
|
||||
private string _statusInfo;
|
||||
public string StatusInfo
|
||||
{
|
||||
get => _statusInfo;
|
||||
set { _statusInfo = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private BitmapImage _videoSource;
|
||||
public BitmapImage VideoSource
|
||||
{
|
||||
get => _videoSource;
|
||||
set { _videoSource = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
// --- 构造函数 ---
|
||||
public VideoTileViewModel(string cameraId, string name)
|
||||
{
|
||||
_boundCameraId = cameraId;
|
||||
CameraName = name;
|
||||
StatusInfo = "等待信号...";
|
||||
|
||||
// 【修正 1】直接订阅单例服务
|
||||
// 不需要判断 null,因为 Instance 是静态初始化的,永远存在
|
||||
StreamReceiverService.Instance.OnFrameReceived += OnGlobalFrameReceived;
|
||||
}
|
||||
|
||||
// --- 事件回调 (后台线程) ---
|
||||
private void OnGlobalFrameReceived(string cameraId, byte[] jpgData)
|
||||
{
|
||||
// 1. 过滤:不是我的画面,直接忽略
|
||||
if (cameraId != _boundCameraId) return;
|
||||
|
||||
// 2. 解码:耗时操作在后台完成
|
||||
var bitmap = BitmapHelper.ToBitmapImage(jpgData);
|
||||
if (bitmap == null) return;
|
||||
|
||||
// 3. 【修正 2】恢复 UI 更新逻辑
|
||||
// 必须使用 Dispatcher,因为 VideoSource 绑定在界面上,只能在主线程修改
|
||||
Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
get => _displayImage;
|
||||
set => SetProperty(ref _displayImage, value);
|
||||
}
|
||||
VideoSource = bitmap;
|
||||
|
||||
private string _cameraName;
|
||||
public string CameraName
|
||||
{
|
||||
get => _cameraName;
|
||||
set => SetProperty(ref _cameraName, value);
|
||||
}
|
||||
// 更新状态信息 (例如显示当前时间和数据大小)
|
||||
StatusInfo = $"{DateTime.Now:HH:mm:ss} | {jpgData.Length / 1024} KB";
|
||||
});
|
||||
}
|
||||
|
||||
private string _statusInfo;
|
||||
public string StatusInfo
|
||||
{
|
||||
get => _statusInfo;
|
||||
set => SetProperty(ref _statusInfo, value);
|
||||
}
|
||||
// --- 资源清理 ---
|
||||
public void Unload()
|
||||
{
|
||||
// 【修正 3】从单例服务取消订阅
|
||||
// 这一步至关重要,否则切换页面时会内存泄漏
|
||||
StreamReceiverService.Instance.OnFrameReceived -= OnGlobalFrameReceived;
|
||||
|
||||
private bool _isConnected;
|
||||
public bool IsConnected
|
||||
{
|
||||
get => _isConnected;
|
||||
set => SetProperty(ref _isConnected, value);
|
||||
}
|
||||
|
||||
// --- 构造函数 ---
|
||||
public VideoTileViewModel(string ip, int port, string name)
|
||||
{
|
||||
CameraName = name;
|
||||
StatusInfo = "连接中...";
|
||||
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
private void HandleNewFrame(VideoPayload payload)
|
||||
{
|
||||
// 必须回到 UI 线程更新 ImageSource
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
// 1. 更新图片
|
||||
byte[] data = payload.TargetImageBytes ?? payload.OriginalImageBytes;
|
||||
if (data != null && data.Length > 0)
|
||||
{
|
||||
DisplayImage = ByteToBitmap(data);
|
||||
}
|
||||
|
||||
// 2. 更新状态文字
|
||||
StatusInfo = $"{payload.CaptureTime:HH:mm:ss} | {data?.Length / 1024} KB";
|
||||
});
|
||||
}
|
||||
|
||||
// 简单的 Bytes 转 BitmapImage (生产环境建议优化为 WriteableBitmap)
|
||||
private BitmapImage ByteToBitmap(byte[] bytes)
|
||||
{
|
||||
var bitmap = new BitmapImage();
|
||||
using (var stream = new MemoryStream(bytes))
|
||||
{
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = stream;
|
||||
bitmap.EndInit();
|
||||
}
|
||||
bitmap.Freeze(); // 必须冻结才能跨线程
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IsConnected = false;
|
||||
}
|
||||
// 清空图片引用,帮助 GC 回收内存
|
||||
VideoSource = null;
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,6 @@ namespace SHH.CameraDashboard
|
||||
//VideoTiles.Add(new VideoTileViewModel("1004", "仓库通道"));
|
||||
}
|
||||
|
||||
public void AddCamera(string ip, int port, string name)
|
||||
{
|
||||
var tile = new VideoTileViewModel(ip, port, name);
|
||||
VideoTiles.Add(tile);
|
||||
}
|
||||
|
||||
private void ExecuteSetLayout(string layoutType)
|
||||
{
|
||||
switch (layoutType)
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
||||
<ProjectReference Include="..\SHH.NetMQ\SHH.NetMQ.csproj" />
|
||||
<ProjectReference Include="..\SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
37
SHH.CameraDashboard/Services/ProcessDashboardLogger.cs
Normal file
37
SHH.CameraDashboard/Services/ProcessDashboardLogger.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using SHH.ProcessLaunchers;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动器日志适配器
|
||||
/// <para>将底层 ProcessManager 的日志桥接到 System.Diagnostics.Debug 和 MessageBox</para>
|
||||
/// </summary>
|
||||
public class ProcessDashboardLogger : ILauncherLogger
|
||||
{
|
||||
public void LogConsole(string processId, string message, bool isError)
|
||||
{
|
||||
// 将子进程的控制台输出转发到 VS 的输出窗口,方便调试
|
||||
string prefix = isError ? "[STDERR]" : "[STDOUT]";
|
||||
Debug.WriteLine($"{prefix} <{processId}>: {message}");
|
||||
}
|
||||
|
||||
public void LogLifecycle(string processId, LogAction action, LogTrigger trigger, string reason, object payload = null)
|
||||
{
|
||||
string msg = $"[ProcessManager] {processId} - {action}: {reason}";
|
||||
Debug.WriteLine(msg);
|
||||
|
||||
// 如果是严重错误(如资源超限被杀),弹窗提醒
|
||||
if (trigger == LogTrigger.ResourceGuard && action == LogAction.Restart)
|
||||
{
|
||||
// 注意:确保在 UI 线程弹窗
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
MessageBox.Show($"进程 [{processId}] 资源异常!\n原因:{reason}\n系统已执行自动重启。",
|
||||
"资源管控警报", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user