From de3adf033986657d59ba3c9b13115886f3890000 Mon Sep 17 00:00:00 2001
From: twice109 <3518499@qq.com>
Date: Tue, 30 Dec 2025 10:53:02 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=91=84=E5=83=8F=E5=A4=B4?=
=?UTF-8?q?=E4=B8=AD=E6=8E=A7=E5=8F=B0=E9=A1=B9=E7=9B=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Ayay.Solution.sln | 6 +
SHH.CameraDashboard/App.xaml | 25 ++
SHH.CameraDashboard/App.xaml.cs | 14 +
SHH.CameraDashboard/App/AppGlobalData.cs | 17 +
SHH.CameraDashboard/App/ClipboardHelper.cs | 40 ++
SHH.CameraDashboard/App/ThemeManager.cs | 53 +++
SHH.CameraDashboard/App/WizardControl.xaml | 172 ++++++++
SHH.CameraDashboard/App/WizardControl.xaml.cs | 94 +++++
SHH.CameraDashboard/AssemblyInfo.cs | 10 +
.../Controls/BottomDockControl.xaml | 67 ++++
.../Controls/BottomDockControl.xaml.cs | 64 +++
.../Controls/CameraListControl.xaml | 247 ++++++++++++
.../Controls/CameraListControl.xaml.cs | 148 +++++++
.../Controls/DeviceHomeControl.xaml | 199 +++++++++
.../Controls/DeviceHomeControl.xaml.cs | 52 +++
.../Controls/DiagnosticControl.xaml | 83 ++++
.../Controls/DiagnosticControl.xaml.cs | 106 +++++
SHH.CameraDashboard/MainWindow.xaml | 244 +++++++++++
SHH.CameraDashboard/MainWindow.xaml.cs | 107 +++++
SHH.CameraDashboard/Models/ApiLogEntry.cs | 25 ++
SHH.CameraDashboard/Models/CameraInfo.cs | 31 ++
SHH.CameraDashboard/Models/DeviceBrand.cs | 63 +++
SHH.CameraDashboard/Models/ServerNode.cs | 87 ++++
.../Models/VideoSourceStatus.cs | 60 +++
.../SHH.CameraDashboard.csproj | 38 ++
SHH.CameraDashboard/Services/HttpService.cs | 119 ++++++
.../Services/StorageService.cs | 52 +++
.../Style/Themes/Colors.Dark.xaml | 56 +++
.../Style/Themes/Colors.Light.xaml | 56 +++
SHH.CameraDashboard/Style/Themes/Sizes.xaml | 23 ++
SHH.CameraDashboard/Style/Themes/Styles.xaml | 378 ++++++++++++++++++
31 files changed, 2736 insertions(+)
create mode 100644 SHH.CameraDashboard/App.xaml
create mode 100644 SHH.CameraDashboard/App.xaml.cs
create mode 100644 SHH.CameraDashboard/App/AppGlobalData.cs
create mode 100644 SHH.CameraDashboard/App/ClipboardHelper.cs
create mode 100644 SHH.CameraDashboard/App/ThemeManager.cs
create mode 100644 SHH.CameraDashboard/App/WizardControl.xaml
create mode 100644 SHH.CameraDashboard/App/WizardControl.xaml.cs
create mode 100644 SHH.CameraDashboard/AssemblyInfo.cs
create mode 100644 SHH.CameraDashboard/Controls/BottomDockControl.xaml
create mode 100644 SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs
create mode 100644 SHH.CameraDashboard/Controls/CameraListControl.xaml
create mode 100644 SHH.CameraDashboard/Controls/CameraListControl.xaml.cs
create mode 100644 SHH.CameraDashboard/Controls/DeviceHomeControl.xaml
create mode 100644 SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs
create mode 100644 SHH.CameraDashboard/Controls/DiagnosticControl.xaml
create mode 100644 SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs
create mode 100644 SHH.CameraDashboard/MainWindow.xaml
create mode 100644 SHH.CameraDashboard/MainWindow.xaml.cs
create mode 100644 SHH.CameraDashboard/Models/ApiLogEntry.cs
create mode 100644 SHH.CameraDashboard/Models/CameraInfo.cs
create mode 100644 SHH.CameraDashboard/Models/DeviceBrand.cs
create mode 100644 SHH.CameraDashboard/Models/ServerNode.cs
create mode 100644 SHH.CameraDashboard/Models/VideoSourceStatus.cs
create mode 100644 SHH.CameraDashboard/SHH.CameraDashboard.csproj
create mode 100644 SHH.CameraDashboard/Services/HttpService.cs
create mode 100644 SHH.CameraDashboard/Services/StorageService.cs
create mode 100644 SHH.CameraDashboard/Style/Themes/Colors.Dark.xaml
create mode 100644 SHH.CameraDashboard/Style/Themes/Colors.Light.xaml
create mode 100644 SHH.CameraDashboard/Style/Themes/Sizes.xaml
create mode 100644 SHH.CameraDashboard/Style/Themes/Styles.xaml
diff --git a/Ayay.Solution.sln b/Ayay.Solution.sln
index 0bedb48..e025631 100644
--- a/Ayay.Solution.sln
+++ b/Ayay.Solution.sln
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts", "SHH.Contra
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.NetMQ", "SHH.NetMQ\SHH.NetMQ.csproj", "{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraDashboard", "SHH.CameraDashboard\SHH.CameraDashboard.csproj", "{03C249D7-BCF1-404D-AD09-7AB39BA263AD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/SHH.CameraDashboard/App.xaml b/SHH.CameraDashboard/App.xaml
new file mode 100644
index 0000000..6998269
--- /dev/null
+++ b/SHH.CameraDashboard/App.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SHH.CameraDashboard/App.xaml.cs b/SHH.CameraDashboard/App.xaml.cs
new file mode 100644
index 0000000..f60e5be
--- /dev/null
+++ b/SHH.CameraDashboard/App.xaml.cs
@@ -0,0 +1,14 @@
+using System.Configuration;
+using System.Data;
+using System.Windows;
+
+namespace SHH.CameraDashboard
+{
+ ///
+ /// Interaction logic for App.xaml
+ ///
+ public partial class App : Application
+ {
+ }
+
+}
diff --git a/SHH.CameraDashboard/App/AppGlobalData.cs b/SHH.CameraDashboard/App/AppGlobalData.cs
new file mode 100644
index 0000000..ca201f8
--- /dev/null
+++ b/SHH.CameraDashboard/App/AppGlobalData.cs
@@ -0,0 +1,17 @@
+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/ClipboardHelper.cs b/SHH.CameraDashboard/App/ClipboardHelper.cs
new file mode 100644
index 0000000..ef52861
--- /dev/null
+++ b/SHH.CameraDashboard/App/ClipboardHelper.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Windows;
+
+public static class ClipboardHelper
+{
+ ///
+ /// 安全复制文本,包含冲突重试机制
+ ///
+ public static void SetText(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return;
+
+ // 最多尝试 5 次,每次间隔 100 毫秒
+ for (int i = 0; i < 5; i++)
+ {
+ try
+ {
+ Clipboard.SetText(text);
+ return; // 复制成功,退出方法
+ }
+ catch (COMException ex)
+ {
+ // 如果是剪贴板被占用错误,等待后重试
+ if ((uint)ex.ErrorCode == 0x800401D0)
+ {
+ Thread.Sleep(100);
+ continue;
+ }
+ throw; // 其他 COM 错误则抛出
+ }
+ catch (Exception)
+ {
+ if (i == 4) throw; // 最后一次尝试失败则抛出
+ Thread.Sleep(100);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App/ThemeManager.cs b/SHH.CameraDashboard/App/ThemeManager.cs
new file mode 100644
index 0000000..b0c24ef
--- /dev/null
+++ b/SHH.CameraDashboard/App/ThemeManager.cs
@@ -0,0 +1,53 @@
+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 b/SHH.CameraDashboard/App/WizardControl.xaml
new file mode 100644
index 0000000..11fb734
--- /dev/null
+++ b/SHH.CameraDashboard/App/WizardControl.xaml
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/App/WizardControl.xaml.cs b/SHH.CameraDashboard/App/WizardControl.xaml.cs
new file mode 100644
index 0000000..b96bcb8
--- /dev/null
+++ b/SHH.CameraDashboard/App/WizardControl.xaml.cs
@@ -0,0 +1,94 @@
+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/AssemblyInfo.cs b/SHH.CameraDashboard/AssemblyInfo.cs
new file mode 100644
index 0000000..b0ec827
--- /dev/null
+++ b/SHH.CameraDashboard/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/SHH.CameraDashboard/Controls/BottomDockControl.xaml b/SHH.CameraDashboard/Controls/BottomDockControl.xaml
new file mode 100644
index 0000000..f8ef694
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/BottomDockControl.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs b/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs
new file mode 100644
index 0000000..bef341b
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/BottomDockControl.xaml.cs
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 0000000..94f8ddc
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/CameraListControl.xaml
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs b/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs
new file mode 100644
index 0000000..bb10155
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/CameraListControl.xaml.cs
@@ -0,0 +1,148 @@
+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
new file mode 100644
index 0000000..5abdfdc
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs b/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs
new file mode 100644
index 0000000..adec251
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/DeviceHomeControl.xaml.cs
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..90f9af8
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/DiagnosticControl.xaml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs b/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs
new file mode 100644
index 0000000..f889642
--- /dev/null
+++ b/SHH.CameraDashboard/Controls/DiagnosticControl.xaml.cs
@@ -0,0 +1,106 @@
+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/MainWindow.xaml b/SHH.CameraDashboard/MainWindow.xaml
new file mode 100644
index 0000000..d9b1872
--- /dev/null
+++ b/SHH.CameraDashboard/MainWindow.xaml
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RTSP (TCP)
+ RTSP (UDP)
+ WebRTC
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/MainWindow.xaml.cs b/SHH.CameraDashboard/MainWindow.xaml.cs
new file mode 100644
index 0000000..358331a
--- /dev/null
+++ b/SHH.CameraDashboard/MainWindow.xaml.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Windows;
+using System.Windows.Input;
+
+namespace SHH.CameraDashboard
+{
+ public partial class MainWindow : Window
+ {
+ private bool _isDarkTheme = true;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ // 1. 【日志联动】HttpService -> 底部报警栏 (精准匹配你提供的逻辑)
+ // 确保你的 ApiLogEntry 字段已根据我之前的建议对齐
+ HttpService.OnApiLog += (log) =>
+ {
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ BottomDock.PushLog(log);
+ });
+ };
+
+ // 2. 【设备联动】侧边栏选中 -> 设备首页详情
+ Sidebar.OnDeviceSelected += (device) =>
+ {
+ // 更新右侧 主视图
+ DeviceHome.UpdateDevice(device);
+
+ // 联动:向诊断日志推送一条“选中设备”的虚拟日志(可选)
+ BottomDock.LatestLogText.Text = $"当前选中设备: {device.DisplayName} ({device.IpAddress})";
+
+ // 如果右侧配置面板开着,最好关掉它
+ RightConfigPanel.Visibility = Visibility.Collapsed;
+ };
+ }
+
+ // ============================
+ // 1. 窗口基础操作 (拖拽/最小化/关闭)
+ // ============================
+ private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton == MouseButton.Left)
+ this.DragMove();
+ }
+
+ private void OnMinimize(object sender, RoutedEventArgs e) => this.WindowState = WindowState.Minimized;
+ private void OnClose(object sender, RoutedEventArgs e) => this.Close();
+
+ // ============================
+ // 2. 界面交互逻辑
+ // ============================
+
+ // 切换侧边栏展开/收起
+ private void ToggleSidebar(object sender, RoutedEventArgs e)
+ {
+ if (Sidebar.Width > 0)
+ Sidebar.Width = 0;
+ else
+ Sidebar.Width = 250;
+ }
+
+ // 切换主题 (使用你定义的 ThemeManager)
+ private void ToggleTheme(object sender, RoutedEventArgs e)
+ {
+ _isDarkTheme = !_isDarkTheme;
+ // 确保 ThemeManager.ChangeTheme 逻辑未变动
+ ThemeManager.ChangeTheme(_isDarkTheme ? ThemeType.Dark : ThemeType.Light);
+ }
+
+ // 打开/关闭右侧配置面板
+ private void OpenRightPanel(object sender, RoutedEventArgs e) => RightConfigPanel.Visibility = Visibility.Visible;
+ private void CloseRightPanel(object sender, RoutedEventArgs e) => RightConfigPanel.Visibility = Visibility.Collapsed;
+
+ // ============================
+ // 3. 向导模态框逻辑 (完整保留)
+ // ============================
+ private void OpenWizard(object sender, RoutedEventArgs e)
+ {
+ var wizard = new WizardControl();
+
+ // 当向导请求关闭时
+ wizard.RequestClose += (s, args) =>
+ {
+ CloseModal();
+ // 关键:向导可能修改了全局服务器列表,通知侧边栏刷新
+ Sidebar.ReloadServers();
+ };
+
+ ModalContainer.Content = wizard;
+ ModalLayer.Visibility = Visibility.Visible;
+ }
+
+ private void CloseModal_Click(object sender, MouseButtonEventArgs e)
+ {
+ // 点击遮罩层背景关闭
+ CloseModal();
+ }
+
+ private void CloseModal()
+ {
+ ModalLayer.Visibility = Visibility.Collapsed;
+ ModalContainer.Content = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Models/ApiLogEntry.cs b/SHH.CameraDashboard/Models/ApiLogEntry.cs
new file mode 100644
index 0000000..fc9574a
--- /dev/null
+++ b/SHH.CameraDashboard/Models/ApiLogEntry.cs
@@ -0,0 +1,25 @@
+using System.Windows.Media;
+
+namespace SHH.CameraDashboard
+{
+ // 日志实体,用于在事件中传递详细信息
+ public class ApiLogEntry
+ {
+ public DateTime Time { get; set; } = DateTime.Now;
+ public string Method { get; set; } // GET, POST
+ public string Url { get; set; }
+ public int StatusCode { get; set; }
+ public long DurationMs { get; set; } // 耗时(毫秒)
+ public string RequestBody { get; set; } // 发送的内容
+ public string ResponseBody { get; set; } // 接收的内容
+ public string ErrorMessage { get; set; } // 异常信息
+
+ // 辅助属性:是否成功
+ public bool IsSuccess => StatusCode >= 200 && StatusCode < 300;
+
+ // 辅助属性:显示颜色
+ public Brush StatusColor => IsSuccess
+ ? new SolidColorBrush(Color.FromRgb(78, 201, 176)) // 绿色
+ : new SolidColorBrush(Color.FromRgb(244, 71, 71)); // 红色
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Models/CameraInfo.cs b/SHH.CameraDashboard/Models/CameraInfo.cs
new file mode 100644
index 0000000..f68fa08
--- /dev/null
+++ b/SHH.CameraDashboard/Models/CameraInfo.cs
@@ -0,0 +1,31 @@
+namespace SHH.CameraDashboard;
+
+public class CameraInfo
+{
+ // --- 原始 JSON 属性 ---
+ public int Id { get; set; }
+ public string Name { get; set; }
+ public string IpAddress { get; set; }
+ public DeviceBrand Brand { get; set; }
+ public string Status { get; set; } // "Playing", "Disconnected" 等
+ public bool IsPhysicalOnline { get; set; } // 物理在线 (网络)
+ public bool IsOnline { get; set; } // 业务在线 (登录)
+ public bool IsRunning { get; set; } // 正在运行 (拉流)
+ public int RealFps { get; set; }
+ public int Width { get; set; }
+ public int Height { get; set; }
+
+ // --- UI 分离状态逻辑 ---
+
+ // 状态 1: 登录状态 (在线/离线)
+ public string OnlineStatusText => (IsPhysicalOnline && IsOnline) ? "在线" : "离线";
+
+ // 状态 2: 运行状态 (运行/停止)
+ public string RunningStatusText => IsRunning ? "运行中" : "已停止";
+
+ // 品牌信息
+ public string BrandName => Brand.ToString();
+
+ public string DisplayName => string.IsNullOrEmpty(Name) ? IpAddress : Name;
+ public string MediaDetail => IsRunning && Width > 0 ? $"{Width}x{Height} | {RealFps}fps" : "无信号";
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Models/DeviceBrand.cs b/SHH.CameraDashboard/Models/DeviceBrand.cs
new file mode 100644
index 0000000..8e26fa3
--- /dev/null
+++ b/SHH.CameraDashboard/Models/DeviceBrand.cs
@@ -0,0 +1,63 @@
+namespace SHH.CameraDashboard
+{
+ ///
+ /// 视频源物理/逻辑品牌类型
+ /// 职责:用于工厂模式匹配具体的 IVideoSource 实现类,并定义基础通信协议栈
+ ///
+ public enum DeviceBrand
+ {
+ ///
+ /// 未知
+ ///
+ Unknown = 0,
+
+ ///
+ /// 海康威视 (HikVision)
+ /// 技术路径:基于海康私有 SDK (HCNetSDK.dll / PlayCtrl.dll)。
+ /// 特性:支持全功能控制(PTZ、对讲、配置、报警回传)。
+ ///
+ HikVision,
+
+ ///
+ /// 大华 (Dahua)
+ /// 技术路径:基于大华私有 SDK (dhnetsdk.dll / dhplay.dll)。
+ /// 特性:支持全功能控制,与海康私有协议不兼容。
+ ///
+ Dahua,
+
+ ///
+ /// USB 摄像头 / 虚拟摄像头
+ /// 技术路径:基于 DirectShow 或 Windows Media Foundation。
+ /// 特性:通常通过 OpenCV (VideoCapture) 或 DirectShowLib 直接读取本地硬件引用。
+ ///
+ Usb,
+
+ ///
+ /// 标准 RTSP 流媒体
+ /// 技术路径:基于标准 RTSP/RTP 协议 (RFC 2326)。
+ /// 特性:跨品牌兼容,通常使用 FFmpeg 或 GStreamer 库取流,仅支持音视频,不支持云台控制。
+ ///
+ RtspGeneral,
+
+ ///
+ /// 三恒自研 WebSocket 流
+ /// 技术路径:基于 WebSocket 传输的自定义二进制或 Base64 帧。
+ /// 特性:专用于 Web 或云端推送场景的私有流媒体格式。
+ ///
+ WebSocketShine,
+
+ ///
+ /// 本地视频文件
+ /// 技术路径:基于文件 IO 的离线解码。
+ /// 特性:常用于算法演示、回放模拟,支持 Mp4, Avi, Mkv 等容器格式。
+ ///
+ File,
+
+ ///
+ /// 未知/通用标准 (ONVIF)
+ /// 技术路径:基于标准 ONVIF WebService。
+ /// 特性:用于接入非主流厂商但符合 ONVIF 标准的设备,支持基础 PTZ。
+ ///
+ OnvifGeneral
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Models/ServerNode.cs b/SHH.CameraDashboard/Models/ServerNode.cs
new file mode 100644
index 0000000..ef95b98
--- /dev/null
+++ b/SHH.CameraDashboard/Models/ServerNode.cs
@@ -0,0 +1,87 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows.Media;
+
+namespace SHH.CameraDashboard
+{
+
+ // 1. 单个节点的数据模型
+ public class ServerNode : INotifyPropertyChanged
+ {
+ private string _name = "新节点"; // 默认名称
+ public string Name
+ {
+ get => _name;
+ set { _name = value; OnPropertyChanged(); }
+ }
+
+ private string _ip = "127.0.0.1";
+ public string Ip
+ {
+ get => _ip;
+ set
+ {
+ if (_ip != value)
+ {
+ _ip = value;
+ OnPropertyChanged(); // 通知界面更新
+ }
+ }
+ }
+
+ private int _port = 5000;
+ public int Port
+ {
+ get => _port;
+ set
+ {
+ if (_port != value)
+ {
+ _port = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
+ private string _status = "未检测";
+ public string Status
+ {
+ get => _status;
+ set
+ {
+ _status = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(StatusColor)); // 状态变了,颜色也要跟着变
+ }
+ }
+ public Brush StatusColor
+ {
+ get
+ {
+ switch (Status)
+ {
+ case "✅ 连接成功":
+ return new SolidColorBrush(Color.FromRgb(78, 201, 176)); // 绿色
+ case "❌ 状态码异常":
+ return new SolidColorBrush(Color.FromRgb(255, 140, 0)); // 橙色 (警告)
+ case "❌ 无法连接":
+ return new SolidColorBrush(Color.FromRgb(244, 71, 71)); // 红色 (故障)
+ case "⏳ 检测中...":
+ return new SolidColorBrush(Color.FromRgb(86, 156, 214)); // 蓝色
+ default:
+ return new SolidColorBrush(Color.FromRgb(153, 153, 153)); // 灰色
+ }
+ }
+ }
+
+ public void SetResult(bool success, string msg)
+ {
+ Status = msg;
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Models/VideoSourceStatus.cs b/SHH.CameraDashboard/Models/VideoSourceStatus.cs
new file mode 100644
index 0000000..8bcd6e8
--- /dev/null
+++ b/SHH.CameraDashboard/Models/VideoSourceStatus.cs
@@ -0,0 +1,60 @@
+
+namespace SHH.CameraDashboard
+{
+
+ ///
+ /// 视频源逻辑状态枚举
+ /// 描述了从配置加载到视频流稳定输出的完整生命周期
+ ///
+ public enum VideoSourceStatus
+ {
+ ///
+ /// 已断开/初始状态。
+ /// 此时资源已释放,尚未执行 Login 或 Start 操作。
+ ///
+ Disconnected,
+
+ ///
+ /// 正在尝试建立网络连接。
+ /// 此时正在进行 Socket 握手或探测设备 IP 是否可达。
+ ///
+ Connecting,
+
+ ///
+ /// 正在进行身份验证。
+ /// 连接已建立,正在提交 UserName/Password 调用 SDK 的 Login 接口。
+ ///
+ Authorizing,
+
+ ///
+ /// 已登录/待机。
+ /// 登录成功并获取到了设备元数据(Metadata),但尚未启动预览(RealPlay)。
+ /// 适用于“仅管理,不看画面”的场景。
+ ///
+ Connected,
+
+ ///
+ /// 正常取流播放中
+ ///
+ Playing,
+
+ ///
+ /// 正在取流/正常运行中。
+ /// 预览句柄已开启,取流回调函数正在持续接收数据帧并进行解码。
+ ///
+ Streaming,
+
+ ///
+ /// 自动重连中。
+ /// 检测到网络抖动或心跳丢失,SDK 正在尝试内部恢复,此时视频流可能处于停滞状态。
+ ///
+ Reconnecting,
+
+ ///
+ /// 故障/异常状态。
+ /// 发生了不可恢复的错误(如密码错误、最大连接数限制、设备强制离线)。
+ /// 进入此状态通常需要人工干预或调用 Stop 后重新 Start。
+ ///
+ Faulted
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/SHH.CameraDashboard.csproj b/SHH.CameraDashboard/SHH.CameraDashboard.csproj
new file mode 100644
index 0000000..082b9ea
--- /dev/null
+++ b/SHH.CameraDashboard/SHH.CameraDashboard.csproj
@@ -0,0 +1,38 @@
+
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MSBuild:Compile
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SHH.CameraDashboard/Services/HttpService.cs b/SHH.CameraDashboard/Services/HttpService.cs
new file mode 100644
index 0000000..f7bdaf7
--- /dev/null
+++ b/SHH.CameraDashboard/Services/HttpService.cs
@@ -0,0 +1,119 @@
+using Newtonsoft.Json;
+using System.Diagnostics;
+using System.Net.Http;
+using System.Text;
+
+namespace SHH.CameraDashboard
+{
+ public static class HttpService
+ {
+ // 单例 HttpClient,避免端口耗尽
+ private static readonly HttpClient _client;
+
+ // 【关键】日志事件,UI层订阅这个事件来显示日志
+ public static event Action OnApiLog;
+
+ static HttpService()
+ {
+ _client = new HttpClient();
+ // 设置一个合理的超时,避免界面卡死
+ _client.Timeout = TimeSpan.FromSeconds(5);
+ }
+
+ ///
+ /// 泛型 GET 方法
+ ///
+ public static async Task GetAsync(string url)
+ {
+ return await ExecuteRequestAsync(new HttpRequestMessage(HttpMethod.Get, url));
+ }
+
+ ///
+ /// 泛型 POST 方法
+ ///
+ public static async Task PostAsync(string url, object data)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, url);
+ string json = JsonConvert.SerializeObject(data);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ return await ExecuteRequestAsync(request, json);
+ }
+
+ ///
+ /// 专门用于【连通性测试】的方法 (不关心返回值内容,只关心通不通)
+ ///
+ public static async Task TestConnectionAsync(string url)
+ {
+ // 复用核心逻辑,但泛型传 string (忽略结果) 或 object
+ try
+ {
+ await ExecuteRequestAsync(new HttpRequestMessage(HttpMethod.Get, url));
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ // --- 核心执行逻辑 ---
+ private static async Task ExecuteRequestAsync(HttpRequestMessage request, string requestBody = "")
+ {
+ // 1. 准备日志对象
+ var log = new ApiLogEntry
+ {
+ Method = request.Method.ToString(),
+ Url = request.RequestUri.ToString(),
+ RequestBody = requestBody
+ };
+
+ var sw = Stopwatch.StartNew(); // 开始计时
+
+ try
+ {
+ // 2. 发起网络请求
+ var response = await _client.SendAsync(request);
+
+ sw.Stop(); // 停止计时
+ log.DurationMs = sw.ElapsedMilliseconds;
+ log.StatusCode = (int)response.StatusCode;
+
+ // 3. 读取响应内容
+ string content = await response.Content.ReadAsStringAsync();
+ log.ResponseBody = content;
+
+ if (response.IsSuccessStatusCode)
+ {
+ // 如果 T 是 string,直接返回内容,不反序列化
+ if (typeof(T) == typeof(string))
+ return (T)(object)content;
+
+ // 反序列化 JSON
+ return JsonConvert.DeserializeObject(content);
+ }
+ else
+ {
+ log.ErrorMessage = $"HTTP {response.StatusCode}: {response.ReasonPhrase}";
+ throw new HttpRequestException(log.ErrorMessage);
+ }
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ if (log.DurationMs == 0) log.DurationMs = sw.ElapsedMilliseconds;
+
+ log.StatusCode = 0; // 0 代表网络层面的失败(如断网)
+ log.ErrorMessage = ex.Message;
+ log.ResponseBody = ex.ToString(); // 记录堆栈以便排查
+
+ throw; // 抛出异常供调用方 UI 处理
+ }
+ finally
+ {
+ // 4. 【广播日志】无论成功失败,都触发事件
+ // 使用 Invoke 确保 UI 订阅者能收到
+ OnApiLog?.Invoke(log);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Services/StorageService.cs b/SHH.CameraDashboard/Services/StorageService.cs
new file mode 100644
index 0000000..db70677
--- /dev/null
+++ b/SHH.CameraDashboard/Services/StorageService.cs
@@ -0,0 +1,52 @@
+using System.IO;
+using Newtonsoft.Json;
+
+namespace SHH.CameraDashboard.Services
+{
+ public static class StorageService
+ {
+ // 基础目录:C:\Users\Name\AppData\Local\SHH_Dashboard\
+ private static readonly string BaseDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "SHH_CameraDashboard");
+
+ ///
+ /// 泛型保存:将数据 T 序列化为指定文件名的 JSON
+ ///
+ public static void Save(T data, string fileName) where T : class
+ {
+ try
+ {
+ if (!Directory.Exists(BaseDir)) Directory.CreateDirectory(BaseDir);
+
+ string filePath = Path.Combine(BaseDir, fileName);
+ string json = JsonConvert.SerializeObject(data, Formatting.Indented);
+ File.WriteAllText(filePath, json);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"存储失败 [{fileName}]: {ex.Message}");
+ }
+ }
+
+ ///
+ /// 泛型读取:从指定 JSON 文件反序列化为数据 T
+ ///
+ public static T Load(string fileName) where T : class, new()
+ {
+ try
+ {
+ string filePath = Path.Combine(BaseDir, fileName);
+ if (!File.Exists(filePath)) return new T();
+
+ string json = File.ReadAllText(filePath);
+ return JsonConvert.DeserializeObject(json) ?? new T();
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"读取失败 [{fileName}]: {ex.Message}");
+ return new T();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Style/Themes/Colors.Dark.xaml b/SHH.CameraDashboard/Style/Themes/Colors.Dark.xaml
new file mode 100644
index 0000000..ebaac14
--- /dev/null
+++ b/SHH.CameraDashboard/Style/Themes/Colors.Dark.xaml
@@ -0,0 +1,56 @@
+
+
+ #007ACC
+ #1C97EA
+ #005A9E
+
+ #4EC9B0
+ #FFCA28
+ #F44747
+
+ #1E1E1E
+ #252526
+ #333333
+ #3E3E42
+
+ #D4D4D4
+ #858585
+ #555555
+ #FFFFFF
+
+ #3E3E42
+ #007ACC
+
+ #1E1E1E
+ #252526
+ #2D2D30
+ #3E3E42
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Style/Themes/Colors.Light.xaml b/SHH.CameraDashboard/Style/Themes/Colors.Light.xaml
new file mode 100644
index 0000000..35077c9
--- /dev/null
+++ b/SHH.CameraDashboard/Style/Themes/Colors.Light.xaml
@@ -0,0 +1,56 @@
+
+
+ #005FB8
+ #2B88D8
+ #004080
+
+ #107C10
+ #D13438
+ #A80000
+
+ #FFFFFF
+ #F3F3F3
+ #FFFFFF
+ #E1E1E1
+
+ #201F1E
+ #605E5C
+ #A19F9D
+ #FFFFFF
+
+ #D2D0CE
+ #005FB8
+
+ #F5F5F7
+ #FFFFFF
+ #F0F0F0
+ #E5E5E5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Style/Themes/Sizes.xaml b/SHH.CameraDashboard/Style/Themes/Sizes.xaml
new file mode 100644
index 0000000..2e928b8
--- /dev/null
+++ b/SHH.CameraDashboard/Style/Themes/Sizes.xaml
@@ -0,0 +1,23 @@
+
+
+ Microsoft YaHei, Segoe UI
+ Consolas, Cascadia Code, Courier New
+
+ 12
+ 14
+ 18
+ 24
+
+ 3
+ 5
+ 100
+
+ 32
+ 32
+
+ 1
+ 2
+
+
\ No newline at end of file
diff --git a/SHH.CameraDashboard/Style/Themes/Styles.xaml b/SHH.CameraDashboard/Style/Themes/Styles.xaml
new file mode 100644
index 0000000..13b6b4c
--- /dev/null
+++ b/SHH.CameraDashboard/Style/Themes/Styles.xaml
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file