diff --git a/SHH.CameraDashboard/App.xaml b/SHH.CameraDashboard/App.xaml
index 6998269..2957226 100644
--- a/SHH.CameraDashboard/App.xaml
+++ b/SHH.CameraDashboard/App.xaml
@@ -1,25 +1,22 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App.xaml.cs b/SHH.CameraDashboard/App.xaml.cs
index f60e5be..613b685 100644
--- a/SHH.CameraDashboard/App.xaml.cs
+++ b/SHH.CameraDashboard/App.xaml.cs
@@ -1,5 +1,4 @@
-using System.Configuration;
-using System.Data;
+using System.Collections.ObjectModel;
using System.Windows;
namespace SHH.CameraDashboard
@@ -9,6 +8,35 @@ namespace SHH.CameraDashboard
///
public partial class App : Application
{
- }
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
-}
+ // 1. 【核心代码】程序启动时,异步读取配置文件
+ var savedNodes = await LocalStorageService.LoadAsync>(AppPaths.ServiceNodesConfig);
+ if (savedNodes != null)
+ {
+ foreach (var node in savedNodes)
+ AppGlobal.ServiceNodes.Add(node);
+ }
+
+ // 3. 启动主窗口
+ // 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留,
+ // 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。
+ var mainWin = new MainWindow();
+ mainWin.Show();
+ }
+
+ ///
+ /// 全局统一退出入口
+ ///
+ public static void ShutdownApp()
+ {
+ // 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接)
+ // 2. 保存用户配置
+ // 3. 彻底退出
+ Current.Shutdown();
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App/AppGlobal.cs b/SHH.CameraDashboard/App/AppGlobal.cs
new file mode 100644
index 0000000..94d491d
--- /dev/null
+++ b/SHH.CameraDashboard/App/AppGlobal.cs
@@ -0,0 +1,113 @@
+using System.Collections.ObjectModel;
+
+namespace SHH.CameraDashboard
+{
+ ///
+ /// 应用程序全局状态和事件总线。
+ /// 此类作为一个静态的中央枢纽,用于在应用程序的不同部分之间共享数据和通信。
+ ///
+ public static class AppGlobal
+ {
+ #region --- 全局数据存储 ---
+
+ ///
+ /// 获取一个可观察的集合,用于存储和显示所有已配置的服务节点。
+ /// 由于使用了 ,当集合内容发生变化时,UI(如 ListView)会自动更新。
+ ///
+ public static ObservableCollection ServiceNodes { get; }
+ = new ObservableCollection();
+
+ ///
+ /// 获取或设置当前正在使用的服务节点。
+ /// 当用户从列表中选择一个节点时,应更新此属性。
+ ///
+ public static ServiceNodeModel? UseServiceNode { get; set; }
+
+ #endregion
+
+ #region --- 全局事件总线 ---
+
+ #region CameraAdd
+
+ ///
+ /// 当应用程序的任何部分请求添加一个新摄像头时发生。
+ ///
+ public static event Action? OnRequestAddCamera;
+
+ ///
+ /// 触发 事件,以请求打开摄像头添加界面。
+ ///
+ public static void RequestAdd()
+ => OnRequestAddCamera?.Invoke();
+
+ #endregion
+
+ #region CameraEdit
+
+ ///
+ /// 当应用程序的任何部分请求编辑一个摄像头时发生。
+ /// 事件处理程序将接收到要编辑的 实例。
+ ///
+ public static event Action? OnRequestEditCamera;
+
+ ///
+ /// 触发 事件,以请求打开摄像头编辑界面。
+ ///
+ /// 要编辑的摄像头数据模型。
+ public static void RequestEdit(WebApiCameraModel camera)
+ => OnRequestEditCamera?.Invoke(camera);
+
+ #endregion
+
+ #region CameraDelete
+
+ ///
+ /// 当应用程序的任何部分请求删除一个摄像头时发生。
+ /// 事件处理程序将接收到要删除的 实例。
+ ///
+ public static event Action? OnRequestDeleteCamera;
+
+ ///
+ /// 触发 事件,以请求删除指定的摄像头。
+ ///
+ /// 要删除的摄像头数据模型。
+ public static void RequestDelete(WebApiCameraModel camera)
+ => OnRequestDeleteCamera?.Invoke(camera);
+
+ #endregion
+
+ #region CameraRefreshList
+
+ ///
+ /// 当应用程序的任何部分请求刷新摄像头列表时发生。
+ ///
+ public static event Action? OnRefreshListRequest;
+
+ ///
+ /// 触发 事件,以请求刷新摄像头列表数据。
+ ///
+ public static void RequestRefresh()
+ => OnRefreshListRequest?.Invoke();
+
+ #endregion
+
+ // [新增] 请求云台控制事件
+ public static event Action? OnRequestPtzCamera;
+
+ // [新增] 触发方法
+ public static void RequestPtz(WebApiCameraModel camera) => OnRequestPtzCamera?.Invoke(camera);
+
+ // 图像处理
+ public static event Action? OnRequestImgProc;
+
+ public static void RequestImgProc(WebApiCameraModel camera) => OnRequestImgProc?.Invoke(camera);
+
+ // 1. 定义事件委托:当 ViewModel 请求订阅时触发
+ public static event Action? OnRequestSubscription;
+
+ // 2. 定义触发方法:供 CameraItemTopViewModel 调用
+ public static void RequestSubscription(WebApiCameraModel camera) => OnRequestSubscription?.Invoke(camera);
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App/AppGlobalData.cs b/SHH.CameraDashboard/App/AppGlobalData.cs
deleted file mode 100644
index ca201f8..0000000
--- a/SHH.CameraDashboard/App/AppGlobalData.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace SHH.CameraDashboard
-{
- // 2. 全局配置存储
- public static class AppGlobalData
- {
- public static List ActiveServerList { get; private set; } = new List();
-
- public static void SaveConfig(IEnumerable nodes)
- {
- ActiveServerList.Clear();
- foreach (var node in nodes)
- {
- ActiveServerList.Add(new ServerNode { Ip = node.Ip, Port = node.Port });
- }
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App/ThemeManager.cs b/SHH.CameraDashboard/App/ThemeManager.cs
deleted file mode 100644
index b0c24ef..0000000
--- a/SHH.CameraDashboard/App/ThemeManager.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-
-namespace SHH.CameraDashboard
-{
- public enum ThemeType { Dark, Light }
-
- public static class ThemeManager
- {
- public static void ChangeTheme(ThemeType theme)
- {
- var appResources = Application.Current.Resources;
-
- // 1. 找到旧的颜色字典并移除
- // 我们通过检查 Source 路径来识别它
- ResourceDictionary oldDict = null;
- foreach (var dict in appResources.MergedDictionaries)
- {
- // 只要路径里包含 "Colors." 说明它是我们的皮肤文件
- if (dict.Source != null && dict.Source.OriginalString.Contains("Themes/Colors."))
- {
- oldDict = dict;
- break;
- }
- }
-
- if (oldDict != null)
- {
- appResources.MergedDictionaries.Remove(oldDict);
- }
-
- // 2. 加载新字典
- string dictName = theme switch
- {
- ThemeType.Light => "/Style/Themes/Colors.Light.xaml",
- ThemeType.Dark => "/Style/Themes/Colors.Dark.xaml",
- _ => "/Style/Themes/Colors.Dark.xaml" // 默认
- };
-
- var newDict = new ResourceDictionary
- {
- Source = new Uri(dictName, UriKind.Relative)
- };
-
- // 3. 添加到集合中 (建议加在最前面,或者根据索引位置)
- appResources.MergedDictionaries.Add(newDict);
- }
- }
-}
diff --git a/SHH.CameraDashboard/App/WizardControl.xaml.cs b/SHH.CameraDashboard/App/WizardControl.xaml.cs
deleted file mode 100644
index b96bcb8..0000000
--- a/SHH.CameraDashboard/App/WizardControl.xaml.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using SHH.CameraDashboard.Services;
-using System.Collections.ObjectModel;
-using System.Windows;
-using System.Windows.Controls;
-using System.Xml.Linq;
-
-namespace SHH.CameraDashboard
-{
- public partial class WizardControl : UserControl
- {
- private const string ServerConfigFile = "servers.config.json";
-
- // 绑定源
- public ObservableCollection Nodes { get; set; } = new ObservableCollection();
-
- // 定义关闭事件,通知主窗体关闭模态框
- public event EventHandler RequestClose;
-
- public WizardControl()
- {
- InitializeComponent();
-
- // 使用泛型加载,指定返回类型为 ObservableCollection
- // 这里的 LoadServers 逻辑变为了通用的 Load
- Nodes = StorageService.Load>(ServerConfigFile);
-
- NodeList.ItemsSource = Nodes;
- }
-
- private void AddNode_Click(object sender, RoutedEventArgs e)
- {
- Nodes.Add(new ServerNode());
- }
-
- private void DeleteNode_Click(object sender, RoutedEventArgs e)
- {
- if (sender is Button btn && btn.DataContext is ServerNode node)
- {
- Nodes.Remove(node);
- }
- }
-
- private async void Check_Click(object sender, RoutedEventArgs e)
- {
- // 禁用按钮防止重复点击
- var btn = sender as Button;
- if (btn != null) btn.IsEnabled = false;
-
- foreach (var node in Nodes)
- {
- node.SetResult(false, "⏳ 检测中...");
-
- // 构造 URL
- string url = $"http://{node.Ip}:{node.Port}/api/Cameras";
- // 或者是 /api/health,看你的后端提供什么接口
-
- try
- {
- // 【修改】这里调用封装好的 HttpService
- // 我们使用 TestConnectionAsync,它内部会触发 OnApiLog 事件记录日志
- bool isConnected = await HttpService.TestConnectionAsync(url);
-
- if (isConnected)
- node.SetResult(true, "✅ 连接成功");
- else
- node.SetResult(false, "❌ 状态码异常");
- }
- catch (Exception ex)
- {
- // 异常也被 HttpService 记录了,这里只负责更新 UI 状态
- node.SetResult(false, "❌ 无法连接");
- }
- }
-
- if (btn != null) btn.IsEnabled = true;
- }
-
- private void Apply_Click(object sender, RoutedEventArgs e)
- {
- // 将当前的 Nodes 集合保存到指定文件
- StorageService.Save(Nodes, ServerConfigFile);
-
- // 同步到全局单例内存中
- AppGlobalData.SaveConfig(Nodes);
-
- RequestClose?.Invoke(this, EventArgs.Empty);
- }
-
- private void Cancel_Click(object sender, RoutedEventArgs e)
- {
- RequestClose?.Invoke(this, EventArgs.Empty);
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/BottomDockControl.xaml b/SHH.CameraDashboard/Controls/BottomDockControl.xaml
deleted file mode 100644
index f8ef694..0000000
--- a/SHH.CameraDashboard/Controls/BottomDockControl.xaml
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs b/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs
deleted file mode 100644
index bef341b..0000000
--- a/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Media;
-
-namespace SHH.CameraDashboard
-{
- public partial class BottomDockControl : UserControl
- {
- private bool _isExpanded = false;
-
- public BottomDockControl()
- {
- InitializeComponent();
-
- // 核心修正:这里必须使用 WebApiDiag,因为它对应你 XAML 里的 x:Name
- if (this.WebApiDiag != null)
- {
- // 订阅 DiagnosticControl 抛出的关闭事件
- this.WebApiDiag.RequestCollapse += (s, e) =>
- {
- // 当子页面点击“关闭”按钮时,执行收回面板的方法
- HideExpandedPanel();
- };
- }
- }
-
- // 逻辑:隐藏上方的大面板
- private void HideExpandedPanel()
- {
- ExpandedPanel.Visibility = Visibility.Collapsed;
- ArrowIcon.Text = "▲"; // 箭头恢复向上
- }
-
- // 接收全局日志,分发给内部控件,并更新状态栏摘要
- public void PushLog(ApiLogEntry log)
- {
- // 1. 推送给内部的诊断控件 (详细列表)
- WebApiDiag.PushLog(log);
-
- // 2. 更新底部状态栏 (摘要)
- string statusIcon = log.IsSuccess ? "✅" : "❌";
- LatestLogText.Text = $"{statusIcon} [{log.Time:HH:mm:ss}] {log.Method} {log.Url} ({log.StatusCode})";
- LatencyText.Text = $"{log.DurationMs}ms";
-
- // 如果失败,可以将状态栏背景变红一下(可选)
- if (!log.IsSuccess)
- {
- // 这里简单处理,如果想要复杂的动画可以使用 Storyboard
- LatestLogText.Foreground = new SolidColorBrush(Colors.Yellow);
- }
- else
- {
- LatestLogText.Foreground = Brushes.White;
- }
- }
-
- private void TogglePanel_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
- {
- _isExpanded = !_isExpanded;
- ExpandedPanel.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed;
- ArrowIcon.Text = _isExpanded ? "▼" : "▲";
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/CameraListControl.xaml b/SHH.CameraDashboard/Controls/CameraListControl.xaml
deleted file mode 100644
index 94f8ddc..0000000
--- a/SHH.CameraDashboard/Controls/CameraListControl.xaml
+++ /dev/null
@@ -1,247 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs b/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs
deleted file mode 100644
index bb10155..0000000
--- a/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System.Collections.ObjectModel;
-using System.Windows;
-using System.Windows.Controls;
-
-namespace SHH.CameraDashboard
-{
- // 简单的包装类,用于 ComboBox 显示
- public class ServerOption
- {
- public string Name { get; set; }
-
- public string Ip { get; set; }
- public int Port { get; set; }
- // 修改显示属性:如果有名字就显示 名字(IP),没有就显示 IP
- public string DisplayText => string.IsNullOrEmpty(Name) ? $"{Ip}:{Port}" : $"{Name} ({Ip})";
- }
-
- public partial class CameraListControl : UserControl
- {
- // 所有摄像头数据(原始全集)
- private List _allCameras = new List();
-
- // 绑定到界面的数据(过滤后)
- public ObservableCollection DisplayCameras { get; set; } = new ObservableCollection();
-
- public CameraListControl()
- {
- InitializeComponent();
- CameraList.ItemsSource = DisplayCameras;
-
- // 初始加载服务器列表
- ReloadServers();
- }
-
- ///
- /// 公开方法:供主窗体在向导结束后调用,刷新下拉框
- ///
- public void ReloadServers()
- {
- var savedSelection = ServerCombo.SelectedItem as ServerOption;
-
- // 1. 转换全局配置到 ComboBox 选项
- var options = AppGlobalData.ActiveServerList
- .Select(n => new ServerOption { Ip = n.Ip, Port = n.Port })
- .ToList();
-
- ServerCombo.ItemsSource = options;
-
- // 2. 尝试恢复之前的选中项,或者默认选中第一个
- if (options.Count > 0)
- {
- if (savedSelection != null)
- {
- var match = options.FirstOrDefault(o => o.Ip == savedSelection.Ip && o.Port == savedSelection.Port);
- ServerCombo.SelectedItem = match ?? options[0];
- }
- else
- {
- ServerCombo.SelectedItem = options[0];
- }
- }
- else
- {
- // 如果没有配置,清空列表
- _allCameras.Clear();
- DisplayCameras.Clear();
- UpdateEmptyState();
- }
- }
-
- private async void ServerCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (ServerCombo.SelectedItem is ServerOption server)
- {
- // 切换服务器,加载数据
- LoadingMask.Visibility = Visibility.Visible;
- EmptyText.Visibility = Visibility.Collapsed;
-
- string url = $"http://{server.Ip}:{server.Port}/api/Cameras";
-
- try
- {
- // 使用 HttpService 获取列表
- var list = await HttpService.GetAsync>(url);
-
- _allCameras = list ?? new List();
-
- // 应用当前的搜索词
- FilterList(SearchBox.Text);
- }
- catch
- {
- // 失败清空
- _allCameras.Clear();
- DisplayCameras.Clear();
- }
- finally
- {
- LoadingMask.Visibility = Visibility.Collapsed;
- UpdateEmptyState();
- }
- }
- }
-
- private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
- {
- FilterList(SearchBox.Text);
- }
-
- private void FilterList(string keyword)
- {
- DisplayCameras.Clear();
-
- if (string.IsNullOrWhiteSpace(keyword))
- {
- foreach (var c in _allCameras) DisplayCameras.Add(c);
- }
- else
- {
- var lowerKw = keyword.ToLower();
- var results = _allCameras.Where(c =>
- (c.Name != null && c.Name.ToLower().Contains(lowerKw)) ||
- (c.IpAddress != null && c.IpAddress.Contains(lowerKw))
- );
-
- foreach (var c in results) DisplayCameras.Add(c);
- }
- }
-
- private void UpdateEmptyState()
- {
- EmptyText.Visibility = DisplayCameras.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- }
-
- // 1. 定义一个事件:当设备被选中时触发
- public event System.Action OnDeviceSelected;
-
- // 2. 实现 ListView 的选中事件处理
- private void CameraList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (CameraList.SelectedItem is CameraInfo selectedCam)
- {
- // 触发事件,把选中的相机传出去
- OnDeviceSelected?.Invoke(selectedCam);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml b/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml
deleted file mode 100644
index 5abdfdc..0000000
--- a/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml
+++ /dev/null
@@ -1,199 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs b/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs
deleted file mode 100644
index adec251..0000000
--- a/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Media;
-
-namespace SHH.CameraDashboard
-{
- public partial class DeviceHomeControl : UserControl
- {
- private CameraInfo _currentDevice;
-
- public DeviceHomeControl()
- {
- InitializeComponent();
- }
-
- // 供外部调用,切换显示的设备
- public void UpdateDevice(CameraInfo device)
- {
- _currentDevice = device;
-
- if (device == null)
- {
- EmptyView.Visibility = Visibility.Visible;
- DetailView.Visibility = Visibility.Collapsed;
- return;
- }
-
- // 切换到详情视图
- EmptyView.Visibility = Visibility.Collapsed;
- DetailView.Visibility = Visibility.Visible;
-
- // 绑定数据到界面控件
- TxtName.Text = device.DisplayName;
- TxtIp.Text = device.IpAddress;
- TxtStreamUrl.Text = $"rtsp://{device.IpAddress}:554/live/main";
-
- // 根据状态切换颜色和文字
- if (device.Status == "Playing" || device.Status == "Connected")
- {
- TxtStatus.Text = "在线运行";
- TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(78, 201, 176)); // Green
- BadgeStatus.Background = new SolidColorBrush(Color.FromArgb(30, 78, 201, 176));
- }
- else
- {
- TxtStatus.Text = "离线/断开";
- TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(244, 71, 71)); // Red
- BadgeStatus.Background = new SolidColorBrush(Color.FromArgb(30, 244, 71, 71));
- }
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DiagnosticControl.xaml b/SHH.CameraDashboard/Controls/DiagnosticControl.xaml
deleted file mode 100644
index 90f9af8..0000000
--- a/SHH.CameraDashboard/Controls/DiagnosticControl.xaml
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs b/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs
deleted file mode 100644
index f889642..0000000
--- a/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System.Windows;
-using System.Windows.Controls;
-
-namespace SHH.CameraDashboard.Controls
-{
- public partial class DiagnosticControl : UserControl
- {
- public event EventHandler RequestCollapse;
- private ApiLogEntry _selectedItem;
- private bool _isInitialized = false;
-
- public DiagnosticControl()
- {
- InitializeComponent();
- _isInitialized = true;
- }
-
- // 外部(MainWindow或BottomDock)调用此方法推送日志
- public void PushLog(ApiLogEntry entry)
- {
- this.Dispatcher.Invoke(() => {
- // 确保 XAML 中的 ListView 名称是 LogList
- LogList.Items.Insert(0, entry);
-
- // 限制日志数量,防止内存溢出(可选)
- if (LogList.Items.Count > 100) LogList.Items.RemoveAt(100);
- });
- }
-
- private void Close_Click(object sender, RoutedEventArgs e)
- {
- RequestCollapse?.Invoke(this, EventArgs.Empty);
- }
-
- private void LogList_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (!_isInitialized) return;
- _selectedItem = LogList.SelectedItem as ApiLogEntry;
- UpdateDetailView();
- }
-
- private void Tab_Checked(object sender, RoutedEventArgs e)
- {
- if (!_isInitialized) return;
- UpdateDetailView();
- }
-
- private void UpdateDetailView()
- {
- // 防御性编程:检查所有可能为 null 的 UI 元素
- if (TxtEmpty == null || TxtContent == null || BtnReq == null) return;
-
- if (_selectedItem == null)
- {
- TxtEmpty.Visibility = Visibility.Visible;
- TxtContent.Visibility = Visibility.Collapsed; // 隐藏编辑框更美观
- TxtContent.Text = string.Empty;
- return;
- }
-
- TxtEmpty.Visibility = Visibility.Collapsed;
- TxtContent.Visibility = Visibility.Visible;
-
- // 根据切换按钮显示对应内容
- TxtContent.Text = (BtnReq.IsChecked == true)
- ? _selectedItem.RequestBody
- : _selectedItem.ResponseBody;
- }
-
- private void Clear_Click(object sender, RoutedEventArgs e)
- {
- // 具体的清空逻辑
- LogList.Items.Clear();
- _selectedItem = null;
- UpdateDetailView();
- }
-
- private void Copy_Click(object sender, RoutedEventArgs e)
- {
- // 如果没选中或内容本身为空,不执行复制
- if (_selectedItem == null || TxtContent == null || string.IsNullOrEmpty(TxtContent.Text)) return;
-
- // 使用之前定义的带重试机制的 Helper
- ClipboardHelper.SetText(TxtContent.Text);
- }
-
- // 右键菜单复制逻辑
- private void CopySummary_Click(object sender, RoutedEventArgs e)
- {
- if (LogList.SelectedItem is ApiLogEntry item)
- ClipboardHelper.SetText($"[{item.Time}] {item.Url} - {item.StatusCode}");
- }
-
- private void CopyRequest_Click(object sender, RoutedEventArgs e)
- {
- if (LogList.SelectedItem is ApiLogEntry item)
- ClipboardHelper.SetText(item.RequestBody ?? "");
- }
-
- private void CopyResponse_Click(object sender, RoutedEventArgs e)
- {
- if (LogList.SelectedItem is ApiLogEntry item)
- ClipboardHelper.SetText(item.ResponseBody ?? "");
- }
- }
-}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Converters/BoolToMaxScaleConverter.cs b/SHH.CameraDashboard/Converters/BoolToMaxScaleConverter.cs
new file mode 100644
index 0000000..dd36b05
--- /dev/null
+++ b/SHH.CameraDashboard/Converters/BoolToMaxScaleConverter.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+using System.Windows.Data;
+
+namespace SHH.CameraDashboard;
+
+///
+/// 布尔值转最大缩放比例转换器
+///
+public class BoolToMaxScaleConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ // 如果 AllowExpand (value) 为 true
+ if (value is bool allow && allow)
+ {
+ // 允许放大:最大支持 200% (即放大 2 倍)
+ // 你也可以改成 300 或根据业务需求调整
+ return 200.0;
+ }
+
+ // 不允许放大:最大限制在 100% (原图大小)
+ return 100.0;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Converters/InverseNullToVisibilityConverter.cs b/SHH.CameraDashboard/Converters/InverseNullToVisibilityConverter.cs
new file mode 100644
index 0000000..05c3103
--- /dev/null
+++ b/SHH.CameraDashboard/Converters/InverseNullToVisibilityConverter.cs
@@ -0,0 +1,19 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace SHH.CameraDashboard;
+
+// 2. 对象为空时显示,不为空时隐藏 (用于显示提示文字)
+public class InverseNullToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value == null ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Converters/NullToVisibilityConverter.cs b/SHH.CameraDashboard/Converters/NullToVisibilityConverter.cs
new file mode 100644
index 0000000..80b222a
--- /dev/null
+++ b/SHH.CameraDashboard/Converters/NullToVisibilityConverter.cs
@@ -0,0 +1,19 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace SHH.CameraDashboard;
+
+// 1. 对象为空时隐藏,不为空时显示
+public class NullToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value == null ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Converters/SubscriptionTypeConverter.cs b/SHH.CameraDashboard/Converters/SubscriptionTypeConverter.cs
new file mode 100644
index 0000000..c42fdf6
--- /dev/null
+++ b/SHH.CameraDashboard/Converters/SubscriptionTypeConverter.cs
@@ -0,0 +1,34 @@
+using System.Globalization;
+using System.Windows.Data;
+
+namespace SHH.CameraDashboard
+{
+ ///
+ /// 将订阅类型的 int 值 (0,1,2...) 转换为中文描述
+ ///
+ public class SubscriptionTypeConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value is int typeId)
+ {
+ return typeId switch
+ {
+ 0 => "🖥️ 本地窗口预览",
+ 1 => "📼 本地录像存储",
+ 2 => "🪟 句柄绑定显示",
+ 3 => "📡 网络转发传输",
+ 4 => "🌐 网页端推流",
+ _ => $"未知类型 ({typeId})"
+ };
+ }
+ // 如果后端传回的是字符串枚举 (兼容性),也可以尝试转换
+ return value?.ToString() ?? "未知";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Core/AppPaths.cs b/SHH.CameraDashboard/Core/AppPaths.cs
new file mode 100644
index 0000000..1c7ea82
--- /dev/null
+++ b/SHH.CameraDashboard/Core/AppPaths.cs
@@ -0,0 +1,29 @@
+using System.IO;
+
+namespace SHH.CameraDashboard
+{
+ public static class AppPaths
+ {
+ // 1. 基础目录:运行目录下的 Configs 文件夹
+ public static readonly string BaseDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configs");
+
+ // 2. 具体的配置文件路径
+ // 服务节点配置
+ public static string ServiceNodesConfig => Path.Combine(BaseDir, "service_nodes.json");
+
+ // 用户偏好设置 (预留)
+ public static string UserSettingsConfig => Path.Combine(BaseDir, "user_settings.json");
+
+ // 布局缓存 (预留)
+ public static string LayoutCache => Path.Combine(BaseDir, "layout_cache.json");
+
+ // 静态构造函数:确保目录存在
+ static AppPaths()
+ {
+ if (!Directory.Exists(BaseDir))
+ {
+ Directory.CreateDirectory(BaseDir);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Core/EnumHelper.cs b/SHH.CameraDashboard/Core/EnumHelper.cs
new file mode 100644
index 0000000..c3dcc1c
--- /dev/null
+++ b/SHH.CameraDashboard/Core/EnumHelper.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel;
+using System.Reflection;
+
+namespace SHH.CameraDashboard
+{
+ public static class EnumHelper
+ {
+ public static string GetDescription(Enum value)
+ {
+ FieldInfo fi = value.GetType().GetField(value.ToString());
+
+ DescriptionAttribute[] attributes =
+ (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
+
+ if (attributes != null && attributes.Length > 0)
+ return attributes[0].Description;
+ else
+ return value.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Core/IOverlayClosable.cs b/SHH.CameraDashboard/Core/IOverlayClosable.cs
new file mode 100644
index 0000000..2ec5d79
--- /dev/null
+++ b/SHH.CameraDashboard/Core/IOverlayClosable.cs
@@ -0,0 +1,7 @@
+namespace SHH.CameraDashboard;
+
+// 定义一个简单的接口,让子 ViewModel 具备关闭能力
+public interface IOverlayClosable
+{
+ event Action? RequestClose;
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Core/JsonHelper.cs b/SHH.CameraDashboard/Core/JsonHelper.cs
new file mode 100644
index 0000000..f2c3e4f
--- /dev/null
+++ b/SHH.CameraDashboard/Core/JsonHelper.cs
@@ -0,0 +1,109 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Serialization;
+
+namespace SHH.CameraDashboard
+{
+ ///
+ /// JSON 序列化与反序列化帮助类
+ /// 职责:
+ /// 1. 提供全局统一的 JSON 转换配置(如驼峰命名、日期格式)。
+ /// 2. 封装常见的序列化和反序列化操作。
+ /// 3. 增加对 null 输入和无效 JSON 的健壮性处理。
+ ///
+ public static class JsonHelper
+ {
+ #region --- 静态配置 ---
+
+ ///
+ /// 全局共享的 JSON 序列化设置。
+ /// 静态构造函数保证其只被初始化一次。
+ ///
+ private static readonly JsonSerializerSettings _settings;
+
+ #endregion
+
+ #region --- 静态构造函数 ---
+
+ ///
+ /// 静态构造函数,用于初始化全局的 JSON 序列化设置。
+ ///
+ static JsonHelper()
+ {
+ _settings = new JsonSerializerSettings
+ {
+ // 1. 命名策略:将 C# 的 PascalCase 属性名序列化为 JSON 的 camelCase。
+ // 这是与 JavaScript/TypeScript 前端交互的标准做法。
+ ContractResolver = new CamelCasePropertyNamesContractResolver(),
+
+ // 2. 日期格式:统一使用 "yyyy-MM-dd HH:mm:ss" 格式,避免时区和格式差异导致的问题。
+ DateFormatString = "yyyy-MM-dd HH:mm:ss",
+
+ // 3. Null 值处理:在序列化时忽略值为 null 的属性。
+ // 这可以显著减小 JSON 字符串的大小,并使生成的 JSON 更干净。
+ // 例如,`{ Name = "Alice", Age = null }` 会被序列化为 `{"name":"Alice"}`。
+ NullValueHandling = NullValueHandling.Ignore
+ };
+
+ // 4. 枚举转换:将枚举值序列化为其字符串表示,而不是数字。
+ // 例如,`LogLevel.Info` 会被序列化为 `"info"`,而不是 `1`。
+ _settings.Converters.Add(new StringEnumConverter());
+ }
+
+ #endregion
+
+ #region --- 公共方法 ---
+
+ ///
+ /// 将对象序列化为 JSON 字符串。
+ ///
+ /// 要序列化的对象。
+ /// 序列化后的 JSON 字符串。如果输入为 null,则返回空字符串。
+ public static string Serialize(object obj)
+ {
+ // [健壮性] 如果输入对象为 null,返回空字符串,而不是 "null"。
+ // 这可以防止在创建 HTTP 请求内容时出现意外行为。
+ if (obj == null)
+ {
+ return string.Empty;
+ }
+
+ return JsonConvert.SerializeObject(obj, _settings);
+ }
+
+ ///
+ /// 将 JSON 字符串反序列化为指定类型的对象。
+ ///
+ /// 目标对象的类型(必须是引用类型)。
+ /// 要反序列化的 JSON 字符串。
+ /// 成功时返回反序列化后的对象;失败或输入无效时返回 null。
+ public static T? Deserialize(string json) where T : class
+ {
+ // [健壮性] 检查输入是否为 null、空字符串或仅包含空白字符。
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ // [健壮性] 处理 JSON 字符串为 "null" 的特殊情况。
+ if (json.Trim() == "null")
+ {
+ return null;
+ }
+
+ try
+ {
+ // 尝试使用预配置的设置进行反序列化。
+ return JsonConvert.DeserializeObject(json, _settings);
+ }
+ catch (JsonException)
+ {
+ // [健壮性] 如果 JSON 格式无效,捕获异常并返回 null。
+ // 这可以防止程序因一个格式错误的 JSON 字符串而崩溃。
+ return null;
+ }
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Core/RelayCommand.cs b/SHH.CameraDashboard/Core/RelayCommand.cs
new file mode 100644
index 0000000..81e4421
--- /dev/null
+++ b/SHH.CameraDashboard/Core/RelayCommand.cs
@@ -0,0 +1,65 @@
+using System.Windows.Input;
+
+namespace SHH.CameraDashboard;
+
+// ===========================================================================
+// 1. 新增:非泛型 RelayCommand (支持 new RelayCommand(Method, Check))
+// ===========================================================================
+public class RelayCommand : ICommand
+{
+ private readonly Action