具备界面基础功能

This commit is contained in:
2026-01-01 22:40:32 +08:00
parent 0c86b4dad3
commit d039559402
81 changed files with 8333 additions and 1905 deletions

View File

@@ -1,25 +1,22 @@
<Application x:Class="SHH.CameraDashboard.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SHH.CameraDashboard"
StartupUri="MainWindow.xaml">
<Application
x:Class="SHH.CameraDashboard.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:vm="clr-namespace:SHH.CameraDashboard">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- 颜色主题(深色 / 浅色) -->
<ResourceDictionary Source="/Style/Themes/Colors.Dark.xaml"/>
<!-- 如果需要支持浅色主题,可以在 ThemeManager 中切换 Colors.Light.xaml -->
<!-- 尺寸定义(字号、圆角、边距等) -->
<ResourceDictionary Source="/Style/Themes/Sizes.xaml"/>
<!-- 通用控件样式Button、TextBox、ListViewItem 等) -->
<ResourceDictionary Source="/Style/Themes/Styles.xaml"/>
<!-- 如果有专用控件样式,可以单独放在这里 -->
<!-- <ResourceDictionary Source="/Style/Themes/Wizard.xaml"/> -->
<!-- <ResourceDictionary Source="/Style/Themes/Diagnostic.xaml"/> -->
<ResourceDictionary Source="/Style/Themes/Colors.Dark.xaml" />
<ResourceDictionary Source="/Style/Themes/Icons.xaml" />
<ResourceDictionary Source="/Style/Themes/Sizes.xaml" />
<ResourceDictionary Source="/Style/Themes/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<DataTemplate DataType="{x:Type vm:WizardClientsViewModel}">
<local:WizardClientsControl />
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
</Application>
</Application>

View File

@@ -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
/// </summary>
public partial class App : Application
{
}
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
}
// 1. 【核心代码】程序启动时,异步读取配置文件
var savedNodes = await LocalStorageService.LoadAsync<ObservableCollection<ServiceNodeModel>>(AppPaths.ServiceNodesConfig);
if (savedNodes != null)
{
foreach (var node in savedNodes)
AppGlobal.ServiceNodes.Add(node);
}
// 3. 启动主窗口
// 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留,
// 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。
var mainWin = new MainWindow();
mainWin.Show();
}
/// <summary>
/// 全局统一退出入口
/// </summary>
public static void ShutdownApp()
{
// 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接)
// 2. 保存用户配置
// 3. 彻底退出
Current.Shutdown();
}
}
}

View File

@@ -0,0 +1,113 @@
using System.Collections.ObjectModel;
namespace SHH.CameraDashboard
{
/// <summary>
/// 应用程序全局状态和事件总线。
/// 此类作为一个静态的中央枢纽,用于在应用程序的不同部分之间共享数据和通信。
/// </summary>
public static class AppGlobal
{
#region --- ---
/// <summary>
/// 获取一个可观察的集合,用于存储和显示所有已配置的服务节点。
/// 由于使用了 <see cref="ObservableCollection{T}"/>当集合内容发生变化时UI如 ListView会自动更新。
/// </summary>
public static ObservableCollection<ServiceNodeModel> ServiceNodes { get; }
= new ObservableCollection<ServiceNodeModel>();
/// <summary>
/// 获取或设置当前正在使用的服务节点。
/// 当用户从列表中选择一个节点时,应更新此属性。
/// </summary>
public static ServiceNodeModel? UseServiceNode { get; set; }
#endregion
#region --- 线 ---
#region CameraAdd
/// <summary>
/// 当应用程序的任何部分请求添加一个新摄像头时发生。
/// </summary>
public static event Action? OnRequestAddCamera;
/// <summary>
/// 触发 <see cref="OnRequestAddCamera"/> 事件,以请求打开摄像头添加界面。
/// </summary>
public static void RequestAdd()
=> OnRequestAddCamera?.Invoke();
#endregion
#region CameraEdit
/// <summary>
/// 当应用程序的任何部分请求编辑一个摄像头时发生。
/// 事件处理程序将接收到要编辑的 <see cref="WebApiCameraModel"/> 实例。
/// </summary>
public static event Action<WebApiCameraModel>? OnRequestEditCamera;
/// <summary>
/// 触发 <see cref="OnRequestEditCamera"/> 事件,以请求打开摄像头编辑界面。
/// </summary>
/// <param name="camera">要编辑的摄像头数据模型。</param>
public static void RequestEdit(WebApiCameraModel camera)
=> OnRequestEditCamera?.Invoke(camera);
#endregion
#region CameraDelete
/// <summary>
/// 当应用程序的任何部分请求删除一个摄像头时发生。
/// 事件处理程序将接收到要删除的 <see cref="WebApiCameraModel"/> 实例。
/// </summary>
public static event Action<WebApiCameraModel>? OnRequestDeleteCamera;
/// <summary>
/// 触发 <see cref="OnRequestDeleteCamera"/> 事件,以请求删除指定的摄像头。
/// </summary>
/// <param name="camera">要删除的摄像头数据模型。</param>
public static void RequestDelete(WebApiCameraModel camera)
=> OnRequestDeleteCamera?.Invoke(camera);
#endregion
#region CameraRefreshList
/// <summary>
/// 当应用程序的任何部分请求刷新摄像头列表时发生。
/// </summary>
public static event Action? OnRefreshListRequest;
/// <summary>
/// 触发 <see cref="OnRefreshListRequest"/> 事件,以请求刷新摄像头列表数据。
/// </summary>
public static void RequestRefresh()
=> OnRefreshListRequest?.Invoke();
#endregion
// [新增] 请求云台控制事件
public static event Action<WebApiCameraModel>? OnRequestPtzCamera;
// [新增] 触发方法
public static void RequestPtz(WebApiCameraModel camera) => OnRequestPtzCamera?.Invoke(camera);
// 图像处理
public static event Action<WebApiCameraModel>? OnRequestImgProc;
public static void RequestImgProc(WebApiCameraModel camera) => OnRequestImgProc?.Invoke(camera);
// 1. 定义事件委托:当 ViewModel 请求订阅时触发
public static event Action<WebApiCameraModel>? OnRequestSubscription;
// 2. 定义触发方法:供 CameraItemTopViewModel 调用
public static void RequestSubscription(WebApiCameraModel camera) => OnRequestSubscription?.Invoke(camera);
#endregion
}
}

View File

@@ -1,17 +0,0 @@
namespace SHH.CameraDashboard
{
// 2. 全局配置存储
public static class AppGlobalData
{
public static List<ServerNode> ActiveServerList { get; private set; } = new List<ServerNode>();
public static void SaveConfig(IEnumerable<ServerNode> nodes)
{
ActiveServerList.Clear();
foreach (var node in nodes)
{
ActiveServerList.Add(new ServerNode { Ip = node.Ip, Port = node.Port });
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<ServerNode> Nodes { get; set; } = new ObservableCollection<ServerNode>();
// 定义关闭事件,通知主窗体关闭模态框
public event EventHandler RequestClose;
public WizardControl()
{
InitializeComponent();
// 使用泛型加载,指定返回类型为 ObservableCollection<ServerNode>
// 这里的 LoadServers 逻辑变为了通用的 Load<T>
Nodes = StorageService.Load<ObservableCollection<ServerNode>>(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);
}
}
}

View File

@@ -1,67 +0,0 @@
<UserControl
x:Class="SHH.CameraDashboard.BottomDockControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SHH.CameraDashboard.Controls">
<Grid VerticalAlignment="Bottom">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="30" />
</Grid.RowDefinitions>
<Border
x:Name="ExpandedPanel"
Grid.Row="0"
Height="250"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,1,0,0"
Visibility="Collapsed">
<local:DiagnosticControl x:Name="WebApiDiag" />
</Border>
<Border
Grid.Row="1"
Background="{DynamicResource Brush.Brand}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,1,0,0"
Cursor="Hand"
MouseLeftButtonDown="TogglePanel_Click">
<DockPanel Margin="10,0" LastChildFill="False">
<TextBlock
x:Name="LatestLogText"
VerticalAlignment="Center"
FontSize="11"
Foreground="White"
Text="准备就绪" />
<TextBlock
x:Name="ArrowIcon"
Margin="10,0,0,0"
VerticalAlignment="Center"
DockPanel.Dock="Right"
Foreground="White"
Text="▲" />
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
<TextBlock
Margin="0,0,5,0"
FontSize="10"
Foreground="#EEE"
Opacity="0.7"
Text="API Latency:" />
<TextBlock
x:Name="LatencyText"
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="0ms" />
</StackPanel>
</DockPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -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 ? "▼" : "▲";
}
}
}

View File

@@ -1,247 +0,0 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraListControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:SHH.CameraDashboard"
d:DesignHeight="600"
d:DesignWidth="250"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<Style x:Key="Style.OnlineLed" TargetType="Ellipse">
<Setter Property="Width" Value="8" />
<Setter Property="Height" Value="8" />
<Setter Property="Fill" Value="#666666" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsPhysicalOnline}" Value="True">
<Setter Property="Fill" Value="{DynamicResource Brush.Status.Success}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsPhysicalOnline}" Value="False">
<Setter Property="Fill" Value="{DynamicResource Brush.Status.Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="Style.RunningStatusBox" TargetType="Border">
<Setter Property="Width" Value="14" />
<Setter Property="Height" Value="14" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="{DynamicResource Brush.Status.Warning}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRunning}" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Accent}" />
</DataTrigger>
</Style.Triggers>
</Style>
</UserControl.Resources>
<Border
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,1,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="10,10,10,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Margin="0,0,8,0"
VerticalAlignment="Center"
Text="📡" />
<ComboBox
x:Name="ServerCombo"
Grid.Column="1"
Height="30"
DisplayMemberPath="DisplayText"
SelectionChanged="ServerCombo_SelectionChanged"
Style="{StaticResource {x:Type ComboBox}}" />
</Grid>
<Border
Grid.Row="1"
Margin="10,5,10,10"
Background="{DynamicResource Brush.Bg.Input}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.Small}">
<Grid>
<TextBox
x:Name="SearchBox"
Height="28"
Padding="8,0"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource Brush.Text.Primary}"
TextChanged="SearchBox_TextChanged" />
<TextBlock
Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
IsHitTestVisible="False"
Text="🔍 搜索摄像头...">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=SearchBox}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border>
<ListView
x:Name="CameraList"
Grid.Row="2"
Padding="0,0,0,10"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectionChanged="CameraList_SelectionChanged">
<ListView.ItemTemplate>
<DataTemplate DataType="{x:Type models:CameraInfo}">
<Grid>
<Grid Margin="0,6" Background="#01FFFFFF">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse
Margin="0,5,0,0"
VerticalAlignment="Top"
Style="{StaticResource Style.OnlineLed}" />
<StackPanel Grid.Column="1">
<StackPanel Margin="0,0,0,4" Orientation="Horizontal">
<TextBlock
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<DockPanel LastChildFill="False">
<TextBlock
FontFamily="Consolas"
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="{Binding Name}" />
<Border DockPanel.Dock="Right" Style="{StaticResource Style.RunningStatusBox}">
<Path
x:Name="PlayIcon"
Margin="3,2"
Data="M3,2.5 L9,6 L3,9.5 Z"
Fill="White"
Stretch="Fill">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRunning}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Border>
</DockPanel>
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Right"
FontSize="10"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="{Binding MediaDetail}"
Visibility="{Binding IsRunning, Converter={StaticResource BooleanToVisibilityConverter}}" />
</StackPanel>
</Grid>
<Border
Margin="0,0,0,0"
Padding="3,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Background="{DynamicResource Brush.Bg.L4}"
CornerRadius="2">
<TextBlock
FontSize="9"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="{Binding Brand}" />
</Border>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border
x:Name="Bd"
Margin="5,1"
Padding="8,4"
CornerRadius="4">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Bg.L4}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
<Setter TargetName="Bd" Property="BorderThickness" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
</ListView>
<Grid
x:Name="LoadingMask"
Grid.Row="2"
Background="{DynamicResource Brush.Bg.Panel}"
Opacity="0.8"
Visibility="Collapsed">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Accent}"
Text="加载中..." />
</Grid>
<TextBlock
x:Name="EmptyText"
Grid.Row="2"
Margin="0,50,0,0"
HorizontalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="暂无数据"
Visibility="Collapsed" />
</Grid>
</Border>
</UserControl>

View File

@@ -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<CameraInfo> _allCameras = new List<CameraInfo>();
// 绑定到界面的数据(过滤后)
public ObservableCollection<CameraInfo> DisplayCameras { get; set; } = new ObservableCollection<CameraInfo>();
public CameraListControl()
{
InitializeComponent();
CameraList.ItemsSource = DisplayCameras;
// 初始加载服务器列表
ReloadServers();
}
/// <summary>
/// 公开方法:供主窗体在向导结束后调用,刷新下拉框
/// </summary>
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<List<CameraInfo>>(url);
_allCameras = list ?? new List<CameraInfo>();
// 应用当前的搜索词
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<CameraInfo> OnDeviceSelected;
// 2. 实现 ListView 的选中事件处理
private void CameraList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CameraList.SelectedItem is CameraInfo selectedCam)
{
// 触发事件,把选中的相机传出去
OnDeviceSelected?.Invoke(selectedCam);
}
}
}
}

View File

@@ -1,199 +0,0 @@
<UserControl
x:Class="SHH.CameraDashboard.DeviceHomeControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="600"
d:DesignWidth="800"
mc:Ignorable="d">
<Grid Background="{DynamicResource Brush.Bg.Window}">
<Grid x:Name="EmptyView" Visibility="Visible">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock
HorizontalAlignment="Center"
FontSize="60"
Foreground="{DynamicResource Brush.Text.Primary}"
Opacity="0.2"
Text="📺" />
<TextBlock
Margin="0,20,0,0"
HorizontalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="请在左侧选择一台设备以查看详情" />
</StackPanel>
</Grid>
<Grid x:Name="DetailView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="60" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Padding="20,0"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<DockPanel>
<StackPanel VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock
Margin="0,0,15,0"
VerticalAlignment="Center"
FontSize="24"
Text="📷" />
<StackPanel VerticalAlignment="Center">
<TextBlock
x:Name="TxtName"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="Camera #001" />
<StackPanel Margin="0,5,0,0" Orientation="Horizontal">
<Border
x:Name="BadgeStatus"
Margin="0,0,10,0"
Padding="6,2"
Background="#224ec9b0"
CornerRadius="4">
<TextBlock
x:Name="TxtStatus"
FontSize="11"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Status.Success}"
Text="在线" />
</Border>
<TextBlock
x:Name="TxtIp"
VerticalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="192.168.1.100" />
</StackPanel>
</StackPanel>
</StackPanel>
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
<Button
Margin="5,0"
Content="⚙️ 配置"
Style="{StaticResource Btn.Ghost}" />
<Button
Margin="5,0"
Content="🔄 重启"
Style="{StaticResource Btn.Ghost}" />
</StackPanel>
</DockPanel>
</Border>
<Grid Grid.Row="1" Margin="20">
<Border Background="Black" CornerRadius="{StaticResource Radius.Normal}">
<Grid>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock
HorizontalAlignment="Center"
FontSize="20"
Foreground="#444"
Text="Live Stream" />
<TextBlock
x:Name="TxtStreamUrl"
Margin="0,10,0,0"
Foreground="#333"
Text="rtsp://..." />
<ProgressBar
Width="150"
Height="2"
Margin="0,20,0,0"
IsIndeterminate="True"
Opacity="0.5" />
</StackPanel>
<TextBlock
Margin="15"
HorizontalAlignment="Left"
VerticalAlignment="Top"
FontSize="16"
FontWeight="Bold"
Foreground="White"
Opacity="0.8"
Text="{Binding ElementName=TxtName, Path=Text}">
<TextBlock.Effect>
<DropShadowEffect
BlurRadius="3"
Opacity="0.8"
ShadowDepth="2"
Color="Black" />
</TextBlock.Effect>
</TextBlock>
</Grid>
</Border>
</Grid>
<Grid Grid.Row="2" Margin="20,0,20,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Margin="0,0,10,0"
Padding="15"
Background="{DynamicResource Brush.Bg.Panel}"
CornerRadius="5">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Secondary}" Text="实时帧率" />
<TextBlock
Margin="0,5,0,0"
FontSize="20"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="25 FPS" />
</StackPanel>
</Border>
<Border
Grid.Column="1"
Margin="5,0"
Padding="15"
Background="{DynamicResource Brush.Bg.Panel}"
CornerRadius="5">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Secondary}" Text="累计丢包" />
<TextBlock
Margin="0,5,0,0"
FontSize="20"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="0.02%" />
</StackPanel>
</Border>
<Border
Grid.Column="2"
Margin="10,0,0,0"
Padding="15"
Background="{DynamicResource Brush.Bg.Panel}"
CornerRadius="5">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Secondary}" Text="运行时间" />
<TextBlock
Margin="0,5,0,0"
FontSize="20"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="12d 4h 20m" />
</StackPanel>
</Border>
</Grid>
</Grid>
</Grid>
</UserControl>

View File

@@ -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));
}
}
}
}

View File

@@ -1,83 +0,0 @@
<UserControl x:Class="SHH.CameraDashboard.Controls.DiagnosticControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Background="{DynamicResource Brush.Bg.Window}">
<Grid.RowDefinitions>
<RowDefinition Height="35"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1" Padding="10,0">
<DockPanel LastChildFill="False">
<TextBlock Text="🌐 WebAPI 诊断日志" VerticalAlignment="Center" FontWeight="Bold" Foreground="{DynamicResource Brush.Text.Primary}"/>
<TextBlock Text="{Binding Logs.Count, StringFormat='(共 {0} 条)'}" VerticalAlignment="Center" Margin="10,0,0,0" Foreground="{DynamicResource Brush.Text.Secondary}" FontSize="11"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="🗑 清空" Click="Clear_Click" Style="{StaticResource Btn.Ghost}" Height="24" FontSize="11" Padding="10,0"/>
<Button Content="✕" Click="Close_Click" Style="{StaticResource Btn.Ghost}" Height="24" Width="30" Margin="5,0,0,0"/>
</StackPanel>
</DockPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="250"/>
<ColumnDefinition Width="5"/>
<ColumnDefinition Width="*" MinWidth="300"/>
</Grid.ColumnDefinitions>
<ListView x:Name="LogList" Grid.Column="0" SelectionChanged="LogList_SelectionChanged"
ItemContainerStyle="{StaticResource Style.ListViewItem.Table}" Background="Transparent" BorderThickness="0">
<ListView.View>
<GridView ColumnHeaderContainerStyle="{StaticResource Style.GridViewHeader.Flat}">
<GridViewColumn Header="请求时间" Width="90">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Time}" Foreground="{DynamicResource Brush.Text.Secondary}" FontFamily="Consolas"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="URL" Width="200">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Url}" TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="状态码" Width="60">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding StatusCode}" Foreground="{Binding StatusColor}" FontWeight="Bold"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch" Background="{DynamicResource Brush.Bg.Panel}"/>
<Grid Grid.Column="2" Background="{DynamicResource Brush.Bg.Input}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Background="{DynamicResource Brush.Bg.Panel}" BorderThickness="0,0,0,1" BorderBrush="{DynamicResource Brush.Border}">
<DockPanel LastChildFill="False">
<StackPanel Orientation="Horizontal">
<RadioButton x:Name="BtnReq" Content="Request" IsChecked="True" GroupName="Logs" Style="{StaticResource TabRadioStyle}" Checked="Tab_Checked"/>
<RadioButton x:Name="BtnResp" Content="Response" GroupName="Logs" Style="{StaticResource TabRadioStyle}" Checked="Tab_Checked"/>
</StackPanel>
<Button Content="📋 复制" Click="Copy_Click" DockPanel.Dock="Right" Style="{StaticResource Btn.Ghost}" Height="22" Margin="0,0,10,0"/>
</DockPanel>
</Border>
<TextBox x:Name="TxtContent" Grid.Row="1" Style="{StaticResource Style.TextBox.CodeEditor}"/>
<StackPanel x:Name="TxtEmpty" Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="请选择一条日志查看详情" Foreground="{DynamicResource Brush.Text.Secondary}"/>
</StackPanel>
</Grid>
</Grid>
</Grid>
</UserControl>

View File

@@ -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 ?? "");
}
}
}

View File

@@ -0,0 +1,29 @@
using System.Globalization;
using System.Windows.Data;
namespace SHH.CameraDashboard;
/// <summary>
/// 布尔值转最大缩放比例转换器
/// </summary>
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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,34 @@
using System.Globalization;
using System.Windows.Data;
namespace SHH.CameraDashboard
{
/// <summary>
/// 将订阅类型的 int 值 (0,1,2...) 转换为中文描述
/// </summary>
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();
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,7 @@
namespace SHH.CameraDashboard;
// 定义一个简单的接口,让子 ViewModel 具备关闭能力
public interface IOverlayClosable
{
event Action? RequestClose;
}

View File

@@ -0,0 +1,109 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace SHH.CameraDashboard
{
/// <summary>
/// JSON 序列化与反序列化帮助类
/// 职责:
/// 1. 提供全局统一的 JSON 转换配置(如驼峰命名、日期格式)。
/// 2. 封装常见的序列化和反序列化操作。
/// 3. 增加对 null 输入和无效 JSON 的健壮性处理。
/// </summary>
public static class JsonHelper
{
#region --- ---
/// <summary>
/// 全局共享的 JSON 序列化设置。
/// 静态构造函数保证其只被初始化一次。
/// </summary>
private static readonly JsonSerializerSettings _settings;
#endregion
#region --- ---
/// <summary>
/// 静态构造函数,用于初始化全局的 JSON 序列化设置。
/// </summary>
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 --- ---
/// <summary>
/// 将对象序列化为 JSON 字符串。
/// </summary>
/// <param name="obj">要序列化的对象。</param>
/// <returns>序列化后的 JSON 字符串。如果输入为 null则返回空字符串。</returns>
public static string Serialize(object obj)
{
// [健壮性] 如果输入对象为 null返回空字符串而不是 "null"。
// 这可以防止在创建 HTTP 请求内容时出现意外行为。
if (obj == null)
{
return string.Empty;
}
return JsonConvert.SerializeObject(obj, _settings);
}
/// <summary>
/// 将 JSON 字符串反序列化为指定类型的对象。
/// </summary>
/// <typeparam name="T">目标对象的类型(必须是引用类型)。</typeparam>
/// <param name="json">要反序列化的 JSON 字符串。</param>
/// <returns>成功时返回反序列化后的对象;失败或输入无效时返回 null。</returns>
public static T? Deserialize<T>(string json) where T : class
{
// [健壮性] 检查输入是否为 null、空字符串或仅包含空白字符。
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
// [健壮性] 处理 JSON 字符串为 "null" 的特殊情况。
if (json.Trim() == "null")
{
return null;
}
try
{
// 尝试使用预配置的设置进行反序列化。
return JsonConvert.DeserializeObject<T>(json, _settings);
}
catch (JsonException)
{
// [健壮性] 如果 JSON 格式无效,捕获异常并返回 null。
// 这可以防止程序因一个格式错误的 JSON 字符串而崩溃。
return null;
}
}
#endregion
}
}

View File

@@ -0,0 +1,65 @@
using System.Windows.Input;
namespace SHH.CameraDashboard;
// ===========================================================================
// 1. 新增:非泛型 RelayCommand (支持 new RelayCommand(Method, Check))
// ===========================================================================
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Predicate<object> _canExecute;
public RelayCommand(Action<object> execute, Predicate<object> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object parameter)
{
_execute(parameter);
}
public event EventHandler CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
}
// ===========================================================================
// 2. 保留:泛型 RelayCommand<T> (支持 new RelayCommand<string>(Method...))
// ===========================================================================
public class RelayCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Predicate<T> _canExecute;
public RelayCommand(Action<T> execute, Predicate<T> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute((T)parameter);
}
public void Execute(object parameter)
{
_execute((T)parameter);
}
public event EventHandler CanExecuteChanged
{
add => CommandManager.RequerySuggested += value;
remove => CommandManager.RequerySuggested -= value;
}
}

View File

@@ -0,0 +1,92 @@
using System.Windows;
namespace SHH.CameraDashboard
{
public static class ThemeManager
{
// 使用字典Key=主题名, Value=路径
private static readonly Dictionary<string, string> _themeMap = new Dictionary<string, string>
{
{ "Dark", "/Style/Themes/Colors.Dark.xaml" },
{ "Light", "/Style/Themes/Colors.Light.xaml" },
// { "Blue", "/Style/Themes/Colors.Blue.xaml" }
};
// 当前主题名称
public static string CurrentThemeName { get; private set; } = "Dark";
/// <summary>
/// 指定切换到某个主题
/// </summary>
/// <param name="themeName">主题名称 (Dark, Light...)</param>
public static void SetTheme(string themeName)
{
if (!_themeMap.ContainsKey(themeName))
{
System.Diagnostics.Debug.WriteLine($"未找到主题: {themeName}");
return;
}
ApplyTheme(_themeMap[themeName]);
CurrentThemeName = themeName;
}
/// <summary>
/// 循环切换下一个主题 (保留旧功能)
/// </summary>
public static void SwitchToNextTheme()
{
// 获取所有 Key 的列表
var keys = _themeMap.Keys.ToList();
// 找到当前 Key 的索引
int index = keys.IndexOf(CurrentThemeName);
// 计算下一个索引
index++;
if (index >= keys.Count) index = 0;
// 切换
SetTheme(keys[index]);
}
/// <summary>
/// 私有方法:执行具体的资源替换
/// </summary>
private static void ApplyTheme(string themePath)
{
var appResources = Application.Current.Resources;
ResourceDictionary oldDict = null;
// 查找旧的皮肤字典
foreach (var dict in appResources.MergedDictionaries)
{
if (dict.Source != null && dict.Source.OriginalString.Contains("/Themes/Colors."))
{
oldDict = dict;
break;
}
}
// 移除旧的
if (oldDict != null)
{
appResources.MergedDictionaries.Remove(oldDict);
}
// 加载新的
try
{
var newDict = new ResourceDictionary
{
Source = new Uri(themePath, UriKind.Relative)
};
appResources.MergedDictionaries.Add(newDict);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"加载主题文件失败: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,81 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace SHH.CameraDashboard;
public static class TouchBehavior
{
//原有 Action 属性保持不变...
public static readonly DependencyProperty ActionProperty =
DependencyProperty.RegisterAttached("Action", typeof(PtzAction?), typeof(TouchBehavior), new PropertyMetadata(null, OnActionChanged));
public static PtzAction? GetAction(DependencyObject obj) => (PtzAction?)obj.GetValue(ActionProperty);
public static void SetAction(DependencyObject obj, PtzAction? value) => obj.SetValue(ActionProperty, value);
// ★★★ [新增] 私有附加属性,用于记录“是否处于按下状态” ★★★
private static readonly DependencyProperty IsActiveProperty =
DependencyProperty.RegisterAttached("IsActive", typeof(bool), typeof(TouchBehavior), new PropertyMetadata(false));
private static bool GetIsActive(DependencyObject obj) => (bool)obj.GetValue(IsActiveProperty);
private static void SetIsActive(DependencyObject obj, bool value) => obj.SetValue(IsActiveProperty, value);
private static void OnActionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is Button btn)
{
btn.PreviewMouseDown -= Btn_PreviewMouseDown;
btn.PreviewMouseUp -= Btn_PreviewMouseUp;
btn.MouseLeave -= Btn_PreviewMouseUp;
if (e.NewValue != null)
{
btn.PreviewMouseDown += Btn_PreviewMouseDown;
btn.PreviewMouseUp += Btn_PreviewMouseUp;
btn.MouseLeave += Btn_PreviewMouseUp;
}
}
}
private static void Btn_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (sender is Button btn && btn.DataContext is CameraPtzViewModel vm)
{
var action = GetAction(btn);
if (action.HasValue && vm.StartCommand.CanExecute(action.Value))
{
// 1. 标记为活跃状态
SetIsActive(btn, true);
// 2. 执行开始命令
vm.StartCommand.Execute(action.Value);
// (可选) 捕获鼠标,防止快速拖出窗口丢失 MouseUp
btn.CaptureMouse();
}
}
}
private static void Btn_PreviewMouseUp(object sender, MouseEventArgs e)
{
if (sender is Button btn && btn.DataContext is CameraPtzViewModel vm)
{
// ★★★ [核心修改] 只有之前标记为活跃,才执行停止 ★★★
if (GetIsActive(btn))
{
var action = GetAction(btn);
if (action.HasValue && vm.StopCommand.CanExecute(action.Value))
{
vm.StopCommand.Execute(action.Value);
}
// 重置状态
SetIsActive(btn, false);
// 释放鼠标
btn.ReleaseMouseCapture();
}
}
}
}

View File

@@ -2,15 +2,15 @@
x:Class="SHH.CameraDashboard.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="clr-namespace:SHH.CameraDashboard"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="SHH 视频中控"
Width="1366"
Height="768"
Width="1900"
Height="1030"
Background="{DynamicResource Brush.Bg.Window}"
FontFamily="{StaticResource Font.Normal}"
Foreground="{DynamicResource Brush.Text.Primary}"
Topmost="{Binding IsTopMost}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
@@ -22,223 +22,341 @@
ResizeBorderThickness="5" />
</WindowChrome.WindowChrome>
<Grid x:Name="RootGrid">
<Grid x:Name="AppLayout">
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<Border
Background="{DynamicResource Brush.Bg.Window}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="45" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="5" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
x:Name="TitleBar"
Grid.Row="0"
Height="32"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1"
MouseDown="OnTitleBarMouseDown">
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Column="0"
Margin="10,0"
Margin="12,0"
VerticalAlignment="Center"
Orientation="Horizontal">
<Button
Width="40"
Click="ToggleSidebar"
Content="≡"
FontSize="18"
<TextBlock
Margin="0,0,8,0"
VerticalAlignment="Center"
Text="📷" />
<TextBlock
VerticalAlignment="Center"
FontSize="12"
FontWeight="Bold"
Style="{StaticResource Btn.Ghost}"
ToolTip="展开/收起侧边栏" />
Text="SHH 视频网关 - 中控台" />
</StackPanel>
<TextBlock
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="{StaticResource Size.Font.Large}"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
IsHitTestVisible="False"
Text="SHH 视频中控平台" />
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
Command="{Binding OpenWizardCommand}"
Style="{StaticResource Btn.TitleBar}"
ToolTip="向导">
<Path
Width="18"
Height="18"
Data="{StaticResource Icon.Wizard}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform" />
</Button>
<StackPanel
Grid.Column="2"
Margin="0,0,10,0"
Orientation="Horizontal">
<Button
Width="40"
Margin="0,0,5,0"
Click="OpenWizard"
Content="🧙‍♂️"
Style="{StaticResource Btn.Ghost}"
ToolTip="打开配置向导" />
Height="30"
Click="BtnTheme_Click"
Style="{StaticResource Btn.TitleBar}"
ToolTip="切换皮肤">
<StackPanel VerticalAlignment="Center" Orientation="Horizontal">
<Path
Width="14"
Height="14"
Data="{StaticResource Icon.Theme}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform" />
<Path
Width="8"
Height="8"
Margin="4,2,0,0"
VerticalAlignment="Center"
Data="M7,10L12,15L17,10H7Z"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform" />
</StackPanel>
<Button.ContextMenu>
<ContextMenu Placement="Bottom">
<MenuItem Command="{Binding SwitchThemeCommand}" CommandParameter="Dark">
<MenuItem.Header>
<TextBlock
Margin="0,1,0,0"
VerticalAlignment="Center"
FontSize="13"
Text="🌚 深色模式 (Dark)" />
</MenuItem.Header>
</MenuItem>
<MenuItem Command="{Binding SwitchThemeCommand}" CommandParameter="Light">
<MenuItem.Header>
<TextBlock
Margin="0,1,0,0"
VerticalAlignment="Center"
FontSize="13"
Text="☀️ 浅色模式 (Light)" />
</MenuItem.Header>
</MenuItem>
</ContextMenu>
</Button.ContextMenu>
</Button>
<Button
Width="40"
Margin="0,0,15,0"
Click="ToggleTheme"
Content="🎨"
Style="{StaticResource Btn.Ghost}"
ToolTip="切换皮肤 (Dark/Light)" />
Command="{Binding ToggleTopMostCommand}"
Style="{StaticResource Btn.TitleBar}"
ToolTip="{Binding TopMostTooltip}">
<Path
Width="16"
Height="16"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Data" Value="{StaticResource Icon.Pin.Outline}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsTopMost}" Value="True">
<Setter Property="Data" Value="{StaticResource Icon.Pin.Fixed}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Button>
<Rectangle Width="10" Fill="Transparent" />
<Button
Width="40"
Click="OnMinimize"
Command="{Binding MinimizeCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
Content="—"
Style="{StaticResource Btn.Ghost}"
ToolTip="最小化" />
Style="{StaticResource Btn.TitleBar}" />
<Button
Width="40"
Margin="5,0,0,0"
Click="OnClose"
Command="{Binding ToggleMaximizeCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
Content="{Binding MaxButtonContent}"
Style="{StaticResource Btn.TitleBar}" />
<Button
Command="{Binding CloseCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
Content="✕"
Style="{StaticResource Btn.Danger}"
ToolTip="关闭" />
Style="{StaticResource Btn.TitleBar.Close}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ctrl:CameraListControl
x:Name="Sidebar"
Grid.Column="0"
Width="250" />
<DockPanel Grid.Row="1">
<!-- 左侧摄像头列表 -->
<Border
Grid.Column="0"
Width="1"
HorizontalAlignment="Right"
Background="{DynamicResource Brush.Border}" />
Width="320"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,1,0"
DockPanel.Dock="Left">
<local:CameraList x:Name="CameraList" />
</Border>
<Grid
Grid.Column="1"
<!-- 右侧面板 -->
<Border
Width="500"
Background="{DynamicResource Brush.Bg.Window}"
ClipToBounds="True">
<ctrl:DeviceHomeControl x:Name="DeviceHome" Margin="0" />
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1,0,0,0"
DockPanel.Dock="Right"
Visibility="{Binding IsRightPanelVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
<Border
x:Name="RightConfigPanel"
Width="350"
<Border.Resources>
<DataTemplate DataType="{x:Type local:CameraEditViewModel}">
<local:CameraEdit />
</DataTemplate>
<DataTemplate DataType="{x:Type local:CameraPtzViewModel}">
<local:CameraPtz />
</DataTemplate>
<DataTemplate DataType="{x:Type local:CameraImgProcViewModel}">
<local:CameraImgProc />
</DataTemplate>
<DataTemplate DataType="{x:Type local:CameraImageSubscriptionViewModels}">
<local:CameraImageSubscription />
</DataTemplate>
</Border.Resources>
<ContentControl Content="{Binding CurrentRightPanelViewModel}" />
</Border>
<DockPanel>
<local:CameraItemTop DataSource="{Binding ElementName=CameraList, Path=DataContext.SelectedCamera}" DockPanel.Dock="Top" />
<Grid />
</DockPanel>
</DockPanel>
<GridSplitter
Grid.Row="2"
Height="5"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Background="Transparent"
ResizeBehavior="PreviousAndNext"
ResizeDirection="Rows"
ShowsPreview="True" />
<Border
x:Name="BottomLogPanel"
Grid.Row="3"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,1,0,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Height" Value="250" />
<Setter Property="MinHeight" Value="100" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsLogPanelExpanded}" Value="False">
<Setter Property="Height" Value="35" />
<Setter Property="MinHeight" Value="35" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<TabControl
Padding="0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{DynamicResource Style.TabItem.Modern}">
<TabItem Header="WebAPI 诊断">
<local:ServiceNodesDiagnostic DataContext="{Binding DiagnosticVM}" />
</TabItem>
</TabControl>
<StackPanel
Margin="0,2,5,0"
HorizontalAlignment="Right"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1,0,0,0"
Visibility="Collapsed">
<Border.Effect>
<DropShadowEffect
BlurRadius="20"
Direction="180"
Opacity="0.3"
ShadowDepth="5"
Color="Black" />
</Border.Effect>
VerticalAlignment="Top"
Panel.ZIndex="1"
Orientation="Horizontal">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="*" />
<RowDefinition Height="60" />
</Grid.RowDefinitions>
<Button
Width="30"
Height="30"
Command="{Binding DiagnosticVM.ClearCommand}"
Style="{StaticResource Btn.TitleBar}"
ToolTip="清空日志">
<Path
Width="14"
Height="14"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="{StaticResource Icon.Trash}"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform" />
</Button>
<Border BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<DockPanel Margin="15,0" LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontSize="{StaticResource Size.Font.Large}"
FontWeight="Bold"
Text="⚙️ 设备参数配置" />
<Button
Width="30"
Padding="0"
Click="CloseRightPanel"
Content="✕"
DockPanel.Dock="Right"
Style="{StaticResource Btn.Ghost}" />
</DockPanel>
</Border>
<StackPanel Grid.Row="1" Margin="20">
<TextBlock
Margin="0,10,0,5"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="设备名称" />
<TextBox Padding="5" Text="Camera #01" />
<TextBlock
Margin="0,15,0,5"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="流媒体协议" />
<ComboBox
Height="32"
Padding="5"
SelectedIndex="0">
<ComboBoxItem>RTSP (TCP)</ComboBoxItem>
<ComboBoxItem>RTSP (UDP)</ComboBoxItem>
<ComboBoxItem>WebRTC</ComboBoxItem>
</ComboBox>
<TextBlock
Margin="0,15,0,5"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="主码流地址" />
<TextBox
Height="80"
Padding="5"
VerticalContentAlignment="Top"
Text="rtsp://admin:123456@192.168.1.20:554/h264/ch1/main/av_stream"
TextWrapping="Wrap" />
</StackPanel>
<Border
Grid.Row="2"
Background="{DynamicResource Brush.Bg.Window}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,1,0,0">
<StackPanel
Margin="15,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
Margin="0,0,10,0"
Click="CloseRightPanel"
Content="取消"
Style="{StaticResource Btn.Ghost}" />
<Button
Width="100"
Background="{DynamicResource Brush.Accent}"
Click="CloseRightPanel"
Content="保存配置" />
</StackPanel>
</Border>
</Grid>
</Border>
<Button
Width="30"
Height="30"
Command="{Binding ToggleLogPanelCommand}"
Style="{StaticResource Btn.TitleBar}"
ToolTip="展开/收起面板">
<Path
Width="12"
Height="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Fill="{Binding RelativeSource={RelativeSource AncestorType=Button}, Path=Foreground}"
Stretch="Uniform">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Data" Value="{StaticResource Icon.ChevronDown}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsLogPanelExpanded}" Value="False">
<Setter Property="Data" Value="{StaticResource Icon.ChevronUp}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Button>
</StackPanel>
</Grid>
</Border>
<Grid
x:Name="GlobalMask"
Grid.Row="1"
Grid.RowSpan="3"
Background="#CC000000"
Visibility="{Binding IsOverlayVisible, Converter={StaticResource BooleanToVisibilityConverter}}">
<ContentControl
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{Binding OverlayContent}">
<ContentControl.Style>
<Style TargetType="ContentControl">
<Style.Triggers>
<DataTrigger Binding="{Binding IsOverlayVisible}" Value="True">
<DataTrigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetProperty="Opacity"
From="0"
To="1"
Duration="0:0:0.2" />
<DoubleAnimation
Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
From="0.9"
To="1"
Duration="0:0:0.2" />
<DoubleAnimation
Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
From="0.9"
To="1"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</DataTrigger.EnterActions>
</DataTrigger>
</Style.Triggers>
<Setter Property="RenderTransform">
<Setter.Value>
<ScaleTransform CenterX="0.5" CenterY="0.5" />
</Setter.Value>
</Setter>
</Style>
</ContentControl.Style>
</ContentControl>
</Grid>
<ctrl:BottomDockControl x:Name="BottomDock" Grid.Row="2" />
</Grid>
<Grid x:Name="ModalLayer" Visibility="Collapsed">
<Border Background="#99000000" MouseDown="CloseModal_Click" />
<ContentControl
x:Name="ModalContainer"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ContentControl.Effect>
<DropShadowEffect
BlurRadius="30"
Opacity="0.5"
ShadowDepth="10"
Color="Black" />
</ContentControl.Effect>
</ContentControl>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -1,107 +1,37 @@
using System;
using System.Windows;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
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)
private void TitleBar_MouseDown(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;
DragMove();
}
}
private void CloseModal_Click(object sender, MouseButtonEventArgs e)
private void BtnTheme_Click(object sender, RoutedEventArgs e)
{
// 点击遮罩层背景关闭
CloseModal();
}
private void CloseModal()
{
ModalLayer.Visibility = Visibility.Collapsed;
ModalContainer.Content = null;
var btn = sender as Button;
if (btn != null && btn.ContextMenu != null)
{
// 设置菜单的目标为当前按钮(防止定位跑偏)
btn.ContextMenu.PlacementTarget = btn;
// 打开菜单
btn.ContextMenu.IsOpen = true;
}
}
}
}

View File

@@ -0,0 +1,515 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
/// <summary>
/// 主窗口的视图模型 (ViewModel)。
/// 它封装了主窗口的所有业务逻辑、状态和用户交互命令。
/// 它还作为一个中心协调者,响应全局事件并管理子视图(如右侧编辑面板和蒙板)。
/// </summary>
public class MainWindowViewModel : INotifyPropertyChanged
{
#region --- ---
/// <summary>
/// 初始化 MainWindowViewModel 的新实例。
/// </summary>
public MainWindowViewModel()
{
#region
// 最小化窗口控制命令
MinimizeCommand = new RelayCommand<Window>(win =>
{
if (win != null)
{
win.WindowState = WindowState.Minimized;
}
});
// 最大化、还原命令
ToggleMaximizeCommand = new RelayCommand<Window>(win =>
{
if (win == null) return;
if (win.WindowState == WindowState.Maximized)
{
win.WindowState = WindowState.Normal;
MaxButtonContent = "⬜";
}
else
{
win.WindowState = WindowState.Maximized;
MaxButtonContent = "❐";
}
});
// 关闭应用程序命令
CloseCommand = new RelayCommand<Window>(win =>
{
App.ShutdownApp();
});
// 窗体置顶
ToggleTopMostCommand = new RelayCommand<object>(_ => IsTopMost = !IsTopMost);
#endregion
#region
// 向导功能命令
OpenWizardCommand = new RelayCommand<object>(_ =>
{
var wizardVM = new WizardClientsViewModel();
ShowOverlay(wizardVM);
});
// 更换主题命令
SwitchThemeCommand = new RelayCommand<string>(themeName =>
{
if (!string.IsNullOrEmpty(themeName))
ThemeManager.SetTheme(themeName);
});
#endregion
#region
// 订阅事件
AppGlobal.OnRequestAddCamera += HandleAddRequest; // 新增
AppGlobal.OnRequestEditCamera += HandleEditRequest; // 编辑
AppGlobal.OnRequestDeleteCamera += HandleDeleteRequest; // 删除
AppGlobal.OnRequestPtzCamera += HandlePtzRequest; // 云台
AppGlobal.OnRequestImgProc += HandleImgProcRequest; //订阅图像处理请求
AppGlobal.OnRequestSubscription += ShowSubscriptionPanel; // 订阅图像处理请求
#endregion
// 打开或折叠日志面板
ToggleLogPanelCommand = new RelayCommand<object>(_ => IsLogPanelExpanded = !IsLogPanelExpanded);
// 初始化子 ViewModel
DiagnosticVM = new ServiceNodesViewModel();
// 启动异步初始化流程
_ = InitializeAsync();
}
#endregion
#region --- ---
#region HandleAddRequest
/// <summary>
/// 处理添加请求
/// </summary>
public void HandleAddRequest()
{
// 1. 创建一个新的、空白的 DTO并提供一些合理的默认值以提升用户体验
var newDto = new CameraEditInfo
{
Id = 0, // 0 表示新增
Name = "新摄像头",
IpAddress = "192.168.1.64",
Brand = 1, // 默认为海康(1) 或你想要的默认值
Port = 8000,
Username = "admin",
Password = "abcd1234",
ChannelIndex = 1,
StreamType = 0, // 主码流
EnhanceImage = false,
AllowCompress = true,
};
// 2. 创建用于编辑的 ViewModel
var editVm = new CameraEditViewModel(newDto);
// 3. 监听编辑窗口的关闭请求,以便在操作完成后进行清理和刷新
editVm.RequestClose += (isSaved, data) =>
{
CurrentRightPanelViewModel = null;
if (isSaved)
{
System.Diagnostics.Debug.WriteLine("新增成功,刷新列表...");
AppGlobal.RequestRefresh();
}
};
// 4. 在右侧面板中显示编辑界面
CurrentRightPanelViewModel = editVm;
}
#endregion
#region HandleEditRequest
/// <summary>
/// 处理编辑请求:调用 Repository 获取数据
/// </summary>
private async void HandleEditRequest(WebApiCameraModel listModel)
{
if (listModel == null) return;
try
{
// 1. 调用 API 客户端获取该摄像头的完整详细信息
var editModel = await ApiClient.Instance.Cameras.GetCameraDetailsAsync(listModel.Id);
if (editModel == null)
{
System.Diagnostics.Debug.WriteLine("获取设备详情失败 (Repository返回null)");
return;
}
// 2. 创建用于编辑的 ViewModel
var editVm = new CameraEditViewModel(editModel);
// 3. 监听编辑窗口的关闭请求
editVm.RequestClose += (isSaved, newConfig) =>
{
CurrentRightPanelViewModel = null;
if (isSaved && newConfig != null)
{
// 这里以后可以调用 Update 方法
System.Diagnostics.Debug.WriteLine($"保存成功: {newConfig.Name}");
AppGlobal.RequestRefresh();
}
};
// 4. 在右侧面板中显示编辑界面
CurrentRightPanelViewModel = editVm;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"HandleEditRequest 异常: {ex.Message}");
}
}
#endregion
#region HandleDeleteRequest
/// <summary>
/// 处理删除请求
/// </summary>
private async void HandleDeleteRequest(WebApiCameraModel camera)
{
if (camera == null) return;
// 1. 弹窗确认 (防止误删)
var result = MessageBox.Show(
$"确定要删除摄像头 \"{camera.Name}\" 吗?\n此操作不可恢复。",
"确认删除",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
bool success = await ApiClient.Instance.Cameras.DeleteCameraAsync(camera.Id);
if (success)
{
// 3. 成功后刷新列表
System.Diagnostics.Debug.WriteLine($"删除 {camera.Name} 成功,刷新列表...");
AppGlobal.RequestRefresh();
}
else
{
MessageBox.Show("删除失败,请检查网络或日志。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region HandlePtzRequest ()
/// <summary>
/// [新增] 处理云台控制请求
/// </summary>
private void HandlePtzRequest(WebApiCameraModel camera)
{
if (camera == null) return;
try
{
// 1. 创建云台控制的 ViewModel
var ptzVm = new CameraPtzViewModel(camera);
// 2. 监听关闭事件 (以便点击面板上的关闭按钮时,能收起右侧面板)
// 注意:我们需要在 CameraPtzViewModel 中定义这个 RequestClose 事件
ptzVm.RequestClose += () =>
{
CurrentRightPanelViewModel = null;
};
// 3. 将右侧面板的内容切换为 云台控制
CurrentRightPanelViewModel = ptzVm;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"打开云台失败: {ex.Message}");
}
}
#endregion
private async void HandleImgProcRequest(WebApiCameraModel camera)
{
if (camera == null) return;
try
{
// 获取详情 (因为列表模型可能不包含 TargetResolution 等深层字段)
var detailDto = await ApiClient.Instance.Cameras.GetCameraDetailsAsync(camera.Id);
if (detailDto == null) return;
// 创建 VM
var vm = new CameraImgProcViewModel(camera, detailDto);
// 处理关闭回调
vm.RequestClose += () => CurrentRightPanelViewModel = null;
CurrentRightPanelViewModel = vm;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"打开图像处理失败: {ex.Message}");
}
}
// 2. 添加处理方法 (仿照 HandlePtzRequest 写法)
private void ShowSubscriptionPanel(WebApiCameraModel camera)
{
if (camera == null) return;
try
{
// A. 复用模式:只创建 ViewModel
var vm = new CameraImageSubscriptionViewModels(camera);
// B. 复用关闭逻辑:当子页面请求关闭时,清空当前面板对象
vm.RequestClose += () => CurrentRightPanelViewModel = null;
// C. 复用显示通道直接赋值UI 会自动更新
CurrentRightPanelViewModel = vm;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"打开订阅面板失败: {ex.Message}");
}
}
#endregion
#region --- ---
// 这里的类型是 object (或 INotifyPropertyChanged)
// 它可以存放 CameraEditViewModel也可以存放 LogViewModel, SettingsViewModel 等
private object? _currentRightPanelViewModel;
public object? CurrentRightPanelViewModel
{
get => _currentRightPanelViewModel;
set
{
_currentRightPanelViewModel = value;
OnPropertyChanged();
// 每次内容变了,通知 Visibility 属性也更新一下
OnPropertyChanged(nameof(IsRightPanelVisible));
}
}
// 界面绑定的可见性:只要 Content 不为空,就显示面板
public bool IsRightPanelVisible => _currentRightPanelViewModel != null;
#endregion
#region --- (Commands) ---
/// <summary>
/// 获取最小化窗口的命令。
/// </summary>
public ICommand MinimizeCommand { get; }
/// <summary>
/// 获取切换窗口最大化/还原状态的命令。
/// </summary>
public ICommand ToggleMaximizeCommand { get; }
/// <summary>
/// 获取关闭应用程序的命令。
/// </summary>
public ICommand CloseCommand { get; }
/// <summary>
/// 获取打开客户端配置向导的命令。
/// </summary>
public ICommand OpenWizardCommand { get; }
/// <summary>
/// 获取根据名称切换主题的命令。
/// </summary>
public ICommand SwitchThemeCommand { get; }
/// <summary>
/// 获取切换窗口置顶状态的命令。
/// </summary>
public ICommand ToggleTopMostCommand { get; }
/// <summary>
/// 获取展开或折叠日志面板的命令。
/// </summary>
public ICommand ToggleLogPanelCommand { get; }
#endregion
#region --- (Properties) ---
private string _maxButtonContent = "⬜";
/// <summary>
/// 获取或设置最大化/还原按钮的显示内容。
/// </summary>
public string MaxButtonContent
{
get => _maxButtonContent;
set { _maxButtonContent = value; OnPropertyChanged(); }
}
private bool _isTopMost;
/// <summary>
/// 获取或设置一个值,该值指示窗口是否应始终显示在其他窗口之上。
/// </summary>
public bool IsTopMost
{
get => _isTopMost;
set
{
if (_isTopMost != value)
{
_isTopMost = value;
OnPropertyChanged(nameof(IsTopMost));
OnPropertyChanged(nameof(TopMostTooltip));
}
}
}
/// <summary>
/// 获取用于窗口置顶按钮的动态工具提示文本。
/// </summary>
public string TopMostTooltip => IsTopMost ? "取消置顶" : "窗口置顶";
private bool _isLogPanelExpanded = true;
/// <summary>
/// 获取或设置一个值,该值指示日志面板是否展开。
/// </summary>
public bool IsLogPanelExpanded
{
get => _isLogPanelExpanded;
set
{
if (_isLogPanelExpanded != value)
{
_isLogPanelExpanded = value;
OnPropertyChanged(nameof(IsLogPanelExpanded));
}
}
}
/// <summary>
/// 获取用于服务节点诊断面板的 ViewModel。
/// </summary>
public ServiceNodesViewModel DiagnosticVM { get; }
#endregion
#region --- / ---
private bool _isOverlayVisible;
/// <summary>
/// 获取或设置全局蒙板的可见性。
/// </summary>
public bool IsOverlayVisible
{
get => _isOverlayVisible;
set { _isOverlayVisible = value; OnPropertyChanged(); }
}
private object? _overlayContent;
/// <summary>
/// 获取或设置蒙板内显示的内容(一个子 ViewModel
/// </summary>
public object? OverlayContent
{
get => _overlayContent;
set { _overlayContent = value; OnPropertyChanged(); }
}
/// <summary>
/// 显示一个带有指定内容的全局蒙板。
/// </summary>
/// <param name="contentViewModel">要在蒙板中显示的子 ViewModel。</param>
public void ShowOverlay(object contentViewModel)
{
if (contentViewModel == null) return;
if (contentViewModel is IOverlayClosable closable)
{
closable.RequestClose += () =>
{
IsOverlayVisible = false;
OverlayContent = null;
};
}
OverlayContent = contentViewModel;
IsOverlayVisible = true;
}
#endregion
#region --- ---
/// <summary>
/// 执行应用程序的异步初始化逻辑。
/// </summary>
private async Task InitializeAsync()
{
await Task.Delay(500);
if (CheckIfNeedWizard())
{
var wizardVM = new WizardClientsViewModel();
ShowOverlay(wizardVM);
}
}
/// <summary>
/// 检查是否需要在启动时显示设置向导。
/// </summary>
private bool CheckIfNeedWizard()
{
return true;
}
#endregion
#region --- INotifyPropertyChanged ---
/// <summary>
/// 当属性值更改时发生。
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 引发 PropertyChanged 事件通知UI更新。
/// </summary>
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string name = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
#endregion
}
}

View File

@@ -1,25 +0,0 @@
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)); // 红色
}
}

View File

@@ -1,31 +0,0 @@
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" : "无信号";
}

View File

@@ -0,0 +1,58 @@
namespace SHH.CameraDashboard
{
/// <summary>
/// WebAPI 调用日志模型
/// 用于记录 API 请求的详细信息,便于调试和性能分析
/// </summary>
public class LogWebApiModel
{
#region --- ---
/// <summary>
/// 获取或设置请求发生的时间
/// </summary>
public DateTime Time { get; set; } = DateTime.Now;
/// <summary>
/// 获取或设置 HTTP 请求方法 (GET, POST, PUT, DELETE 等)
/// </summary>
public string Method { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求的 URL 路径
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应的状态码 (200, 404, 500 等)
/// </summary>
public string StatusCode { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求的原始数据 (JSON/XML 等)
/// </summary>
public string RequestData { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应的原始数据 (JSON/XML 等)
/// </summary>
public string ResponseData { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求处理耗时(毫秒)
/// </summary>
public long ElapsedMilliseconds { get; set; }
/// <summary>
/// 调用 WebAPI 的应用模块
/// </summary>
public string AppModule { get; set; } = "System";
/// <summary>
/// 是否自动发送
/// </summary>
public bool IsAutoPost { get; set; } = false;
#endregion
}
}

View File

@@ -1,87 +0,0 @@
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));
}
}

View File

@@ -0,0 +1,65 @@
using System.ComponentModel;
namespace SHH.CameraDashboard
{
/// <summary>
/// 服务节点模型,用于表示一个可连接的服务端点
/// </summary>
public class ServiceNodeModel : INotifyPropertyChanged
{
#region --- ---
/// <summary>
/// 获取或设置服务节点的名称
/// </summary>
public string ServiceNodeName { get; set; } = string.Empty;
/// <summary>
/// 获取或设置服务节点的IP地址
/// </summary>
public string ServiceNodeIp { get; set; } = string.Empty;
/// <summary>
/// 获取或设置服务节点的端口号
/// </summary>
public string ServiceNodePort { get; set; } = string.Empty;
#endregion
#region --- ---
private string _status = "未检测";
/// <summary>
/// 获取或设置服务节点的连接状态(如:未检测、在线、离线)
/// </summary>
public string Status
{
get => _status;
set
{
_status = value;
OnPropertyChanged(nameof(Status));
}
}
#endregion
#region --- INotifyPropertyChanged ---
/// <summary>
/// 当属性值更改时发生
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 引发 <see cref="PropertyChanged"/> 事件
/// </summary>
/// <param name="propertyName">已更改的属性名称</param>
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}

View File

@@ -1,60 +0,0 @@

namespace SHH.CameraDashboard
{
/// <summary>
/// 视频源逻辑状态枚举
/// 描述了从配置加载到视频流稳定输出的完整生命周期
/// </summary>
public enum VideoSourceStatus
{
/// <summary>
/// 已断开/初始状态。
/// 此时资源已释放,尚未执行 Login 或 Start 操作。
/// </summary>
Disconnected,
/// <summary>
/// 正在尝试建立网络连接。
/// 此时正在进行 Socket 握手或探测设备 IP 是否可达。
/// </summary>
Connecting,
/// <summary>
/// 正在进行身份验证。
/// 连接已建立,正在提交 UserName/Password 调用 SDK 的 Login 接口。
/// </summary>
Authorizing,
/// <summary>
/// 已登录/待机。
/// 登录成功并获取到了设备元数据Metadata但尚未启动预览RealPlay
/// 适用于“仅管理,不看画面”的场景。
/// </summary>
Connected,
/// <summary>
/// 正常取流播放中
/// </summary>
Playing,
/// <summary>
/// 正在取流/正常运行中。
/// 预览句柄已开启,取流回调函数正在持续接收数据帧并进行解码。
/// </summary>
Streaming,
/// <summary>
/// 自动重连中。
/// 检测到网络抖动或心跳丢失SDK 正在尝试内部恢复,此时视频流可能处于停滞状态。
/// </summary>
Reconnecting,
/// <summary>
/// 故障/异常状态。
/// 发生了不可恢复的错误(如密码错误、最大连接数限制、设备强制离线)。
/// 进入此状态通常需要人工干预或调用 Stop 后重新 Start。
/// </summary>
Faulted
}
}

View File

@@ -0,0 +1,321 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraEdit"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DataContext="{d:DesignInstance Type=local:CameraEditViewModel}"
d:DesignHeight="700"
d:DesignWidth="500"
mc:Ignorable="d">
<Grid Margin="15" Background="{DynamicResource Brush.Bg.Window}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Margin="0,0,0,15"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="设备参数配置" />
<ScrollViewer
Grid.Row="1"
Padding="0,0,5,0"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<StackPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style TargetType="TextBox">
<Setter Property="Height" Value="26" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Input}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
</Style>
</StackPanel.Resources>
<GroupBox
Margin="0,0,0,15"
Padding="10"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Text.Primary}"
Header="基础身份 (Identity)">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<WrapPanel Grid.ColumnSpan="2">
<TextBlock Margin="0,5" Text="设备 ID *" />
<TextBox
Width="80"
Margin="30,5"
Text="{Binding EditingDto.Id}" />
<TextBlock Margin="0,5" Text="品牌类型" />
<ComboBox
Width="180"
Margin="5,5,0,5"
DisplayMemberPath="Label"
ItemsSource="{Binding BrandOptions}"
SelectedValue="{Binding EditingDto.Brand}"
SelectedValuePath="Value" />
</WrapPanel>
<TextBlock
Grid.Row="1"
Margin="0,5"
Text="设备名称" />
<TextBox
Grid.Row="1"
Grid.Column="1"
Margin="0,5"
Text="{Binding EditingDto.Name}" />
<TextBlock
Grid.Row="3"
Margin="0,5"
Text="安装位置" />
<TextBox
Grid.Row="3"
Grid.Column="1"
Margin="0,5"
Text="{Binding EditingDto.Location}" />
</Grid>
</GroupBox>
<GroupBox
Margin="0,0,0,15"
Padding="10"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Text.Primary}"
Header="核心连接 (Connectivity)">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="60" />
<ColumnDefinition Width="60" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Margin="0,5" Text="IP 地址 *" />
<TextBox
Grid.Column="1"
Margin="0,5,10,5"
Text="{Binding EditingDto.IpAddress}" />
<TextBlock
Grid.Column="2"
Margin="0,5"
HorizontalAlignment="Right"
Text="端口 " />
<TextBox
Grid.Column="3"
Margin="5,5,0,5"
Text="{Binding EditingDto.Port}" />
<WrapPanel Grid.Row="1" Grid.ColumnSpan="4">
<TextBlock
Width="80"
Margin="0,5,0,5"
Text="用户名" />
<TextBox
Width="120"
Margin="0,5,0,5"
Text="{Binding EditingDto.Username}" />
<TextBlock
Width="40"
Margin="33,5,10,5"
Text="密码" />
<TextBox
Width="140"
Margin="0,5,0,5"
Text="{Binding EditingDto.Password}" />
</WrapPanel>
<WrapPanel Grid.Row="2" Grid.ColumnSpan="4">
<TextBlock
Width="80"
Margin="0,5,0,5"
Text="句柄" />
<TextBox
Width="70"
Margin="0,5,0,5"
Text="{Binding EditingDto.RenderHandle}" />
<TextBlock
Width="40"
Margin="23,5,0,5"
Text="通道号" />
<TextBox
Width="40"
Margin="10,5,0,5"
Text="{Binding EditingDto.ChannelIndex}" />
<TextBlock
Width="50"
Margin="20,0,10,0"
Text="码流类型" />
<ComboBox
Width="80"
Margin="0,5,0,5"
SelectedIndex="{Binding EditingDto.StreamType}">
<ComboBoxItem Content="主码流" />
<ComboBoxItem Content="子码流" />
</ComboBox>
</WrapPanel>
</Grid>
</GroupBox>
<GroupBox
Margin="0,0,0,15"
Padding="10"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Text.Primary}"
Header="关联主板 (Metadata)">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="60" />
<ColumnDefinition Width="60" />
</Grid.ColumnDefinitions>
<TextBlock Margin="0,5" Text="主板 IP" />
<TextBox
Grid.Column="1"
Margin="0,5,10,5"
Text="{Binding EditingDto.MainboardIp}" />
<TextBlock
Grid.Column="2"
Margin="0,5"
HorizontalAlignment="Right"
Text="端口 " />
<TextBox
Grid.Column="3"
Margin="5,5,0,5"
Text="{Binding EditingDto.MainboardPort}" />
</Grid>
</GroupBox>
<GroupBox
Margin="0,0,0,15"
Padding="10"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Text.Primary}"
Header="运行参数 (Runtime)">
<WrapPanel>
<TextBlock
Width="30"
Margin="0,5,0,5"
Text="RTSP" />
<TextBox
Width="360"
Height="60"
Margin="20,5,0,5"
VerticalContentAlignment="Top"
Text="{Binding EditingDto.RtspPath}" />
</WrapPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
<StackPanel
Grid.Row="2"
Margin="0,10,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
Width="100"
Height="32"
Margin="0,0,10,0"
Command="{Binding SaveCommand}"
Content="保存配置"
Cursor="Hand">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.State.Success}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.9" />
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button
Width="100"
Height="32"
Command="{Binding CancelCommand}"
Content="取消"
Cursor="Hand">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// CameraEdit.xaml 的交互逻辑
/// </summary>
public partial class CameraEdit : UserControl
{
public CameraEdit()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,317 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraImageSubscription"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="600"
d:DesignWidth="900"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<local:SubscriptionTypeConverter x:Key="TypeConverter" />
</UserControl.Resources>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="20" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
Margin="0,0,0,15"
Orientation="Horizontal">
<TextBlock
Margin="0,0,8,0"
VerticalAlignment="Center"
FontSize="18"
Text="📡" />
<TextBlock
VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="订阅管理" />
<TextBlock
Margin="5,2,0,0"
VerticalAlignment="Center"
FontSize="14"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text=" (实时分发控制)" />
</StackPanel>
<Border
Grid.Row="1"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.Normal}">
<DataGrid
BorderThickness="0"
IsReadOnly="True"
ItemsSource="{Binding Subscriptions}"
SelectedItem="{Binding SelectedSubscription}"
SelectionMode="Single">
<DataGrid.Columns>
<DataGridTextColumn
Width="80"
Binding="{Binding AppId}"
Header="App ID">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn
Width="125"
Binding="{Binding Type, Converter={StaticResource TypeConverter}}"
Header="类型">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="5,0" />
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Width="70" Header="目标帧率">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border
Padding="5,1"
HorizontalAlignment="Center"
Background="{DynamicResource Brush.Bg.L3}"
CornerRadius="3"
ToolTip="配置的期望帧率">
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Inverse}"
Text="{Binding DisplayFps, StringFormat={}{0} FPS}" />
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="70" Header="实际帧率">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border
Padding="5,1"
HorizontalAlignment="Center"
CornerRadius="3">
<TextBlock
FontSize="11"
FontWeight="Bold"
Foreground="{DynamicResource Brush.State.Success}"
Text="{Binding RealFps, StringFormat={}{0:F1} FPS}" />
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="2*"
Binding="{Binding Memo}"
Header="备注">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
<Setter Property="ToolTip" Value="{Binding Memo}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTemplateColumn Width="50" Header="操作">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Height="30"
Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding AppId}"
Content="注销"
Style="{StaticResource Btn.Ghost.Danger}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
<Border
Grid.Row="3"
Padding="20"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.Normal}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="30" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Row="0"
Grid.ColumnSpan="5"
Margin="0,0,0,15"
Orientation="Horizontal">
<TextBlock
Margin="0,0,5,0"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="" />
<TextBlock
FontSize="14"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="新增 / 编辑订阅" />
</StackPanel>
<TextBlock
Grid.Row="1"
Grid.Column="0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="App ID:" />
<TextBox
Grid.Row="1"
Grid.Column="1"
Margin="0,5"
Text="{Binding EditAppId}" />
<TextBlock
Grid.Row="1"
Grid.Column="3"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="类型:" />
<ComboBox
Grid.Row="1"
Grid.Column="4"
Height="{StaticResource Height.Input}"
Margin="0,5"
VerticalContentAlignment="Center"
DisplayMemberPath="Value"
ItemsSource="{Binding SubscriptionTypes}"
SelectedValue="{Binding EditType}"
SelectedValuePath="Key" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="目标帧率:" />
<Grid
Grid.Row="2"
Grid.Column="1"
Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Slider
VerticalAlignment="Center"
IsSnapToTickEnabled="True"
Maximum="25"
Minimum="1"
Value="{Binding EditFps}" />
<TextBlock
Grid.Column="1"
Margin="10,0,0,0"
VerticalAlignment="Center"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Brand}"
Text="{Binding EditFps, StringFormat={}{0} fps}" />
</Grid>
<TextBlock
Grid.Row="2"
Grid.Column="3"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="备注:" />
<TextBox
Grid.Row="2"
Grid.Column="4"
Margin="0,5"
Text="{Binding EditMemo}" />
<Grid
Grid.Row="3"
Grid.ColumnSpan="5"
Visibility="{Binding IsNetworkType, Converter={StaticResource BoolToVis}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="30" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="目标 IP:" />
<TextBox
Grid.Column="1"
Margin="0,5"
Text="{Binding EditTargetIp}" />
<TextBlock
Grid.Column="3"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="目标端口:" />
<TextBox
Grid.Column="4"
Margin="0,5"
Text="{Binding EditTargetPort}" />
</Grid>
<StackPanel
Grid.Row="4"
Grid.ColumnSpan="5"
Margin="0,15,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
Width="100"
Margin="0,0,10,0"
Command="{Binding ClearFormCommand}"
Content="重置表单"
Style="{StaticResource Btn.Ghost}" />
<Button
Width="120"
Command="{Binding SaveCommand}"
Content="提交保存"
Style="{StaticResource PrimaryBtnStyle}" />
</StackPanel>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// CameraImageSubscription.xaml 的交互逻辑
/// </summary>
public partial class CameraImageSubscription : UserControl
{
public CameraImageSubscription()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,274 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraImgProc"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DataContext="{d:DesignInstance local:CameraImgProcViewModel}"
d:DesignHeight="650"
d:DesignWidth="500"
mc:Ignorable="d">
<UserControl.Resources>
<local:BoolToMaxScaleConverter x:Key="BoolToMaxScaleConverter" />
</UserControl.Resources>
<Grid Margin="20" Background="{DynamicResource Brush.Bg.Window}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<DockPanel Margin="0,0,0,20" LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Title}" />
<Button
Width="30"
Height="30"
Command="{Binding CancelCommand}"
Content="✕"
DockPanel.Dock="Right"
Style="{DynamicResource Btn.Ghost}" />
</DockPanel>
<Border
Grid.Row="1"
Margin="0,0,0,15"
Padding="15"
Background="{DynamicResource Brush.Bg.Panel}"
CornerRadius="6">
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
<TextBlock Foreground="{DynamicResource Brush.Text.Secondary}" Text="源画面分辨率:" />
<TextBlock
Margin="10,0,0,0"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding SourceResolutionText}" />
</StackPanel>
</Border>
<GroupBox
Grid.Row="2"
Margin="0,0,0,15"
Padding="15"
BorderBrush="{DynamicResource Brush.Border}"
Foreground="{DynamicResource Brush.Text.Secondary}"
Header="分辨率调整">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DockPanel LastChildFill="False">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Primary}" Text="允许缩小 (Allow Shrink)" />
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="可将画面缩小至任意尺寸" />
</StackPanel>
<CheckBox
DockPanel.Dock="Right"
IsChecked="{Binding AllowShrink}"
Style="{DynamicResource Style.CheckBox.Switch}" />
</DockPanel>
<DockPanel
Grid.Column="1"
Margin="20,0,0,0"
LastChildFill="False">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Primary}" Text="允许放大 (Allow Enlarge)" />
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="最大支持 1080P" />
</StackPanel>
<CheckBox
Margin="5,0,0,0"
DockPanel.Dock="Right"
IsChecked="{Binding AllowEnlarge}"
IsEnabled="{Binding CanCheckExpand}"
Style="{DynamicResource Style.CheckBox.Switch}" />
</DockPanel>
</Grid>
<StackPanel Grid.Row="1" Margin="0,20,0,0">
<TextBlock
Margin="0,0,0,10"
HorizontalAlignment="Center"
Foreground="{DynamicResource Brush.State.Warning}"
Text="请先开启“允许缩小”或“允许放大”以调整分辨率">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSliderEnabled}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Grid Margin="0,0,0,15" IsEnabled="{Binding IsSliderEnabled}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<TextBlock
Margin="0,0,10,0"
VerticalAlignment="Center"
Text="缩放比" />
<Slider
Grid.Column="1"
VerticalAlignment="Center"
IsSnapToTickEnabled="True"
Maximum="{Binding AllowEnlarge, Converter={StaticResource BoolToMaxScaleConverter}}"
Minimum="10"
TickFrequency="5"
Value="{Binding ScalePercent}" />
<TextBlock
Grid.Column="2"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Brand}"
Text="{Binding ScalePercent, StringFormat={}{0:F0}%}" />
</Grid>
<Grid IsEnabled="{Binding IsSliderEnabled}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock
Margin="0,0,0,5"
FontSize="12"
Text="宽度" />
<TextBox
Height="32"
Style="{DynamicResource Style.TextBox.Search}"
Text="{Binding TargetWidth, UpdateSourceTrigger=PropertyChanged}"
TextAlignment="Center" />
</StackPanel>
<ToggleButton
Grid.Column="1"
Margin="15,18,15,0"
IsChecked="{Binding IsRatioLocked}"
ToolTip="锁定长宽比">
<Path
Width="16"
Height="16"
Data="M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 17,6V8H18M12,4A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,4Z"
Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"
Stretch="Uniform" />
</ToggleButton>
<StackPanel Grid.Column="2">
<TextBlock
Margin="0,0,0,5"
FontSize="12"
Text="高度" />
<TextBox
Height="32"
Style="{DynamicResource Style.TextBox.Search}"
Text="{Binding TargetHeight, UpdateSourceTrigger=PropertyChanged}"
TextAlignment="Center" />
</StackPanel>
</Grid>
</StackPanel>
<StackPanel Grid.Row="2" Margin="0,0,0,20">
<TextBlock
Margin="0,20,0,10"
FontSize="16"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="常用尺寸预设" />
<WrapPanel>
<WrapPanel.Resources>
<Style BasedOn="{StaticResource Btn.Ghost.Secondary}" TargetType="Button">
<Setter Property="Margin" Value="0,0,8,8" />
<Setter Property="Width" Value="Auto" />
<Setter Property="Padding" Value="6,0" />
<Setter Property="Height" Value="16" />
<Setter Property="Command" Value="{Binding ApplyPresetCommand}" />
</Style>
</WrapPanel.Resources>
<Button CommandParameter="1920x1080" Content="1080P (1920x1080)" />
<Button CommandParameter="1280x720" Content="720P (1280x720)" />
<Button CommandParameter="704x576" Content="D1 (704x576)" />
<Button CommandParameter="640x480" Content="VGA (640x480)" />
<Button CommandParameter="640x640" Content="Square (640x640)" />
<Button CommandParameter="352x288" Content="CIF (352x288)" />
</WrapPanel>
</StackPanel>
</Grid>
</GroupBox>
<StackPanel Grid.Row="3">
<TextBlock
Margin="0,0,0,10"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="高级处理" />
<Border
Padding="10"
Background="{DynamicResource Brush.Bg.Input}"
CornerRadius="4">
<DockPanel LastChildFill="False">
<StackPanel>
<TextBlock Foreground="{DynamicResource Brush.Text.Primary}" Text="图像画质增强 (Image Enhancement)" />
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="自动调节对比度与锐度" />
</StackPanel>
<CheckBox
DockPanel.Dock="Right"
IsChecked="{Binding EnhanceImage}"
Style="{DynamicResource Style.CheckBox.Switch}" />
</DockPanel>
</Border>
</StackPanel>
<StackPanel
Grid.Row="4"
Margin="0,20,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
Width="100"
Margin="0,0,10,0"
Command="{Binding CancelCommand}"
Content="取消"
Style="{DynamicResource Btn.Ghost}" />
<Button
Width="120"
Command="{Binding SaveCommand}"
Content="保存应用" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,14 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard;
/// <summary>
/// CameraImgProc.xaml 的交互逻辑
/// </summary>
public partial class CameraImgProc : UserControl
{
public CameraImgProc()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,364 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraItemTop"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="100"
d:DesignWidth="800"
mc:Ignorable="d">
<Border
Padding="20,12,20,6"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<Grid>
<Grid HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasSelection}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<TextBlock
FontSize="16"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.5"
Text="请在左侧列表中选择一个摄像头以查看详情" />
</Grid>
<Grid>
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasSelection}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120" />
<ColumnDefinition Width="210" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<WrapPanel>
<!-- 在线、离线小圆点 -->
<Border
Width="10"
Height="10"
Margin="0,0,10,0"
VerticalAlignment="Center"
CornerRadius="5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#FF5555" />
<Style.Triggers>
<DataTrigger Binding="{Binding Camera.IsPhysicalOnline}" Value="True">
<Setter Property="Background" Value="#55FF55" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<!-- 播放状态、ID号 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Border
Margin="6,2,0,0"
Padding="6,2"
VerticalAlignment="Center"
CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#10808080" />
<Style.Triggers>
<DataTrigger Binding="{Binding Camera.Status}" Value="Playing">
<Setter Property="Background" Value="#00CC6A" />
</DataTrigger>
<DataTrigger Binding="{Binding Camera.Status}" Value="Faulted">
<Setter Property="Background" Value="#FF5555" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock
FontSize="11"
FontWeight="SemiBold"
Foreground="White"
Text="{Binding Camera.Status}" />
</Border>
<TextBlock
Grid.Row="1"
Margin="6,8,0,0"
FontSize="13"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.6"
Text="ID:" />
<TextBlock
Grid.Row="1"
Margin="26,8,0,0"
FontSize="13"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="{Binding Camera.Id}" />
</Grid>
</WrapPanel>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<WrapPanel>
<!-- 设备名称 -->
<TextBlock
VerticalAlignment="Center"
FontSize="14"
FontWeight="Bold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding Camera.Name}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Camera.Name}" Value="">
<Setter Property="Text" Value="未命名" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="FontStyle" Value="Italic" />
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
<DataTrigger Binding="{Binding Camera.Name}" Value="{x:Null}">
<Setter Property="Text" Value="未命名" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="FontStyle" Value="Italic" />
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock
Margin="4,0,0,0"
FontSize="13"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.5"
Text="{Binding Camera.Brand}" />
</WrapPanel>
<!-- IP 地址 -->
<TextBlock
Grid.Row="1"
HorizontalAlignment="Left"
FontFamily="Consolas"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Camera.IpAddress}" />
</Grid>
<WrapPanel Grid.Column="2" VerticalAlignment="Center">
<!-- 分辨率 -->
<StackPanel Margin="20,0,20,0">
<TextBlock
HorizontalAlignment="Right"
FontSize="10"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.5"
Text="RESOLUTION" />
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<TextBlock
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Camera.Width}" />
<TextBlock
Margin="2,0"
VerticalAlignment="Center"
FontSize="14"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text=" x " />
<TextBlock
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Camera.Height}" />
</StackPanel>
</StackPanel>
<Border BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,1,0" />
<!-- 实际帧 -->
<StackPanel Margin="20,0,20,0">
<TextBlock
HorizontalAlignment="Right"
FontSize="10"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.5"
Text="REAL FPS" />
<TextBlock
HorizontalAlignment="Right"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Camera.RealFps}" />
</StackPanel>
</WrapPanel>
<StackPanel
Grid.Column="4"
VerticalAlignment="Center"
Orientation="Horizontal">
<Button
Width="32"
Height="32"
Margin="8,0,8,0"
Command="{Binding DeleteCommand}"
Style="{StaticResource Btn.Ghost.Danger}"
ToolTip="删除此摄像头">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.Trash}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="8,0,8,0"
Command="{Binding EditDeviceCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="编辑设备配置">
<Path
Width="18"
Height="18"
Data="{StaticResource Icon.Action.Edit}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="5,0,0,0"
Command="{Binding ImageProcessCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="图像处理">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.Action.Process}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="5,0,0,0"
Command="{Binding ImageSubscribeCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="图像订阅">
<Path
Width="18"
Height="18"
Data="{StaticResource Icon.Action.Subscribe}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="0,0,5,0"
Command="{Binding PtzCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="云台控制 (PTZ)">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.Ptz}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="8,0,8,0"
Background="Transparent"
BorderThickness="0"
Command="{Binding TogglePlayCommand}"
Cursor="Hand">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border
x:Name="bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<Path
Width="24"
Height="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Stretch="Uniform">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Data" Value="{StaticResource Icon.Action.Play}" />
<Setter Property="Fill" Value="{DynamicResource Brush.Text.Secondary}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Camera.Status}" Value="Playing">
<Setter Property="Data" Value="{StaticResource Icon.Action.Pause}" />
<Setter Property="Fill" Value="#00CC6A" />
</DataTrigger>
<DataTrigger Binding="{Binding Camera.Status}" Value="Connecting">
<Setter Property="Data" Value="{StaticResource Icon.Status.Loading}" />
<Setter Property="Fill" Value="{DynamicResource Brush.State.Warning}" />
</DataTrigger>
<DataTrigger Binding="{Binding Camera.IsPhysicalOnline}" Value="False">
<Setter Property="Data" Value="{StaticResource Icon.Status.WifiOff}" />
<Setter Property="Fill" Value="{DynamicResource Brush.State.Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#1A808080" />
</Trigger>
<DataTrigger Binding="{Binding Camera.IsPhysicalOnline}" Value="False">
<Setter Property="Cursor" Value="Arrow" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Grid>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,55 @@
using System.Windows;
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// CameraItemTop.xaml 的交互逻辑
/// </summary>
public partial class CameraItemTop : UserControl
{
// 持有 ViewModel 的实例
private CameraItemTopViewModel _viewModel;
public CameraItemTop()
{
InitializeComponent();
// 1. 初始化 ViewModel
_viewModel = new CameraItemTopViewModel();
// 2. 设置 DataContext这样 XAML 里的 {Binding Name} 就能找到 VM 里的属性
this.DataContext = _viewModel;
}
// ============================================================
// 定义依赖属性 (DependencyProperty) - 这是接收绑定的关键
// ============================================================
// 注册一个名为 "DataSource" 的依赖属性
public static readonly DependencyProperty DataSourceProperty =
DependencyProperty.Register(
nameof(DataSource), // 属性名
typeof(WebApiCameraModel), // 属性类型
typeof(CameraItemTop), // 所属类型
new PropertyMetadata(null, OnDataSourceChanged)); // 回调函数
// 这是一个普通的包装属性,方便代码访问
public WebApiCameraModel DataSource
{
get => (WebApiCameraModel)GetValue(DataSourceProperty);
set => SetValue(DataSourceProperty, value);
}
// 当外部绑定的数据发生变化时(比如用户在左侧列表点选了新项)
private static void OnDataSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as CameraItemTop;
if (control != null && control._viewModel != null)
{
// 【核心逻辑】将外部传进来的数据,转发给 ViewModel
control._viewModel.Camera = e.NewValue as WebApiCameraModel;
}
}
}
}

View File

@@ -0,0 +1,374 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraList"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="600"
d:DesignWidth="300"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</UserControl.Resources>
<Grid Background="{DynamicResource Brush.Bg.Panel}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ComboBox
Grid.Row="0"
Height="32"
Margin="10,10,10,0"
VerticalContentAlignment="Center"
ItemsSource="{Binding NodeOptions}"
SelectedItem="{Binding SelectedNode}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock FontWeight="SemiBold" Text="{Binding ServiceNodeName}" />
<TextBlock
Margin="5,0,0,0"
Foreground="Gray"
Text="{Binding ServiceNodeIp, StringFormat=' ({0})'}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding ServiceNodeIp}" Value="ALL">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Grid Grid.Row="1" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border
Padding="8,6"
Background="{DynamicResource Brush.Bg.Input}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Margin="0,0,5,0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="🔍" />
<TextBox
Grid.Column="1"
VerticalContentAlignment="Center"
Background="Transparent"
BorderThickness="0"
CaretBrush="{DynamicResource Brush.Text.Primary}"
FontSize="14"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</Border>
<WrapPanel Grid.Column="1" Margin="0,5,0,0">
<Button
Width="32"
Height="32"
Margin="5,0,0,0"
Command="{Binding AddCameraCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="添加新设备">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Plus}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="32"
Height="32"
Margin="5,0,0,0"
Command="{Binding RefreshCommand}"
Style="{StaticResource Btn.Ghost.Info}"
ToolTip="重新加载数据">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Refresh}"
Style="{StaticResource Style.IconPath}" />
</Button>
</WrapPanel>
</Grid>
<ListBox
Grid.Row="2"
HorizontalContentAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
ItemsSource="{Binding FilteredCameras}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItem="{Binding SelectedCamera}">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,0,0,1" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid Margin="0,0,0,1">
<Border
x:Name="bd"
Margin="5,0"
Background="Transparent"
CornerRadius="4"
Opacity="0" />
<Border Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource Brush.Text.Primary}" />
<Setter TargetName="bd" Property="Opacity" Value="0.1" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="{DynamicResource Brush.Brand}" />
<Setter TargetName="bd" Property="Opacity" Value="0.2" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Brand}" />
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="10"
Height="10"
Margin="0,0,10,0"
VerticalAlignment="Center"
CornerRadius="5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#FF5555" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsPhysicalOnline}" Value="True">
<Setter Property="Background" Value="#55FF55" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<WrapPanel>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
FontSize="14"
FontWeight="Bold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding Name}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Style.Triggers>
<DataTrigger Binding="{Binding Name}" Value="">
<Setter Property="Text" Value="未命名" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="FontStyle" Value="Italic" />
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
<DataTrigger Binding="{Binding Name}" Value="{x:Null}">
<Setter Property="Text" Value="未命名" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="FontStyle" Value="Italic" />
<Setter Property="Opacity" Value="0.5" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock
Margin="12,0,10,0"
HorizontalAlignment="Center"
FontSize="13"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.5"
Text="{Binding Id}" />
</WrapPanel>
<Button
Grid.Column="1"
Width="24"
Height="24"
Margin="5,0,0,0"
Background="Transparent"
BorderThickness="0"
ToolTip="{Binding Status}">
<Path
Width="14"
Height="14"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Stretch="Uniform">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Data" Value="{StaticResource Icon.Action.Play}" />
<Setter Property="Fill" Value="{DynamicResource Brush.Brand}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsPhysicalOnline}" Value="False">
<Setter Property="Data" Value="{StaticResource Icon.Status.WifiOff}" />
<Setter Property="Fill" Value="{DynamicResource Brush.State.Danger}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Connecting">
<Setter Property="Data" Value="{StaticResource Icon.Status.Loading}" />
<Setter Property="Fill" Value="{DynamicResource Brush.State.Warning}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Authorizing">
<Setter Property="Data" Value="{StaticResource Icon.Status.Loading}" />
<Setter Property="Fill" Value="{DynamicResource Brush.State.Warning}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Playing">
<Setter Property="Data" Value="{StaticResource Icon.Action.Pause}" />
<Setter Property="Fill" Value="{DynamicResource Brush.Status.Warning}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Streaming">
<Setter Property="Data" Value="{StaticResource Icon.Action.Pause}" />
<Setter Property="Fill" Value="{DynamicResource Brush.Status.Warning}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Button>
</Grid>
<StackPanel Margin="0,2,0,0" Orientation="Horizontal">
<TextBlock
FontSize="12"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding IpAddress}" />
<TextBlock
Margin="4,0"
FontSize="12"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.3"
Text="|" />
<TextBlock
FontSize="12"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.7"
Text="{Binding Brand}" />
</StackPanel>
<StackPanel Margin="0,2,0,0" Orientation="Horizontal">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Playing">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Streaming">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.6">
<Run Foreground="{DynamicResource Brush.Text.Primary}" Text="{Binding Width}" />
<Run Text="x" />
<Run Foreground="{DynamicResource Brush.Text.Primary}" Text="{Binding Height}" />
</TextBlock>
<TextBlock Text=" " />
<TextBlock
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
Opacity="0.6">
<Run Text="FPS:" />
<Run
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding RealFps}" />
</TextBlock>
</StackPanel>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid
Grid.Row="2"
Background="{DynamicResource Brush.Bg.Panel}"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<ProgressBar
Width="100"
Height="4"
Margin="0,0,0,10"
IsIndeterminate="True" />
<TextBlock
HorizontalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="正在同步数据..." />
</StackPanel>
</Grid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,17 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// CameraList.xaml 的交互逻辑
/// </summary>
public partial class CameraList : UserControl
{
public CameraList()
{
InitializeComponent();
DataContext = new CameraListViewModel();
}
}
}

View File

@@ -0,0 +1,413 @@
<UserControl
x:Class="SHH.CameraDashboard.CameraPtz"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DataContext="{d:DesignInstance local:CameraPtzViewModel}"
d:DesignHeight="600"
d:DesignWidth="500"
mc:Ignorable="d">
<Grid Margin="20" Background="{DynamicResource Brush.Bg.Window}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DockPanel Margin="0,0,0,20" LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="{Binding Title}" />
</DockPanel>
<Grid Grid.Row="1" Margin="0,0,0,30">
<Grid
Width="220"
Height="220"
Margin="50,0,0,0"
HorizontalAlignment="Left">
<Ellipse
Fill="{DynamicResource Brush.Bg.Input}"
Stroke="{DynamicResource Brush.Border}"
StrokeThickness="1" />
<Button
Width="60"
Height="60"
Margin="0,10,0,0"
VerticalAlignment="Top"
local:TouchBehavior.Action="{x:Static local:PtzAction.Up}"
Style="{StaticResource Btn.Ghost.Secondary}">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.ChevronUp}"
Style="{StaticResource Style.IconPath}" />
</Button>
<Button
Width="60"
Height="60"
Margin="0,0,0,10"
VerticalAlignment="Bottom"
local:TouchBehavior.Action="{x:Static local:PtzAction.Down}"
Style="{StaticResource Btn.Ghost.Secondary}">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.ChevronUp}"
RenderTransformOrigin="0.5,0.5"
Style="{StaticResource Style.IconPath}">
<Path.RenderTransform>
<RotateTransform Angle="180" />
</Path.RenderTransform>
</Path>
</Button>
<Button
Width="60"
Height="60"
Margin="10,0,0,0"
HorizontalAlignment="Left"
local:TouchBehavior.Action="{x:Static local:PtzAction.Left}"
Style="{StaticResource Btn.Ghost.Secondary}">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.ChevronUp}"
RenderTransformOrigin="0.5,0.5"
Style="{StaticResource Style.IconPath}">
<Path.RenderTransform>
<RotateTransform Angle="-90" />
</Path.RenderTransform>
</Path>
</Button>
<Button
Width="60"
Height="60"
Margin="0,0,10,0"
HorizontalAlignment="Right"
local:TouchBehavior.Action="{x:Static local:PtzAction.Right}"
Style="{StaticResource Btn.Ghost.Secondary}">
<Path
Width="24"
Height="24"
Data="{StaticResource Icon.ChevronUp}"
RenderTransformOrigin="0.5,0.5"
Style="{StaticResource Style.IconPath}">
<Path.RenderTransform>
<RotateTransform Angle="90" />
</Path.RenderTransform>
</Path>
</Button>
<Button
Width="60"
Height="60"
IsHitTestVisible="False"
Opacity="0.8"
Style="{StaticResource Btn.Icon.Info}">
<Path
Width="28"
Height="28"
Data="{StaticResource Icon.Ptz}"
Style="{StaticResource Style.IconPath}" />
</Button>
</Grid>
<WrapPanel
Margin="300,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<Button
Width="150"
Height="48"
Margin="0,0,5,0"
Command="{Binding SyncTimeCommand}"
Style="{StaticResource Btn.Ghost.Info}">
<StackPanel Orientation="Horizontal">
<Path
Width="32"
Height="32"
Data="{StaticResource Icon.Action.Time}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Text="一键校时" />
</StackPanel>
</Button>
<Button
Width="150"
Height="48"
Margin="5,20,0,0"
Command="{Binding RebootCommand}"
Style="{StaticResource Btn.Ghost.Danger}">
<StackPanel Orientation="Horizontal">
<Path
Width="32"
Height="32"
Data="{StaticResource Icon.Action.Reboot}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="18"
FontWeight="SemiBold"
Text="远程重启" />
</StackPanel>
</Button>
</WrapPanel>
</Grid>
<Border
Grid.Row="2"
Padding="15"
Background="{DynamicResource Brush.Bg.Input}"
CornerRadius="8">
<StackPanel>
<TextBlock
Margin="0,0,0,15"
FontSize="18"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="镜头与辅助" />
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="50" />
<RowDefinition Height="50" />
<RowDefinition Height="15" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="变倍" />
</StackPanel>
<Button
Grid.Column="1"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.ZoomOut}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Minus}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="缩小" />
</StackPanel>
</Button>
<Button
Grid.Column="2"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.ZoomIn}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Plus}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="放大" />
</StackPanel>
</Button>
<StackPanel
Grid.Row="1"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="聚焦" />
</StackPanel>
<Button
Grid.Row="1"
Grid.Column="1"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.FocusNear}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Focus.Near}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="拉近" />
</StackPanel>
</Button>
<Button
Grid.Row="1"
Grid.Column="2"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.FocusFar}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Focus.Far}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="拉远" />
</StackPanel>
</Button>
<StackPanel
Grid.Row="2"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
Margin="6,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="光圈" />
</StackPanel>
<Button
Grid.Row="2"
Grid.Column="1"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.IrisClose}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Iris.Small}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="变小" />
</StackPanel>
</Button>
<Button
Grid.Row="2"
Grid.Column="2"
Width="120"
local:TouchBehavior.Action="{x:Static local:PtzAction.IrisOpen}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Iris.Large}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="变大" />
</StackPanel>
</Button>
<Border
Grid.Row="3"
Grid.ColumnSpan="3"
Margin="0,5"
VerticalAlignment="Center"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1" />
<StackPanel
Grid.Row="4"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
Margin="6,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="雨刷" />
</StackPanel>
<Button
Grid.Row="4"
Grid.Column="1"
Width="120"
Command="{Binding StopCommand}"
CommandParameter="{x:Static local:PtzAction.Wiper}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Action.StopSmall}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="停止" />
</StackPanel>
</Button>
<Button
Grid.Row="4"
Grid.Column="2"
Width="120"
Command="{Binding StartCommand}"
CommandParameter="{x:Static local:PtzAction.Wiper}"
Style="{StaticResource Btn.Ghost.Secondary}">
<StackPanel Orientation="Horizontal">
<Path
Width="16"
Height="16"
Data="{StaticResource Icon.Action.PlaySmall}"
Style="{StaticResource Style.IconPath}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Text="启动" />
</StackPanel>
</Button>
</Grid>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// CameraPtz.xaml 的交互逻辑
/// </summary>
public partial class CameraPtz : UserControl
{
public CameraPtz()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,126 @@
<UserControl
x:Class="SHH.CameraDashboard.ServiceNodesDiagnostic"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SHH.CameraDashboard"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d">
<UserControl.Resources>
<local:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<local:InverseNullToVisibilityConverter x:Key="InverseNullToVisibilityConverter" />
</UserControl.Resources>
<Grid Background="{DynamicResource Brush.Bg.Window}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="250" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="*" MinWidth="300" />
</Grid.ColumnDefinitions>
<ListView
x:Name="LogList"
Grid.Column="0"
Background="Transparent"
BorderThickness="0"
ItemContainerStyle="{StaticResource Style.ListViewItem.Table}"
ItemsSource="{Binding Logs}"
SelectedItem="{Binding SelectedLog}">
<ListView.View>
<GridView ColumnHeaderContainerStyle="{StaticResource Style.GridViewHeader.Flat}">
<GridViewColumn
Width="150"
DisplayMemberBinding="{Binding Time, StringFormat='MM-dd HH:mm:ss fff'}"
Header="请求时间" />
<GridViewColumn
Width="60"
DisplayMemberBinding="{Binding StatusCode}"
Header="状态" />
<GridViewColumn
Width="70"
DisplayMemberBinding="{Binding ElapsedMilliseconds}"
Header="耗时 ms" />
<GridViewColumn
Width="60"
DisplayMemberBinding="{Binding Method}"
Header="动作" />
<GridViewColumn
Width="120"
DisplayMemberBinding="{Binding AppModule}"
Header="模块" />
<GridViewColumn
Width="300"
DisplayMemberBinding="{Binding Url}"
Header="URL" />
</GridView>
</ListView.View>
</ListView>
<GridSplitter
Grid.Column="1"
Width="5"
HorizontalAlignment="Stretch"
Background="{DynamicResource Brush.Bg.Panel}" />
<Grid Grid.Column="2" Background="{DynamicResource Brush.Bg.Input}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<DockPanel LastChildFill="False">
<StackPanel Orientation="Horizontal">
<RadioButton
Command="{Binding SwitchTabCommand}"
CommandParameter="0"
Content="Request"
GroupName="Logs"
IsChecked="True"
Style="{StaticResource TabRadioStyle}" />
<RadioButton
Command="{Binding SwitchTabCommand}"
CommandParameter="1"
Content="Response"
GroupName="Logs"
Style="{StaticResource TabRadioStyle}" />
</StackPanel>
</DockPanel>
</Border>
<TextBox
Grid.Row="1"
IsReadOnly="True"
Style="{StaticResource Style.TextBox.CodeEditor}"
Text="{Binding DisplayContent}"
Visibility="{Binding SelectedLog, Converter={StaticResource NullToVisibilityConverter}}" />
<StackPanel
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding SelectedLog, Converter={StaticResource InverseNullToVisibilityConverter}}">
<TextBlock Foreground="{DynamicResource Brush.Text.Secondary}" Text="请选择一条日志查看详情" />
</StackPanel>
</Grid>
</Grid>
<CheckBox
Margin="15,0,0,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Content="过滤自动发送"
Foreground="{DynamicResource Brush.Text.Secondary}"
IsChecked="{Binding IsFilterAutoLogs}"
Style="{DynamicResource Style.CheckBox.Switch}" />
</Grid>
</UserControl>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// ServiceNodesDiagnostic.xaml 的交互逻辑
/// </summary>
public partial class ServiceNodesDiagnostic : UserControl
{
public ServiceNodesDiagnostic()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,126 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
namespace SHH.CameraDashboard;
public class ServiceNodesViewModel : INotifyPropertyChanged, IOverlayClosable, IDisposable
{
// 接口要求的关闭事件
public event Action? RequestClose;
// 日志数据集合
public ObservableCollection<LogWebApiModel> Logs { get; } = new ObservableCollection<LogWebApiModel>();
private LogWebApiModel _selectedLog;
public LogWebApiModel SelectedLog
{
get => _selectedLog;
set
{
_selectedLog = value;
OnPropertyChanged();
UpdateDisplayContent(); // 选中项改变时更新右侧文本
}
}
private string _displayContent;
public string DisplayContent
{
get => _displayContent;
set { _displayContent = value; OnPropertyChanged(); }
}
private int _currentTabIndex = 0; // 0: Request, 1: Response
// =========================================================
// [新增] 1. 过滤开关属性 (绑定到界面 CheckBox)
// =========================================================
private bool _isFilterAutoLogs;
public bool IsFilterAutoLogs
{
get => _isFilterAutoLogs;
set
{
_isFilterAutoLogs = value;
OnPropertyChanged();
// 可选:切换时是否要清空现有日志?根据需求决定
// if (value) Logs.Clear();
}
}
public ICommand ClearCommand { get; }
public ICommand CloseCommand { get; }
public ICommand SwitchTabCommand { get; }
public ServiceNodesViewModel()
{
// 【关键步骤】订阅全局 API 服务的日志事件
WebApiService.Instance.OnRequestCompleted += OnNewLogReceived;
// 初始化指令
ClearCommand = new RelayCommand<object>(_ => Logs.Clear());
CloseCommand = new RelayCommand<object>(_ => RequestClose?.Invoke());
SwitchTabCommand = new RelayCommand<string>(index =>
{
_currentTabIndex = int.Parse(index);
UpdateDisplayContent();
});
}
/// <summary>
/// 当 WebApiService 产生新日志时的回调
/// </summary>
private void OnNewLogReceived(LogWebApiModel log)
{
// 检查程序是否正在退出
if (Application.Current == null) return;
if (IsFilterAutoLogs && CheckIsAutoLog(log))
{
return; // 如果是自动日志且开启了过滤,直接丢弃,不进入 UI 线程
}
// 【线程安全】必须将操作封送回 UI 线程
Application.Current.Dispatcher.InvokeAsync(() =>
{
// 1. 将最新日志插入顶部
Logs.Insert(0, log);
// 2. 限制日志数量 (例如只保留最近 500 条),防止内存溢出
if (Logs.Count > 500)
{
Logs.RemoveAt(Logs.Count - 1);
}
// 3. (可选) 如果你想让列表自动滚动,可以在 View 的 CodeBehind 里做,或者在这里处理选中项逻辑
}, DispatcherPriority.Background);
}
private bool CheckIsAutoLog(LogWebApiModel log)
{
if (log.IsAutoPost) return true;
return false;
}
private void UpdateDisplayContent()
{
if (SelectedLog == null)
{
DisplayContent = string.Empty;
return;
}
// 根据当前选中的 Tab 显示请求或响应内容
DisplayContent = _currentTabIndex == 0 ? SelectedLog.RequestData : SelectedLog.ResponseData;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
public void Dispose()
{
WebApiService.Instance.OnRequestCompleted -= OnNewLogReceived;
}
}

View File

@@ -1,10 +1,10 @@
<UserControl
x:Class="SHH.CameraDashboard.WizardControl"
x:Class="SHH.CameraDashboard.WizardClientsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Width="800"
Width="850"
Height="550"
mc:Ignorable="d">
@@ -12,7 +12,7 @@
Background="{DynamicResource Brush.Bg.Window}"
BorderBrush="{DynamicResource Brush.Border.Focus}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.Normal}">
CornerRadius="{DynamicResource Radius.Normal}">
<Grid Margin="25">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -24,17 +24,17 @@
<DockPanel LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontSize="{StaticResource Size.Font.Huge}"
FontSize="{DynamicResource Size.Font.Huge}"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="📡 服务节点配置" />
<Button
Width="30"
Height="30"
Click="Cancel_Click"
Command="{Binding CancelCommand}"
Content="✕"
DockPanel.Dock="Right"
Style="{StaticResource Btn.Ghost}" />
Style="{DynamicResource Btn.Ghost}" />
</DockPanel>
<TextBlock
Margin="0,10,0,0"
@@ -59,14 +59,15 @@
FontWeight="Bold"
Text="节点清单" />
<Button
Width="90"
Height="26"
Padding="15,0"
Background="{DynamicResource Brush.Accent}"
BorderThickness="0"
Click="AddNode_Click"
Command="{Binding AddNodeCommand}"
Content="+ 新增一行"
DockPanel.Dock="Right"
Style="{StaticResource {x:Type Button}}" />
Foreground="White" />
</DockPanel>
</Border>
@@ -76,13 +77,14 @@
Background="Transparent"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
ItemContainerStyle="{StaticResource Style.ListViewItem.Table}">
ItemContainerStyle="{DynamicResource Style.ListViewItem.Table}"
ItemsSource="{Binding ServiceNodes}">
<ListView.View>
<GridView ColumnHeaderContainerStyle="{StaticResource Style.GridViewHeader.Flat}">
<GridView ColumnHeaderContainerStyle="{DynamicResource Style.GridViewHeader.Flat}">
<GridViewColumn Width="180" Header="节点名称">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{DynamicResource Style.TextBox.InTable}" Text="{Binding ServiceNodeName, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
@@ -90,7 +92,7 @@
<GridViewColumn Width="180" Header="IP 地址">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Ip, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{DynamicResource Style.TextBox.InTable}" Text="{Binding ServiceNodeIp, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
@@ -98,11 +100,10 @@
<GridViewColumn Width="100" Header="端口">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Port, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{DynamicResource Style.TextBox.InTable}" Text="{Binding ServiceNodePort, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="180" Header="连通性状态">
<GridViewColumn.CellTemplate>
<DataTemplate>
@@ -114,8 +115,28 @@
<TextBlock
VerticalAlignment="Center"
FontWeight="Bold"
Foreground="{Binding StatusColor}"
Text="{Binding Status}" />
Text="{Binding Status}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="Gray" />
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="在线">
<Setter Property="Foreground" Value="{DynamicResource Brush.Status.Success}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="离线">
<Setter Property="Foreground" Value="{DynamicResource Brush.Status.Danger}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="正在检测...">
<Setter Property="Foreground" Value="{DynamicResource Brush.Accent}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="警告">
<Setter Property="Foreground" Value="{DynamicResource Brush.Status.Warning}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</DataTemplate>
</GridViewColumn.CellTemplate>
@@ -128,10 +149,11 @@
Width="45"
Height="24"
Padding="0"
Click="DeleteNode_Click"
Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=ListView}}"
CommandParameter="{Binding}"
Content="删除"
FontSize="11"
Style="{StaticResource Btn.Danger}" />
Style="{DynamicResource Btn.Danger}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
@@ -155,15 +177,15 @@
Width="110"
Height="32"
Margin="0,0,10,0"
Click="Check_Click"
Command="{Binding CheckCommand}"
Content="🔍 立即检测"
Style="{StaticResource Btn.Ghost}" />
Style="{DynamicResource Btn.Ghost}" />
<Button
Width="120"
Height="32"
Background="{DynamicResource Brush.Accent}"
BorderThickness="0"
Click="Apply_Click"
Command="{Binding ConfirmCommand}"
Content="保存并应用"
Foreground="White" />
</StackPanel>

View File

@@ -0,0 +1,19 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// WizardClientsControl.xaml 的交互逻辑
/// <para>客户端配置向导的视图,通过数据绑定与 <see cref="ViewModels.WizardClientsViewModel"/> 交互</para>
/// </summary>
public partial class WizardClientsControl : UserControl
{
/// <summary>
/// 初始化 <see cref="WizardClientsControl"/> 类的新实例
/// </summary>
public WizardClientsControl()
{
InitializeComponent();
}
}
}

View File

@@ -9,6 +9,18 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="Olds\**" />
<EmbeddedResource Remove="Olds\**" />
<None Remove="Olds\**" />
<Page Remove="Olds\**" />
</ItemGroup>
<ItemGroup>
<Compile Remove="App\WizardControl.xaml.cs" />
</ItemGroup>
<ItemGroup>
<Page Remove="App\WizardControl.xaml" />
<Page Remove="Style\Themes\Colors.Dark.xaml" />
<Page Remove="Style\Themes\Colors.Light.xaml" />
<Page Remove="Style\Themes\Sizes.xaml" />
@@ -31,8 +43,4 @@
<Resource Include="Style\Themes\Styles.xaml" />
</ItemGroup>
<ItemGroup>
<Folder Include="Converters\" />
</ItemGroup>
</Project>

View File

@@ -1,119 +0,0 @@
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<ApiLogEntry> OnApiLog;
static HttpService()
{
_client = new HttpClient();
// 设置一个合理的超时,避免界面卡死
_client.Timeout = TimeSpan.FromSeconds(5);
}
/// <summary>
/// 泛型 GET 方法
/// </summary>
public static async Task<T> GetAsync<T>(string url)
{
return await ExecuteRequestAsync<T>(new HttpRequestMessage(HttpMethod.Get, url));
}
/// <summary>
/// 泛型 POST 方法
/// </summary>
public static async Task<T> PostAsync<T>(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<T>(request, json);
}
/// <summary>
/// 专门用于【连通性测试】的方法 (不关心返回值内容,只关心通不通)
/// </summary>
public static async Task<bool> TestConnectionAsync(string url)
{
// 复用核心逻辑,但泛型传 string (忽略结果) 或 object
try
{
await ExecuteRequestAsync<string>(new HttpRequestMessage(HttpMethod.Get, url));
return true;
}
catch
{
return false;
}
}
// --- 核心执行逻辑 ---
private static async Task<T> ExecuteRequestAsync<T>(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<T>(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);
}
}
}
}

View File

@@ -0,0 +1,110 @@
using System.IO;
using System.Text;
namespace SHH.CameraDashboard
{
/// <summary>
/// 本地文件存储服务
/// 职责:
/// 1. 提供通用的、异步的对象序列化和反序列化功能。
/// 2. 将对象保存为 UTF-8 编码的 JSON 文件。
/// 3. 从 JSON 文件中读取并反序列化为对象。
/// 4. 处理文件不存在、内容为空或格式错误等常见异常情况。
/// </summary>
public static class LocalStorageService
{
#region --- ---
/// <summary>
/// 将指定对象异步保存到文件。
/// </summary>
/// <typeparam name="T">要保存的对象的类型。</typeparam>
/// <param name="filePath">目标文件的完整路径。</param>
/// <param name="data">要保存的对象实例。</param>
/// <exception cref="Exception">当保存过程中发生任何错误时抛出。</exception>
public static async Task SaveAsync<T>(string filePath, T data)
{
try
{
// 1. 确保文件所在的目录存在,如果不存在则创建
var directoryPath = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
// 2. 使用 JsonHelper 将对象序列化为 JSON 字符串
string json = JsonHelper.Serialize(data);
// 3. 使用 StreamWriter 异步将 JSON 字符串写入文件
// - `false` 表示覆盖现有文件。
// - `Encoding.UTF8` 确保文件编码正确,特别是处理中文字符时。
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8))
{
await writer.WriteAsync(json);
}
}
catch (Exception ex)
{
// 记录错误到调试输出
System.Diagnostics.Debug.WriteLine($"保存文件失败 [{filePath}]: {ex.Message}");
// 重新抛出异常,因为保存失败通常是一个需要上层处理的严重问题(例如,提示用户)
throw;
}
}
#endregion
#region --- ---
/// <summary>
/// 从文件异步加载并反序列化为指定类型的对象。
/// </summary>
/// <typeparam name="T">目标对象的类型。必须是引用类型且具有公共无参数构造函数。</typeparam>
/// <param name="filePath">源文件的完整路径。</param>
/// <returns>
/// 成功时返回反序列化后的对象。
/// 如果文件不存在、内容为空、格式错误或发生任何异常,将返回一个新创建的默认对象实例。
/// </returns>
public static async Task<T> LoadAsync<T>(string filePath) where T : class, new()
{
// 1. 如果文件不存在,直接返回一个新的默认对象
if (!File.Exists(filePath))
{
return new T();
}
try
{
// 2. 使用 StreamReader 异步读取文件全部内容
using (var reader = new StreamReader(filePath, Encoding.UTF8))
{
string json = await reader.ReadToEndAsync();
// 3. 如果文件内容为空或只包含空白字符,返回一个新的默认对象
if (string.IsNullOrWhiteSpace(json))
{
return new T();
}
// 4. 使用 JsonHelper 尝试反序列化 JSON 字符串
var result = JsonHelper.Deserialize<T>(json);
// 5. 如果反序列化结果为 null (例如JSON 内容为 "null" 或格式不匹配)
// 则返回一个新的默认对象作为兜底。
return result ?? new T();
}
}
catch (Exception ex)
{
// 记录错误到调试输出
System.Diagnostics.Debug.WriteLine($"读取文件失败 [{filePath}]: {ex.Message}");
// 发生任何异常(如文件被占用、权限问题等),都返回一个新的默认对象,
// 以确保程序能够继续运行而不会崩溃。
return new T();
}
}
#endregion
}
}

View File

@@ -1,52 +0,0 @@
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");
/// <summary>
/// 泛型保存:将数据 T 序列化为指定文件名的 JSON
/// </summary>
public static void Save<T>(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}");
}
}
/// <summary>
/// 泛型读取:从指定 JSON 文件反序列化为数据 T
/// </summary>
public static T Load<T>(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<T>(json) ?? new T();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"读取失败 [{fileName}]: {ex.Message}");
return new T();
}
}
}
}

View File

@@ -0,0 +1,18 @@
namespace SHH.CameraDashboard
{
public class ApiClient
{
// 单例模式
public static ApiClient Instance { get; } = new ApiClient();
// 各个模块的访问入口
public CameraRepository Cameras { get; }
public MonitorRepository Monitor { get; }
private ApiClient()
{
Cameras = new CameraRepository();
Monitor = new MonitorRepository();
}
}
}

View File

@@ -0,0 +1,105 @@
namespace SHH.CameraDashboard;
public partial class CameraRepository
{
#region GetListByAddressAsync
/// <summary>
/// [新增] 动态检测方法。
/// 专门用于向导界面,根据临时输入的 IP 地址和端口号来获取摄像头列表。
/// </summary>
/// <param name="ipAddress">目标服务器的 IP 地址。</param>
/// <param name="port">目标服务器的端口号。</param>
/// <param name="pageName">调用此方法的页面或步骤名称,用于日志记录。</param>
/// <returns>
/// 一个异步任务,其结果包含一个 <see cref="WebApiCameraModel"/> 对象的列表。
/// - 如果成功获取到列表,返回该列表(可能为空)。
/// - 如果获取过程中发生任何异常(如网络错误、服务器无响应等),返回 <c>null</c>。
/// </returns>
public async Task<List<WebApiCameraModel>?> GetListByAddressAsync(string ipAddress, string port, string pageName)
{
// 1. 统一管理并拼接请求 URL
// 在这里将基础地址与 API 路径组合ViewModel 无需关心具体的路由细节。
// WebApiRoutes.Cameras.Root 是一个假设的常量,例如 "/api/Cameras"。
string requestUrl = $"http://{ipAddress}:{port}{WebApiRoutes.Cameras.Root}";
try
{
// 2. 调用底层的 WebApiService 发送 GET 请求
// 传入 "Wizard" 相关的模块名,便于在日志中区分请求来源。
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, moduleName: $"摄像头列表-{pageName}", default, true);
// 3. 使用 JsonHelper 进行反序列化
// 利用 JsonHelper 的防御性实现,即使 JSON 格式错误或内容为 "null",也不会抛出异常。
var cameraList = JsonHelper.Deserialize<List<WebApiCameraModel>>(jsonResponse);
// 4. 确保返回的列表不为 null
// 如果反序列化结果为 null例如API 返回 "null"),则返回一个空列表,
// 这样调用方可以安全地遍历,而无需进行 null 检查。
return cameraList ?? new List<WebApiCameraModel>();
}
catch (Exception)
{
// 5. 捕获所有异常
// 在动态检测阶段,网络不通、服务器宕机等都是预期内的情况。
// 返回 null 作为明确的失败信号ViewModel 可以据此向用户显示“连接失败”等提示。
return null;
}
}
#endregion
#region ControlPowerAsync
/// <summary>
/// 调用服务端的接口来控制指定摄像头的启停状态(开机/关机)。
/// </summary>
/// <param name="ipAddress">目标服务器的 IP 地址。</param>
/// <param name="port">目标服务器的端口号。</param>
/// <param name="cameraId">要控制的摄像头的唯一ID。</param>
/// <param name="enable">true 表示开机false 表示关机。</param>
/// <param name="pageName">调用此方法的页面或功能名称,用于日志记录。</param>
/// <returns>
/// 一个异步任务,其结果为一个布尔值:
/// - <c>true</c>表示操作成功HTTP 响应为 200 OK
/// - <c>false</c>:表示操作失败(如网络错误、服务器返回 4xx/5xx 错误等)。
/// </returns>
public async Task<bool> ControlPowerAsync(long cameraId, bool enable, string pageName)
{
var useServiceNode = AppGlobal.UseServiceNode;
if (useServiceNode == null)
return false;
var ipAddress = useServiceNode.ServiceNodeIp;
var port = useServiceNode.ServiceNodePort;
// 1. 拼接请求 URL
// 将控制参数cameraId 和 enable作为 URL 的一部分发送。
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}/power?enabled={enable}";
try
{
// 2. 调用底层服务发送 POST 请求
// 关键点:
// a. 即使服务端不关心请求体Body`PostAsync` 方法也要求一个非 null 的 JSON 字符串。
// 因此,我们传入一个空的 JSON 对象 "{}" 作为占位符。
// b. 我们不检查响应内容,因为如果请求失败(例如,服务器返回 404 Not Found 或 500 Internal Server Error
// `WebApiService` 会抛出异常。
await WebApiService.Instance.PostAsync(requestUrl, "{}", moduleName: $"设备启停-{pageName}");
// 3. 如果代码执行到这里,说明请求成功且没有抛出异常。
return true;
}
catch (Exception ex)
{
// 4. 捕获所有可能发生的异常
// 这包括网络连接问题、DNS解析失败、服务器无响应或返回错误状态码等。
// 记录异常信息到调试输出,以便于排查问题。
System.Diagnostics.Debug.WriteLine($"控制摄像头电源失败 [IP: {ipAddress}, CameraId: {cameraId}]: {ex.Message}");
// 返回 false 通知调用方操作已失败。
return false;
}
}
#endregion
}

View File

@@ -0,0 +1,396 @@
using System.Text.Json;
namespace SHH.CameraDashboard
{
public partial class CameraRepository
{
#region GetCameraDetailsAsync
/// <summary>
/// 获取摄像头的详细配置信息。
/// 此方法封装了完整的 API 调用和 JSON 到 DTO 的手动映射过程。
/// </summary>
/// <param name="cameraId">要查询的摄像头的唯一 ID。</param>
/// <returns>
/// 一个异步任务,其结果是一个 <see cref="CameraEditInfo"/> 对象,包含摄像头的详细配置。
/// 如果配置节点信息不存在、API 请求失败或 JSON 解析失败,则返回 <c>null</c>。
/// </returns>
public async Task<CameraEditInfo?> GetCameraDetailsAsync(long cameraId)
{
// 1. 从全局数据中获取当前使用的服务节点信息
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null)
{
// 如果没有配置服务节点,则无法获取信息
return null;
}
var ipAddress = serviceNode.ServiceNodeIp;
var port = serviceNode.ServiceNodePort;
// 2. 拼接 API 请求的 URL
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}";
try
{
// 3. 调用 WebApiService 发送 GET 请求,获取原始 JSON 字符串
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, "GetDetail");
// 4. 检查返回的 JSON 是否为空
if (string.IsNullOrEmpty(jsonResponse))
{
return null;
}
// 5. 将原始 JSON 字符串手动映射到目标 DTO 对象
// 这种方式可以精确控制每个字段的转换,处理类型不匹配等问题。
return MapJsonToEditDto(jsonResponse);
}
catch (Exception ex)
{
// 6. 捕获所有可能的异常(网络错误、服务器错误等)
System.Diagnostics.Debug.WriteLine($"[Repository] 获取摄像头详情失败 (ID: {cameraId}): {ex.Message}");
return null;
}
}
#endregion
#region UpdateCameraAsync
/// <summary>
/// [新增] 更新摄像头的配置信息。
/// 此方法负责将 <see cref="CameraEditInfo"/> 对象转换为 API 所需的 JSON 格式,并发送 PUT 请求。
/// </summary>
/// <param name="dto">包含摄像头新配置信息的 DTO 对象。</param>
/// <returns>
/// 一个异步任务,其结果为一个布尔值:
/// - <c>true</c>表示更新成功HTTP 响应为 200 OK 或其他成功状态码)。
/// - <c>false</c>:表示更新失败(如 DTO 为 null、网络错误、服务器返回错误等
/// </returns>
public async Task<bool> UpdateCameraAsync(CameraEditInfo dto, string pageName)
{
// 1. 防御性检查:确保传入的 DTO 对象不为 null
if (dto == null)
{
return false;
}
// 1. 从全局数据中获取当前使用的服务节点信息
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null)
{
// 如果没有配置服务节点,则无法获取信息
return false;
}
var ipAddress = serviceNode.ServiceNodeIp;
var port = serviceNode.ServiceNodePort;
// 2. 拼接 PUT 请求的 URL
// URL 格式: http://{ip}:{port}/api/Cameras/{id}
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{dto.Id}";
try
{
// 3. 将 DTO 对象手动映射为 API 所需的 JSON 字符串
// 这一步至关重要,因为它处理了特殊的字段转换,例如:
// 将 DTO 中的 `Brand` (int) 转换为 JSON 中的 `"brand": "HikVision"` (string)。
string jsonPayload = MapDtoToEditJson(dto);
// 4. 调用 WebApiService 发送 PUT 请求
// 注意:请确保您的 `WebApiService` 类中存在 `PutAsync` 方法。
// 如果不存在,可以考虑添加一个,或者在某些 RESTful 设计中,也可以使用 `PostAsync` 代替。
await WebApiService.Instance.PutAsync(requestUrl, jsonPayload, pageName);
// 5. 如果代码执行到这里,说明请求成功且没有抛出异常
return true;
}
catch (Exception ex)
{
// 6. 捕获所有可能的异常(网络错误、服务器错误、序列化错误等)
System.Diagnostics.Debug.WriteLine($"[Repository] 更新摄像头配置失败 (ID: {dto.Id}): {ex.Message}");
return false;
}
}
#endregion
#region CreateCameraAsync
/// <summary>
/// [新增] 创建一个新的摄像头配置。
/// 此方法负责将 <see cref="CameraEditInfo"/> 对象转换为 API 所需的 JSON 格式,并发送 POST 请求到摄像头资源集合的根路径。
/// </summary>
/// <param name="nodeIp">目标服务器节点的 IP 地址。</param>
/// <param name="nodePort">目标服务器节点的端口号。</param>
/// <param name="dto">包含新摄像头配置信息的 DTO 对象。</param>
/// <returns>
/// 一个异步任务,其结果为一个布尔值:
/// - <c>true</c>表示创建成功HTTP 响应为 201 Created 或其他成功状态码)。
/// - <c>false</c>:表示创建失败(如 DTO 为 null、网络错误、服务器返回错误等
/// </returns>
public async Task<bool> CreateCameraAsync(CameraEditInfo dto, string pageName)
{
// 1. 防御性检查:确保传入的 DTO 对象不为 null
if (dto == null)
{
return false;
}
// 1. 从全局数据中获取当前使用的服务节点信息
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null)
{
// 如果没有配置服务节点,则无法获取信息
return false;
}
var ipAddress = serviceNode.ServiceNodeIp;
var port = serviceNode.ServiceNodePort;
// 2. 拼接 POST 请求的 URL
// URL 格式: http://{ip}:{port}/api/Cameras
// 注意:创建新资源通常是 POST 到资源集合的根路径,而不是单个资源的路径。
// 此时 DTO 中的 `Id` 字段通常应为 0 或默认值,由服务器在创建时生成新的唯一 ID。
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras";
try
{
// 3. 将 DTO 对象手动映射为 API 所需的 JSON 字符串
// 复用与更新操作相同的映射逻辑,确保数据格式的一致性。
string jsonPayload = MapDtoToEditJson(dto);
// 4. 调用 WebApiService 发送 POST 请求
await WebApiService.Instance.PostAsync(requestUrl, jsonPayload, pageName);
// 5. 如果代码执行到这里,说明请求成功且没有抛出异常
return true;
}
catch (Exception ex)
{
// 6. 捕获所有可能的异常(网络错误、服务器错误、序列化错误等)
System.Diagnostics.Debug.WriteLine($"[Repository] 创建新摄像头失败: {ex.Message}");
return false;
}
}
#endregion
/// <summary>
/// [新增] 删除摄像头
/// </summary>
public async Task<bool> DeleteCameraAsync(long cameraId)
{
// 1. 从全局数据中获取当前使用的服务节点信息
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null)
{
// 如果没有配置服务节点,则无法获取信息
return false;
}
var ipAddress = serviceNode.ServiceNodeIp;
var port = serviceNode.ServiceNodePort;
// URL: http://{ip}:{port}/api/Cameras/{id}
string url = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}";
try
{
// 发送 DELETE 请求
await WebApiService.Instance.DeleteAsync(url, "DeleteCamera");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Repository] 删除失败: {ex.Message}");
return false;
}
}
#region MapJsonToEditDto
/// <summary>
/// 私有辅助方法:手动解析 JSON 字符串并映射到 <see cref="CameraEditInfo"/> DTO。
/// 此方法能精确处理类型转换和提供默认值,避免自动反序列化时因类型不匹配而失败。
/// </summary>
/// <param name="json">从 API 获取的原始 JSON 字符串。</param>
/// <returns>一个填充了数据的 <see cref="CameraEditInfo"/> 对象。即使解析失败,也会返回一个对象实例。</returns>
private CameraEditInfo MapJsonToEditDto(string json)
{
// 初始化一个 DTO 对象,用于存储映射后的数据
var cameraDto = new CameraEditInfo();
try
{
// 使用 System.Text.Json 的 JsonDocument 进行高性能的只读解析
using (JsonDocument doc = JsonDocument.Parse(json))
{
JsonElement root = doc.RootElement;
// --- 基础字段映射 ---
// 使用 TryGetProperty 安全地获取属性,避免因字段不存在而抛出异常
if (root.TryGetProperty("id", out var idElement)) cameraDto.Id = idElement.GetInt64();
if (root.TryGetProperty("name", out var nameElement)) cameraDto.Name = nameElement.GetString() ?? string.Empty;
if (root.TryGetProperty("ipAddress", out var ipElement)) cameraDto.IpAddress = ipElement.GetString();
if (root.TryGetProperty("username", out var userElement)) cameraDto.Username = userElement.GetString();
if (root.TryGetProperty("password", out var passElement)) cameraDto.Password = passElement.GetString();
if (root.TryGetProperty("channelIndex", out var chElement)) cameraDto.ChannelIndex = chElement.GetInt32();
if (root.TryGetProperty("rtspPath", out var rtspElement)) cameraDto.RtspPath = rtspElement.GetString();
if (root.TryGetProperty("mainboardIp", out var mainIpElement)) cameraDto.MainboardIp = mainIpElement.GetString();
if (root.TryGetProperty("mainboardPort", out var mainPortElement)) cameraDto.MainboardPort = mainPortElement.GetInt32();
if (root.TryGetProperty("streamType", out var streamElement)) cameraDto.StreamType = streamElement.GetInt32();
// --- 类型转换 ---
// 将 JSON 中的 int 类型端口号转换为 DTO 中的 ushort 类型
if (root.TryGetProperty("port", out var portElement)) cameraDto.Port = (ushort)portElement.GetInt32();
// --- 特殊逻辑处理 ---
// 将 JSON 中的品牌字符串(如 "HikVision")转换为 DTO 中的整数枚举
if (root.TryGetProperty("brand", out var brandElement))
{
cameraDto.Brand = ParseBrandToEditInt(brandElement.GetString());
}
// --- 设置默认值 ---
// 为未从 API 获取或有特定默认值的字段设置初始值
cameraDto.Location = string.Empty;
cameraDto.UseGrayscale = false;
cameraDto.EnhanceImage = true;
cameraDto.AllowCompress = true;
cameraDto.TargetResolution = string.Empty;
}
}
catch (Exception ex)
{
// 捕获 JSON 解析或映射过程中的任何异常
System.Diagnostics.Debug.WriteLine($"[Repository] JSON 解析或映射到 DTO 时发生异常: {ex.Message}");
// 即使发生异常,也返回一个对象实例,而不是 null以防止上层代码出现空引用异常
}
return cameraDto;
}
#endregion
#region ParseBrandToEditInt
/// <summary>
/// [读] API 字符串 -> DTO 枚举值 (int)
/// 将 WebAPI 返回的 "HikVision" 等字符串解析为 DeviceBrand 枚举对应的 int
/// </summary>
private int ParseBrandToEditInt(string brandApiString)
{
if (string.IsNullOrWhiteSpace(brandApiString))
return (int)DeviceBrand.Unknown;
// 统一转小写进行匹配,防止大小写差异
var lowerName = brandApiString.Trim().ToLower();
// 1. 尝试直接通过枚举名解析 (例如 API 返回 "HikVision", 枚举也是 HikVision)
// 这样如果 API 返回 "Usb",且枚举也有 "Usb",就能自动匹配
if (Enum.TryParse(brandApiString, true, out DeviceBrand result))
{
return (int)result;
}
return (int)DeviceBrand.Unknown;
}
#endregion
#region ConvertBrandToEditString
/// <summary>
/// [写] DTO 枚举值 (int) -> API 字符串
/// 保存时,将 int 转换为 WebAPI 需要的字符串标识
/// </summary>
private string ConvertBrandToEditString(int brandValue)
{
// 将 int 强转为枚举,方便 switch
var brand = (DeviceBrand)brandValue;
switch (brand)
{
case DeviceBrand.HikVision:
return "HikVision"; // API 期望的字符串
case DeviceBrand.Dahua:
return "Dahua";
case DeviceBrand.RtspGeneral:
return "RTSP"; // 假设 API 期望全大写
case DeviceBrand.Usb:
return "Usb";
case DeviceBrand.WebSocketShine:
return "WebSocket"; // 需确认 API 期望什么
case DeviceBrand.File:
return "File";
case DeviceBrand.OnvifGeneral:
return "Onvif";
case DeviceBrand.Unknown:
default:
return "Unknown";
}
}
#endregion
#region MapDtoToEditJson
/// <summary>
/// 辅助方法:将 DTO 转换为 API 需要的 JSON 字符串
/// </summary>
private string MapDtoToEditJson(CameraEditInfo dto)
{
// 根据后端约定的最新格式构建匿名对象
// 特点:
// 1. brand 直接传 int
// 2. 扩展参数(useGrayscale等) 直接放在根节点,不需要 vendorArguments 包裹
var apiModel = new
{
id = dto.Id,
name = dto.Name ?? "",
// ★ 修正1直接使用 int 值,不再转换成 "HikVision" 字符串
brand = dto.Brand,
location = dto.Location ?? "",
ipAddress = dto.IpAddress,
port = dto.Port,
username = dto.Username ?? "",
password = dto.Password ?? "",
// ★ 修正2根据样例renderHandle 传 0 (通常这是运行时句柄,保存时无意义)
renderHandle = 0,
channelIndex = dto.ChannelIndex,
rtspPath = dto.RtspPath ?? "",
mainboardIp = dto.MainboardIp ?? "",
mainboardPort = dto.MainboardPort,
streamType = dto.StreamType,
// ★ 修正3扁平化处理直接放在根层级
useGrayscale = dto.UseGrayscale,
enhanceImage = dto.EnhanceImage,
allowCompress = dto.AllowCompress,
allowExpand = dto.AllowExpand,
targetResolution = dto.TargetResolution ?? "",
allowShrink = dto.AllowShrink,
allowEnlarge = dto.AllowEnlarge,
};
return JsonHelper.Serialize(apiModel);
}
#endregion
}
}

View File

@@ -0,0 +1,125 @@
using System.Text.Json;
namespace SHH.CameraDashboard
{
public partial class CameraRepository
{
public async Task<bool> UpdateImageProcessingAsync(CameraEditInfo dto)
{
if (dto == null) return false;
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
// 1. 使用专用的 Processing 路由
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Processing(dto.Id.ToString())}";
// 2. 解析分辨率字符串 (例如 "1920x1080" -> 1920, 1080)
int width = 1280; // 默认值
int height = 720;
if (!string.IsNullOrWhiteSpace(dto.TargetResolution))
{
var parts = dto.TargetResolution.ToLower().Split('x');
if (parts.Length == 2)
{
int.TryParse(parts[0], out width);
int.TryParse(parts[1], out height);
}
}
try
{
// 2. 构建精简的专用 JSON Payload
// 只包含图像处理相关的字段,不发送其他干扰信息
var payload = new
{
// --- 分辨率 (拆分成 int) ---
targetWidth = width,
targetHeight = height,
// 确保这些字段名与后端 Processing 接口的定义一致
useGrayscale = dto.UseGrayscale,
enhanceImage = dto.EnhanceImage,
// 缩放控制
allowShrink = dto.AllowShrink,
allowEnlarge = dto.AllowEnlarge, // 这里使用你确认过的 AllowEnlarge
// 目标分辨率
targetResolution = dto.TargetResolution ?? ""
};
string jsonBody = JsonHelper.Serialize(payload);
// 3. 发送请求
// 通常专用功能接口使用 POST 或 PUT。根据你的路由命名(Processing 是名词/动词)
// 且是对资源的部分修改PUT 的可能性较大;但如果是“执行处理”,也可能是 POST。
// 建议先试 PUT (因为是 Update 操作),如果报 405 则改 POST。
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "图像处理配置");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"更新图像处理配置失败: {ex.Message}");
return false;
}
}
/// <summary>
/// [新增] 获取指定设备的图像处理配置
/// 对应后端: GET /api/cameras/{id}/processing
/// </summary>
public async Task<CameraEditInfo> GetImageProcessingAsync(long id)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return null;
// 1. 拼接 URL: http://.../api/Cameras/1001/processing
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{id}/processing";
try
{
// 2. 发送 GET 请求
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, "获取图像处理配置");
if (string.IsNullOrEmpty(jsonResponse)) return null;
// 3. 解析后端返回的 ProcessingOptions JSON
// 注意:这里定义一个临时类来匹配后端 JSON 结构,确保字段名一致
var options = JsonHelper.Deserialize<ProcessingOptionsDto>(jsonResponse);
if (options == null) return null;
// 4. 映射回 CameraEditInfo 给 ViewModel 使用
return new CameraEditInfo
{
Id = id,
// 将宽高拼回 "1920x1080" 格式
TargetResolution = $"{options.targetWidth}x{options.targetHeight}",
// 映射开关 (注意字段名对应关系)
AllowShrink = options.enableShrink,
AllowEnlarge = options.enableExpand, // 后端叫 EnableExpand
EnhanceImage = options.enableBrightness
};
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Repository] 获取图像配置失败: {ex.Message}");
return null;
}
}
// [内部类] 用于接收后端 JSON 的 DTO
// 字段名需与后端 ProcessingOptions 序列化出来的 JSON 字段一致 (通常是 camelCase)
private class ProcessingOptionsDto
{
public int targetWidth { get; set; }
public int targetHeight { get; set; }
public bool enableShrink { get; set; }
public bool enableExpand { get; set; } // 对应后端的 EnableExpand
public bool enableBrightness { get; set; } // 对应后端的 EnableBrightness
public int brightness { get; set; }
}
}
}

View File

@@ -0,0 +1,232 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SHH.CameraDashboard
{
public partial class CameraRepository
{
// 文件: Services\WebApis\CameraReps\CameraRepository.cs
public async Task<bool> UpdateSubscriptionAsync(long cameraId, SubscriptionDto dto)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
// URL: POST /api/Cameras/{id}/subscriptions
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions";
try
{
string jsonBody = JsonHelper.Serialize(dto);
// 发送 POST 请求
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "更新订阅配置");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"更新订阅失败: {ex.Message}");
return false;
}
}
/// <summary>
/// [新增] 获取订阅列表
/// </summary>
public async Task<List<SubscriptionDto>> GetSubscriptionsAsync(long cameraId)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return new List<SubscriptionDto>();
// URL: GET /api/Cameras/{id}/subscriptions
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions";
try
{
string json = await WebApiService.Instance.GetAsync(requestUrl, "获取订阅列表");
// 如果返回空或null返回空列表
if (string.IsNullOrEmpty(json)) return new List<SubscriptionDto>();
var list = JsonHelper.Deserialize<List<SubscriptionDto>>(json);
return list ?? new List<SubscriptionDto>();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"获取订阅列表失败: {ex.Message}");
return new List<SubscriptionDto>();
}
}
/// <summary>
/// [修改] 删除/注销订阅
/// 改为标准的 DELETE 请求
/// </summary>
public async Task<bool> DeleteSubscriptionAsync(long cameraId, string appId)
{
if (string.IsNullOrWhiteSpace(appId)) return false;
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
// 拼接 URL: DELETE /api/Cameras/1001/subscriptions/Client_01
// 注意AppId 如果包含特殊字符,建议 UrlEncode但一般 ID 都是字母数字
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions/{appId}";
try
{
// 调用刚刚在 WebApiService 里加的 DeleteAsync
await WebApiService.Instance.DeleteAsync(requestUrl, "注销订阅");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"注销订阅失败: {ex.Message}");
return false;
}
}
}
/// <summary>
/// 视频流订阅配置请求对象
/// 用于定义第三方应用或内部模块对指定相机流的消费需求
/// </summary>
public class SubscriptionDto
{
/// <summary>
/// 进程唯一标识 (如 "AI_Process_01"、"Main_Display_02")
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 订阅业务类型。
/// 决定了后端流控引擎后续的资源分配(如是否开启录像机或渲染器)。
/// </summary>
public int Type { get; set; }
/// <summary>
/// 显示帧率需求 (单位: fps)
/// <para>不需要显示则设为 0控制器会自动注销该类型需求</para>
/// </summary>
public int DisplayFps
{
get => TargetFps;
set => TargetFps = value;
}
public int TargetFps { get; set; }
/// <summary>
/// [新增] 实际显示/分发帧率
/// JSON: "realFps"
/// </summary>
[JsonPropertyName("realFps")]
public double RealFps { get; set; }
/// <summary>
/// 备注信息。
/// 用于记录订阅的用途、申请人或关联业务系统。
/// </summary>
public string Memo { get; set; }
= string.Empty;
/// <summary>
/// 窗口句柄HWND
/// 仅在 Type 为 HandleDisplay 时必填。格式通常为十六进制或十进制字符串。
/// </summary>
public string Handle { get; set; }
= string.Empty;
/// <summary>
/// 录像持续时长(分钟,范围 1-60
/// 仅在 Type 为 LocalRecord 时有效。
/// </summary>
public int RecordDuration { get; set; }
/// <summary>
/// 录像文件存放绝对路径。
/// 仅在 Type 为 LocalRecord 时有效例如C:\Recordings\Room01。
/// </summary>
public string SavePath { get; set; }
= string.Empty;
/// <summary>
/// 通讯方式协议。
/// 仅在 Type 为 NetworkTrans 或 WebPush 时有效,默认为 Network。
/// </summary>
public int Protocol { get; set; }
/// <summary>
/// 目标接收端 IP 地址。
/// 仅在 Type 为 NetworkTrans 或 WebPush 且 Protocol 为 Network 时必填。
/// </summary>
public string TargetIp { get; set; }
= string.Empty;
/// <summary>
/// 目标接收端端口号。
/// 仅在 Type 为 NetworkTrans 或 WebPush 时必填。
/// </summary>
public int TargetPort { get; set; }
}
/// <summary>
/// 订阅业务类型枚举
/// 描述视频流的最终去向和业务用途,用于帧分发策略的路由决策
/// </summary>
public enum SubscriptionType
{
/// <summary>
/// 本地窗口渲染
/// <para>直接在服务器端显示器绘制(如 OpenCV Window、WinForm 控件)</para>
/// </summary>
[Description("本地窗口显示")]
LocalWindow = 0,
/// <summary>
/// 本地录像存储
/// <para>写入磁盘文件(如 MP4/AVI 格式,支持定时切割、循环覆盖)</para>
/// </summary>
[Description("本地录像存储")]
LocalRecord = 1,
/// <summary>
/// 句柄绑定显示
/// <para>渲染到指定 HWND 窗口句柄(如 SDK 硬件解码渲染到客户端控件)</para>
/// </summary>
[Description("句柄绑定显示")]
HandleDisplay = 2,
/// <summary>
/// 自定义网络传输
/// <para>通过私有协议转发给第三方系统(如工控机、告警服务器)</para>
/// </summary>
[Description("自定义网络传输")]
NetworkTrans = 3,
/// <summary>
/// 网页端推流
/// <para>转码为 Web 标准协议(如 WebRTC、HLS、RTMP供浏览器播放</para>
/// </summary>
[Description("网页端推流")]
WebPush = 4
}
/// <summary>
/// 网络传输协议类型
/// </summary>
public enum TransportProtocol
{
/// <summary> 可靠传输 (默认) </summary>
Tcp = 0,
/// <summary> 快速传输 (可能丢包/花屏) </summary>
Udp = 1,
/// <summary> 组播 (节省带宽) </summary>
Multicast = 2,
/// <summary> 内存交互 </summary>
Memory = 99,
}
}

View File

@@ -0,0 +1,90 @@
namespace SHH.CameraDashboard;
public partial class CameraRepository
{
#region PtzControlAsync
/// <summary>
/// 发送 PTZ 控制指令
/// </summary>
public async Task<bool> PtzControlAsync(long cameraId, PtzControlDto payload)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Ptz(cameraId.ToString())}";
try
{
string json = JsonHelper.Serialize(payload);
await WebApiService.Instance.PostAsync(requestUrl, json, "PTZ控制");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"PTZ控制失败: {ex.Message}");
return false;
}
}
#endregion
#region (Maintenance)
/// <summary>
/// [修正] 发送校时指令
/// 修正点:后端 [FromBody] DateTime 需要接收带双引号的标准 ISO 时间字符串
/// </summary>
public async Task<bool> SyncTimeAsync(long cameraId)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Time(cameraId.ToString())}";
try
{
// 1. 获取当前时间,格式化为标准 ISO 8601 (yyyy-MM-ddTHH:mm:ss)
string timeStr = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss");
// 2. [关键] 手动构造 JSON Body必须包含双引号
// 例如发送的内容应该是: "2025-01-01T12:00:00"
string jsonBody = $"\"{timeStr}\"";
// 3. 发送请求
// 确保你的 WebApiService.PostAsync 会设置 Content-Type: application/json
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "设备校时");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"校时失败: {ex.Message}");
return false;
}
}
/// <summary>
/// 发送远程重启指令
/// 接口定义: [HttpPost("{id}/reboot")] public async Task<IActionResult> RebootDevice(long id)
/// 该接口没有 [FromBody] 参数Body 可以为空或空 JSON
/// </summary>
public async Task<bool> RebootCameraAsync(long cameraId)
{
var serviceNode = AppGlobal.UseServiceNode;
if (serviceNode == null) return false;
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Reboot(cameraId.ToString())}";
try
{
await WebApiService.Instance.PostAsync(requestUrl, "{}", "远程重启");
return true;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"重启失败: {ex.Message}");
return false;
}
}
#endregion
}

View File

@@ -0,0 +1,139 @@
using System.ComponentModel.DataAnnotations;
namespace SHH.CameraDashboard;
// ==============================================================================
// 1. 物理与运行配置 DTO (对应 CRUD 操作)
// 用于设备新增/全量配置查询,包含基础身份、连接信息、运行参数等全量字段
// ==============================================================================
public class CameraEditInfo
{
// --- 基础身份 (Identity) ---
/// <summary>
/// 设备唯一标识
/// </summary>
[Required(ErrorMessage = "设备ID不能为空")]
[Range(1, long.MaxValue, ErrorMessage = "设备ID必须为正整数")]
public long Id { get; set; }
/// <summary>
/// 设备友好名称
/// </summary>
[MaxLength(64, ErrorMessage = "设备名称长度不能超过64个字符")]
public string Name { get; set; } = string.Empty;
/// <summary>
/// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)
/// </summary>
[Range(0, 10, ErrorMessage = "品牌类型值必须在0-10范围内")]
public int Brand { get; set; }
/// <summary>
/// 设备安装位置描述
/// </summary>
[MaxLength(128, ErrorMessage = "安装位置长度不能超过128个字符")]
public string Location { get; set; } = string.Empty;
// --- 核心连接 (Connectivity) - 修改此类参数触发冷重启 ---
/// <summary>
/// 摄像头IP地址
/// </summary>
[Required(ErrorMessage = "IP地址不能为空")]
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$",
ErrorMessage = "IPv4地址")]
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// SDK端口 (如海康默认8000)
/// </summary>
[Range(1, 65535, ErrorMessage = "端口号必须在1-65535范围内")]
public ushort Port { get; set; } = 8000;
/// <summary>
/// 登录用户名
/// </summary>
[MaxLength(32, ErrorMessage = "用户名长度不能超过32个字符")]
public string Username { get; set; } = string.Empty;
/// <summary>
/// 登录密码
/// </summary>
[MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")]
public string Password { get; set; } = string.Empty;
public long RenderHandle { get; set; }
/// <summary>
/// 通道号 (通常为1)
/// </summary>
[Range(1, 32, ErrorMessage = "通道号必须在1-32范围内")]
public int ChannelIndex { get; set; } = 1;
/// <summary>
/// RTSP流路径 (备用或非SDK模式使用)
/// </summary>
[MaxLength(256, ErrorMessage = "RTSP地址长度不能超过256个字符")]
public string RtspPath { get; set; } = string.Empty;
// --- 主板关联信息 (Metadata) ---
/// <summary>
/// 关联主板IP地址
/// </summary>
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$",
ErrorMessage = "IPv4地址")]
public string MainboardIp { get; set; } = string.Empty;
/// <summary>
/// 关联主板端口
/// </summary>
[Range(1, 65535, ErrorMessage = "主板端口号必须在1-65535范围内")]
public int MainboardPort { get; set; } = 80;
// --- 运行时参数 (Runtime Options) - 支持热更新 ---
/// <summary>
/// 码流类型 (0:主码流, 1:子码流)
/// </summary>
[Range(0, 1, ErrorMessage = "码流类型只能是0(主码流)或1(子码流)")]
public int StreamType { get; set; } = 0;
/// <summary>
/// 是否使用灰度图 (用于AI分析场景加速)
/// </summary>
public bool UseGrayscale { get; set; } = false;
/// <summary>
/// 是否启用图像增强 (去噪/锐化等)
/// </summary>
public bool EnhanceImage { get; set; } = true;
// --- 画面变换 (Transform) - 支持热更新 ---
/// <summary>
/// 是否允许图像压缩 (降低带宽占用)
/// </summary>
public bool AllowCompress { get; set; } = true;
/// <summary>
/// 是否允许图像放大 (提升渲染质量)
/// </summary>
public bool AllowExpand { get; set; } = false;
/// <summary>
/// 是否允许图像缩小
/// </summary>
public bool AllowShrink { get; set; } = false;
/// <summary>
/// 是否允许放大
/// </summary>
public bool AllowEnlarge { get; set; } = false;
// 给个默认值,比如 0 或 20
public int Brightness { get; set; } = 0;
/// <summary>
/// 目标分辨率 (格式如 1920x1080空则保持原图)
/// </summary>
[RegularExpression(@"^\d+x\d+$", ErrorMessage = "分辨率格式必须为 宽度x高度 (如 1920x1080)")]
public string TargetResolution { get; set; } = string.Empty;
}

View File

@@ -1,4 +1,6 @@
namespace SHH.CameraDashboard
using System.ComponentModel;
namespace SHH.CameraDashboard
{
/// <summary>
/// 视频源物理/逻辑品牌类型
@@ -9,6 +11,7 @@
/// <summary>
/// 未知
/// </summary>
[Description("未知")]
Unknown = 0,
/// <summary>
@@ -16,48 +19,55 @@
/// 技术路径:基于海康私有 SDK (HCNetSDK.dll / PlayCtrl.dll)。
/// 特性支持全功能控制PTZ、对讲、配置、报警回传
/// </summary>
HikVision,
[Description("海康威视")]
HikVision = 1,
/// <summary>
/// 大华 (Dahua)
/// 技术路径:基于大华私有 SDK (dhnetsdk.dll / dhplay.dll)。
/// 特性:支持全功能控制,与海康私有协议不兼容。
/// </summary>
Dahua,
[Description("大华")]
Dahua = 2,
/// <summary>
/// USB 摄像头 / 虚拟摄像头
/// 技术路径:基于 DirectShow 或 Windows Media Foundation。
/// 特性:通常通过 OpenCV (VideoCapture) 或 DirectShowLib 直接读取本地硬件引用。
/// </summary>
Usb,
[Description("USB")]
Usb = 3,
/// <summary>
/// 标准 RTSP 流媒体
/// 技术路径:基于标准 RTSP/RTP 协议 (RFC 2326)。
/// 特性:跨品牌兼容,通常使用 FFmpeg 或 GStreamer 库取流,仅支持音视频,不支持云台控制。
/// </summary>
RtspGeneral,
[Description("RTSP")]
RtspGeneral = 4,
/// <summary>
/// 三恒自研 WebSocket 流
/// 技术路径:基于 WebSocket 传输的自定义二进制或 Base64 帧。
/// 特性:专用于 Web 或云端推送场景的私有流媒体格式。
/// </summary>
WebSocketShine,
[Description("三恒WebSocket")]
WebSocketShine = 5,
/// <summary>
/// 本地视频文件
/// 技术路径:基于文件 IO 的离线解码。
/// 特性:常用于算法演示、回放模拟,支持 Mp4, Avi, Mkv 等容器格式。
/// </summary>
File,
[Description("文件")]
File = 6,
/// <summary>
/// 未知/通用标准 (ONVIF)
/// 技术路径:基于标准 ONVIF WebService。
/// 特性:用于接入非主流厂商但符合 ONVIF 标准的设备,支持基础 PTZ。
/// </summary>
OnvifGeneral
[Description("通用标准")]
OnvifGeneral = 7
}
}

View File

@@ -0,0 +1,20 @@
namespace SHH.CameraDashboard;
/// <summary>
/// PTZ 控制动作枚举
/// </summary>
public enum PtzAction
{
Stop = 0, // 停止动作
Up, // 向上
Down, // 向下
Left, // 向左
Right, // 向右
ZoomIn, // 变倍+ (放大)
ZoomOut, // 变倍- (缩小)
FocusNear, // 聚焦近
FocusFar, // 聚焦远
IrisOpen, // 光圈大
IrisClose, // 光圈小
Wiper // 雨刷
}

View File

@@ -0,0 +1,20 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace SHH.CameraDashboard;
public class PtzControlDto
{
// 确保将枚举序列化为字符串 (如 "Up") 而不是数字
[JsonConverter(typeof(StringEnumConverter))]
public PtzAction Action { get; set; }
// 是否是停止指令 (true=停止当前动作)
public bool Stop { get; set; }
// 速度 (1-10)
public int Speed { get; set; } = 5;
// 持续时间 (毫秒),用于点动模式 (如雨刷)
public int Duration { get; set; } = 0;
}

View File

@@ -0,0 +1,181 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
/// <summary>
/// 代表一个网络摄像头的模型,用于在 UI 上显示和监控其状态。
/// 实现了 <see cref="INotifyPropertyChanged"/> 接口,当属性值改变时可以通知视图进行更新。
/// </summary>
public class WebApiCameraModel : INotifyPropertyChanged
{
#region --- INotifyPropertyChanged ---
/// <summary>
/// 当属性值更改时发生。
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 引发 <see cref="PropertyChanged"/> 事件。
/// </summary>
/// <param name="propertyName">已更改的属性名称。如果未提供,则使用调用方成员的名称。</param>
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// 设置属性值的通用方法。只有当值发生变化时,才会更新字段并通知属性更改。
/// </summary>
/// <typeparam name="T">属性的类型。</typeparam>
/// <param name="field">对存储属性值的字段的引用。</param>
/// <param name="value">属性的新值。</param>
/// <param name="propertyName">属性的名称。</param>
/// <returns>如果值已更改,则返回 <c>true</c>;否则返回 <c>false</c>。</returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
#endregion
#region --- ---
private int _id;
/// <summary>
/// 获取或设置摄像头的唯一标识符。
/// </summary>
public int Id
{
get => _id;
set => SetProperty(ref _id, value);
}
private string _name = string.Empty;
/// <summary>
/// 获取或设置摄像头的自定义名称。
/// </summary>
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
private string _ipAddress = string.Empty;
/// <summary>
/// 获取或设置摄像头的 IP 地址。
/// </summary>
public string IpAddress
{
get => _ipAddress;
set => SetProperty(ref _ipAddress, value);
}
private string _brand = string.Empty;
/// <summary>
/// 获取或设置摄像头的品牌。
/// </summary>
public string Brand
{
get => _brand;
set => SetProperty(ref _brand, value);
}
private string _status = string.Empty;
/// <summary>
/// 获取或设置摄像头的综合状态描述(例如:"在线", "离线", "连接中")。
/// </summary>
public string Status
{
get => _status;
set => SetProperty(ref _status, value);
}
private bool _isOnline;
/// <summary>
/// 获取或设置一个值该值指示摄像头的逻辑连接状态API 是否可达)。
/// </summary>
public bool IsOnline
{
get => _isOnline;
set => SetProperty(ref _isOnline, value);
}
private bool _isPhysicalOnline;
/// <summary>
/// 获取或设置一个值,该值指示摄像头的物理连接状态(例如:通过 ICMP ping 检测)。
/// </summary>
public bool IsPhysicalOnline
{
get => _isPhysicalOnline;
set => SetProperty(ref _isPhysicalOnline, value);
}
private bool _isRunning;
/// <summary>
/// 获取或设置一个值,该值指示摄像头的流媒体服务是否正在运行。
/// </summary>
public bool IsRunning
{
get => _isRunning;
set => SetProperty(ref _isRunning, value);
}
private int _width;
/// <summary>
/// 获取或设置摄像头视频流的宽度(分辨率)。
/// </summary>
public int Width
{
get => _width;
set => SetProperty(ref _width, value);
}
private int _height;
/// <summary>
/// 获取或设置摄像头视频流的高度(分辨率)。
/// </summary>
public int Height
{
get => _height;
set => SetProperty(ref _height, value);
}
private int _realFps;
/// <summary>
/// 获取或设置摄像头当前的实际帧率FPS
/// </summary>
public int RealFps
{
get => _realFps;
set => SetProperty(ref _realFps, value);
}
private long _totalFrames;
/// <summary>
/// 获取或设置自启动以来收到的总帧数。
/// </summary>
public long TotalFrames
{
get => _totalFrames;
set => SetProperty(ref _totalFrames, value);
}
private int _streamType;
/// <summary>
/// 获取或设置视频流的类型例如0 = 主码流, 1 = 子码流)。
/// </summary>
public int StreamType
{
get => _streamType;
set => SetProperty(ref _streamType, value);
}
#endregion
}

View File

@@ -0,0 +1,38 @@
namespace SHH.CameraDashboard
{
public class MonitorRepository
{
private const string MODULE = "MonitorAPI";
public async Task<MonitorWebApiData> GetDashboardDataAsync()
{
try
{
var json = await WebApiService.Instance.GetAsync(WebApiRoutes.Monitor.Dashboard, MODULE);
// 1. 反序列化
var data = JsonHelper.Deserialize<MonitorWebApiData>(json);
// 2. 【核心修复】空合并运算符
// 如果 data 为 null立即 new 一个空对象返回
return data ?? new MonitorWebApiData();
}
catch
{
// 3. 【兜底】发生任何异常(断网/解析错误),返回空对象
// 保证 ViewModel 拿到永远是实例
return new MonitorWebApiData();
}
}
public async Task<string> GetSnapshotUrlAsync(string id)
{
// 假设返回的是图片 URL 或者是 Base64
return await WebApiService.Instance.GetAsync(WebApiRoutes.Monitor.Snapshot(id), MODULE);
}
}
public class MonitorWebApiData
{
}
}

View File

@@ -0,0 +1,34 @@
namespace SHH.CameraDashboard
{
public static class WebApiRoutes
{
// 摄像头模块
public static class Cameras
{
public const string Root = "/api/Cameras";
public static string ById(string id) => $"{Root}/{id}";
public static string BindHandle(string id) => $"{Root}/{id}/bind-handle";
public static string Power(string id) => $"{Root}/{id}/power";
public static string Options(string id) => $"{Root}/{id}/options";
public static string Subscriptions(string id) => $"{Root}/{id}/subscriptions";
public static string Logs(string id) => $"{Root}/{id}/logs";
public static string Processing(string id) => $"{Root}/{id}/processing";
public static string Time(string id) => $"{Root}/{id}/time";
public static string Reboot(string id) => $"{Root}/{id}/reboot";
public static string Ptz(string id) => $"{Root}/{id}/ptz";
}
// 监控模块
public static class Monitor
{
public const string Root = "/api/Monitor";
public const string All = $"{Root}/all";
public const string Dashboard = $"{Root}/dashboard";
public const string SystemLogs = $"{Root}/system-logs";
public const string UpdateProcessing = $"{Root}/update-processing";
public static string ById(string id) => $"{Root}/{id}";
public static string Snapshot(string id) => $"{Root}/snapshot/{id}";
public static string Diagnose(string id) => $"{Root}/diagnose/{id}";
}
}
}

View File

@@ -0,0 +1,299 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Diagnostics;
using System.Net.Http;
using System.Text;
namespace SHH.CameraDashboard
{
/// <summary>
/// 全局 Web API 服务
/// 职责:
/// 1. 统一管理 HttpClient 实例,避免资源耗尽。
/// 2. 封装标准的 CRUD (GET, POST, PUT, DELETE, PATCH) 操作。
/// 3. 提供详细的请求日志(耗时、状态码、请求/响应内容)。
/// 4. 处理超时和异常,提供一致的错误处理机制。
/// </summary>
public class WebApiService
{
#region --- ---
/// <summary>
/// WebAPI 设为单例
/// </summary>
public static WebApiService Instance { get; } = new WebApiService();
/// <summary>
/// 静态 HttpClient 实例。
/// 最佳实践:应用程序应共享单个 HttpClient 实例以避免套接字耗尽。
/// </summary>
private static readonly HttpClient _client;
#endregion
#region --- ---
/// <summary>
/// 当一个请求完成(无论成功或失败)时发生。
/// 可用于记录详细的 API 调用日志。
/// </summary>
public event Action<LogWebApiModel>? OnRequestCompleted;
/// <summary>
/// 日志内容的最大长度5KB防止过大的响应体撑爆内存。
/// </summary>
private const int MAX_LOG_LENGTH = 5120;
#endregion
#region --- ---
/// <summary>
/// 构造函数
/// </summary>
private WebApiService()
{
}
/// <summary>
/// 静态构造函数,用于初始化 HttpClient。
/// </summary>
static WebApiService()
{
// 配置 HttpClient 处理器
var handler = new SocketsHttpHandler
{
// 设置连接池寿命为 15 分钟。
// 这有助于解决 DNS 变更后HttpClient 仍使用旧 IP 的问题。
PooledConnectionLifetime = TimeSpan.FromMinutes(15)
};
_client = new HttpClient(handler)
{
// 设置默认请求超时时间为 15 秒。
Timeout = TimeSpan.FromSeconds(15)
};
}
#endregion
#region --- CRUD ---
/// <summary>
/// 发送一个 GET 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> GetAsync(string url, string moduleName = "System", CancellationToken token = default, bool isAutoPost = false)
{
return await ExecuteRequestAsync(url, "GET", null, moduleName, token, async () =>
{
return await _client.GetAsync(url, token);
}, isAutoPost);
}
/// <summary>
/// 发送一个 POST 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PostAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "POST", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PostAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 PUT 请求(通常用于替换整个资源)。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PutAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "PUT", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PutAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 PATCH 请求(通常用于更新资源的部分属性)。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PatchAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "PATCH", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PatchAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 DELETE 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> DeleteAsync(string url, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "DELETE", null, moduleName, token, async () =>
{
return await _client.DeleteAsync(url, token);
});
}
#endregion
#region --- ---
/// <summary>
/// 创建一个 UTF-8 编码的 JSON 内容对象。
/// </summary>
private StringContent CreateJsonContent(string json)
{
return new StringContent(json ?? "", Encoding.UTF8, "application/json");
}
/// <summary>
/// 核心执行引擎,封装了所有 HTTP 请求的通用逻辑:计时、日志、异常处理。
/// </summary>
private async Task<string> ExecuteRequestAsync(
string url,
string method,
string? requestData,
string moduleName,
CancellationToken token,
Func<Task<HttpResponseMessage>> action,
bool isAutoPost = false)
{
if (requestData == null)
requestData = "(请求数据为空)";
var log = new LogWebApiModel
{
Time = DateTime.Now,
Method = method,
Url = url,
RequestData = TruncateLog(requestData),
AppModule = moduleName,
IsAutoPost = isAutoPost,
};
var sw = Stopwatch.StartNew();
try
{
// 执行具体的 HTTP 请求
using var response = await action();
// 读取响应内容
var responseString = await response.Content.ReadAsStringAsync(token);
// 更新日志
log.StatusCode = ((int)response.StatusCode).ToString();
log.ResponseData = TruncateLog(responseString);
// 如果 HTTP 状态码不是 2xx则抛出异常
response.EnsureSuccessStatusCode();
return responseString;
}
catch (Exception ex)
{
// 捕获所有异常,更新日志状态为 "Error"
log.StatusCode = "Error";
log.ResponseData = $"Client Exception: {ex.Message}";
// 重新抛出异常,让调用者知道请求失败
throw;
}
finally
{
// 无论成功或失败,都记录耗时并触发日志事件
sw.Stop();
log.ElapsedMilliseconds = sw.ElapsedMilliseconds;
// 安全地触发事件,防止订阅者抛出异常影响主线程
try
{
OnRequestCompleted?.Invoke(log);
}
catch (Exception logEx)
{
// 记录日志系统自身的错误,但不中断程序
Debug.WriteLine($"Log System Error: {logEx.Message}");
}
}
}
/// <summary>
/// 截断过长的日志内容,并尝试格式化 JSON 以便阅读。
/// </summary>
private string TruncateLog(string? content)
{
if (string.IsNullOrEmpty(content)) return string.Empty;
// 1. 【新增步骤】尝试将内容格式化为漂亮的 JSON
// 注意:格式化会增加空格和换行,导致长度变长,这是符合预期的
string finalContent = TryFormatJson(content);
// 2. 截断逻辑 (基于格式化后的长度)
if (finalContent.Length > MAX_LOG_LENGTH)
{
// 保留前 N 个字符,并在末尾添加提示
return finalContent.Substring(0, MAX_LOG_LENGTH) +
$"\r\n\r\n... [日志过长已截断,显示前 {MAX_LOG_LENGTH} 字符]";
}
return finalContent;
}
/// <summary>
/// 尝试将字符串解析并格式化为缩进的 JSON。
/// 如果解析失败(说明不是 JSON则原样返回。
/// </summary>
private string TryFormatJson(string content)
{
// 简单的性能优化:如果不像 JSON不以 { 或 [ 开头),直接跳过
var trimmed = content.Trim();
if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
(trimmed.StartsWith("[") && trimmed.EndsWith("]")))
{
try
{
// 使用 JToken 解析,它可以处理 Object {} 和 Array []
var token = JToken.Parse(content);
// 格式化输出 (Indented = 缩进模式)
return token.ToString(Formatting.Indented);
}
catch
{
// 解析失败(可能只是普通的文本消息,或者 HTML忽略异常返回原样
return content;
}
}
return content;
}
#endregion
}
}

View File

@@ -26,6 +26,19 @@
<Color x:Key="Color.Bg.L3">#2D2D30</Color>
<Color x:Key="Color.Bg.L4">#3E3E42</Color>
<StreamGeometry x:Key="Icon.Action.Time">
M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Reboot">
M12,5V1L7,6L12,11V7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12H5A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z
</StreamGeometry>
<SolidColorBrush x:Key="Brush.State.Info" Color="#3B82F6" />
<SolidColorBrush x:Key="Brush.State.Success" Color="#10B981" />
<SolidColorBrush x:Key="Brush.State.Warning" Color="#FFA940" />
<SolidColorBrush x:Key="Brush.State.Danger" Color="#FF7875" />
<SolidColorBrush x:Key="Brush.Bg.L1" Color="{StaticResource Color.Bg.L1}" />
<SolidColorBrush x:Key="Brush.Bg.L2" Color="{StaticResource Color.Bg.L2}" />
<SolidColorBrush x:Key="Brush.Bg.L3" Color="{StaticResource Color.Bg.L3}" />

View File

@@ -0,0 +1,133 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StreamGeometry x:Key="Icon.Wizard">
M19.14,12.94c0.46-0.45,0.46-1.18,0-1.64l-4.24-4.24c-0.45-0.46-1.18-0.46-1.64,0c-0.46,0.45-0.46,1.18,0,1.64
l1.8,1.8l-9.5,9.5l-2.73-2.73l9.5-9.5l1.8,1.8c0.45,0.46,1.18,0.46,1.64,0c0.46-0.45,0.46-1.18,0-1.64L11.53,3.69
c-0.45-0.46-1.18-0.46-1.64,0s-0.46,1.18,0,1.64l1.8,1.8l-9.5,9.5c-0.63,0.63-0.63,1.65,0,2.28c0.63,0.63,1.65,0.63,2.28,0
l9.5-9.5l1.8,1.8c0.45,0.46,1.18,0.46,1.64,0C17.87,10.74,17.87,10.01,17.41,9.55l-1.8-1.8l1.8-1.8
C17.87,5.5,18.6,5.5,19.06,5.95l1.06,1.06l-1.64,1.64C18.02,9.11,18.02,9.84,18.48,10.3l1.97,1.97
C20.91,12.73,21.64,12.73,22.1,12.27l1.64-1.64l1.06,1.06c0.45,0.46,0.45,1.18,0,1.64l-4.24,4.24
c-0.45,0.46-1.18,0.46-1.64,0c-0.46-0.45-0.46-1.18,0-1.64L19.14,12.94z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Theme">
M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6.5 22,12A6,6 0 0,1 16,18C14.9,18 14,17.1 14,16V14.5C14,14.2 13.8,14 13.5,14C13.2,14 13,14.2 13,14.5V16A4,4 0 0,0 17,20C17.6,20 18.1,19.9 18.6,19.7C17.1,21.1 14.8,22 12,22M6.5,10A1.5,1.5 0 0,0 5,11.5A1.5,1.5 0 0,0 6.5,13A1.5,1.5 0 0,0 8,11.5A1.5,1.5 0 0,0 6.5,10M9.5,6A1.5,1.5 0 0,0 8,7.5A1.5,1.5 0 0,0 9.5,9A1.5,1.5 0 0,0 11,7.5A1.5,1.5 0 0,0 9.5,6M14.5,6A1.5,1.5 0 0,0 13,7.5A1.5,1.5 0 0,0 14.5,9A1.5,1.5 0 0,0 16,7.5A1.5,1.5 0 0,0 14.5,6M17.5,10A1.5,1.5 0 0,0 16,11.5A1.5,1.5 0 0,0 17.5,13A1.5,1.5 0 0,0 19,11.5A1.5,1.5 0 0,0 17.5,10Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.ChevronDown">
M7.41,8.59L12,13.17L16.59,8.59L18,10L12,16L6,10L7.41,8.59Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.ChevronUp">
M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Trash">
M9,3L8,4H4V6H20V4H16L15,3H9M18,19A2,2 0 0,1 16,21H8A2,2 0 0,1 6,19V7H18V19M8,9V19H10V9H8M14,9V19H16V9H14Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Pin.Off">
M16,9V19H8V9H16M14.5,3H9.5L8.5,4H5V6H19V4H15.5L14.5,3M18,7H6V19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Pin.Unfixed">
M2,5.27L3.28,4L20,20.72L18.73,22L15.65,18.92L14.5,17.77V22H12.9V16.17L10.5,13.77L10.25,14H9.5L5.75,17.75H4.25V16.25L8,12.5V11.75L2,5.75V5.27M16.5,9.75L13,6.25L13.5,5.75L12.5,4.75L11,6.25L6.25,1.5L13.5,1.5L14.5,2.5L15.5,1.5L22.5,8.5L21.5,9.5L22.5,10.5L16.5,9.75Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Pin.Outline">
M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12M8.83,14H15.17L14,12.83V4H10V12.83L8.83,14Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Pin.Fixed">
M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Play">
M8,5.14V19.14L19,12.14L8,5.14Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Pause">
M14,19H18V5H14M6,19H10V5H6V19Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Status.WifiOff">
M23.64,7C23.14,6.6 18.72,3 12,3C5.28,3 0.86,6.6 0.36,7L12,21.5L18.78,13.06C19.81,13.56 20.61,14.47 20.95,15.58L23.64,7M19,16C17.34,16 16,17.34 16,19C16,20.66 17.34,22 19,22C20.66,22 22,20.66 22,19C22,17.34 20.66,16 19,16M19,20C18.45,20 18,19.55 18,19C18,18.45 18.45,18 19,18C19.55,18 20,18.45 20,19C20,19.55 19.55,20 19,20Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Status.Loading">
M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Edit">
M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Refresh">
M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Plus">
M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Ptz">
M12,2L16,6H13V10H17V7L21,11L17,15V12H13V16H16L12,20L8,16H11V12H7V15L3,11L7,7V10H11V6H8L12,2Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Lens.Zoom">
M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14M12,10H10V12H9V10H7V9H9V7H10V9H12V10Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Lens.Focus">
M6,6H10V4H6A2,2 0 0,0 4,6V10H6V6M14,4V6H18V10H20V6A2,2 0 0,0 18,4H14M18,20H14V18H18V14H20V18A2,2 0 0,0 18,20M6,18H10V20H6A2,2 0 0,0 4,18V14H6V18M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Lens.Iris">
M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22M12,4A8,8 0 0,0 4,12C4,13.85 4.63,15.55 5.68,16.91L13.67,14.19L16.22,7.62C15.06,5.39 12.77,4 12,4M15.21,17.47L9.7,18.7L7.05,10.61C7.81,13.11 9.92,15.93 12.92,17.18C13.71,17.5 14.47,17.61 15.21,17.47M17.65,15.39L16.79,9.45L8.71,9.08C9.57,6.85 11.75,5.19 14.47,5.03C16.85,4.88 19.16,6.31 20.16,8.5C20.42,9.08 20.59,9.72 20.62,10.39C20.69,11.85 20.18,13.25 19.34,14.39C18.88,15.03 18.28,15.41 17.65,15.39Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Lens.Wiper">
M15,11L9.65,16.35C8.82,17.18 7.5,17.23 6.62,16.5C5.74,15.77 5.74,14.43 6.5,13.5L13.13,5.65C13.88,4.77 15.22,4.72 16.1,5.5C16.97,6.23 16.97,7.57 16.21,8.5L15,11M2.38,17.58C1.21,18.75 1.21,20.65 2.38,21.82C3.55,23 5.45,23 6.62,21.82L13.5,14.94L9.26,10.7L2.38,17.58Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Minus">
M19,13H5V11H19V13Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Focus.Far">
M14,6L10.25,11L13.1,14.8L11.5,16C9.81,13.75 7,10 7,10L1,18H23L14,6Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Focus.Near">
M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M15,12A3,3 0 1,1 12,9A3,3 0 0,1 15,12Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Iris.Small">
M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Iris.Large">
M12,6A6,6 0 0,1 18,12A6,6 0 0,1 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.StopSmall">
M6,6H18V18H6V6Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.PlaySmall">
M8,5.14V19.14L19,12.14L8,5.14Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Process">
M7.5,5.6L10,7L8.6,4.5L10,2L7.5,3.4L5,2L6.4,4.5L5,7L7.5,5.6M19.5,8L22,9.4L20.6,6.9L22,4.4L19.5,5.8L17,4.4L18.4,6.9L17,9.4L19.5,8M19.5,16L22,17.4L20.6,14.9L22,12.4L19.5,13.8L17,12.4L18.4,14.9L17,17.4L19.5,16M13,5.83L14.88,8.7L18.7,9.5L15.39,12.28L16.27,15.93L13,13.78L9.73,15.93L10.61,12.28L7.3,9.5L11.12,8.7L13,5.83Z
</StreamGeometry>
<StreamGeometry x:Key="Icon.Action.Subscribe">
M6.18,15.64A2.18,2.18 0 0,1 8.36,17.82C8.36,19 7.38,20 6.18,20C5,20 4,19 4,17.82A2.18,2.18 0 0,1 6.18,15.64M4,4.44A15.56,15.56 0 0,1 19.56,20H16.73A12.73,12.73 0 0,0 4,7.27V4.44M4,10.1A9.9,9.9 0 0,1 13.9,20H11.07A7.07,7.07 0 0,0 4,12.93V10.1Z
</StreamGeometry>
<Style x:Key="Style.IconPath" TargetType="Path">
<Setter Property="Stretch" Value="Uniform" />
<Setter Property="Fill" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Control}}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="SnapsToDevicePixels" Value="True" />
</Style>
</ResourceDictionary>

View File

@@ -1,4 +1,458 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SHH.CameraDashboard">
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<!-- 主界面 - 系统按钮 -->
<Style x:Key="Btn.TitleBar" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="0" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True"
SnapsToDevicePixels="False" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.Bg.Hover}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.Bg.L3}" />
<Setter Property="Opacity" Value="0.8" />
</Trigger>
</Style.Triggers>
</Style>
<!-- 主界面 - 系统按钮 -->
<Style
x:Key="Btn.TitleBar.Close"
BasedOn="{StaticResource Btn.TitleBar}"
TargetType="Button">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Brush.Status.Danger}" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Inverse}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#99F44747" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Inverse}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- 按钮颜色 -->
<Style x:Key="Btn.Icon.Base" TargetType="Button">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.9" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.7" />
<Setter TargetName="border" Property="RenderTransform">
<Setter.Value>
<ScaleTransform CenterX="16" CenterY="16" ScaleX="0.95" ScaleY="0.95" />
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#E0E0E0" />
<Setter Property="Foreground" Value="#A0A0A0" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="Btn.Icon.Success"
BasedOn="{StaticResource Btn.Icon.Base}"
TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.State.Success}" />
</Style>
<Style
x:Key="Btn.Icon.Danger"
BasedOn="{StaticResource Btn.Icon.Base}"
TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.State.Danger}" />
</Style>
<Style
x:Key="Btn.Icon.Warning"
BasedOn="{StaticResource Btn.Icon.Base}"
TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.State.Warning}" />
</Style>
<Style
x:Key="Btn.Icon.Info"
BasedOn="{StaticResource Btn.Icon.Base}"
TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.State.Info}" />
</Style>
<Style x:Key="Btn.Ghost.Base" TargetType="Button">
<Setter Property="Width" Value="32" />
<Setter Property="Height" Value="32" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.7" />
<Setter TargetName="border" Property="RenderTransform">
<Setter.Value>
<ScaleTransform CenterX="16" CenterY="16" ScaleX="0.95" ScaleY="0.95" />
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.3" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="Btn.Ghost.Secondary"
BasedOn="{StaticResource Btn.Ghost.Base}"
TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
</Style>
<Style
x:Key="Btn.Ghost.Danger"
BasedOn="{StaticResource Btn.Ghost.Base}"
TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Brush.State.Danger}" />
</Style>
<Style
x:Key="Btn.Ghost.Info"
BasedOn="{StaticResource Btn.Ghost.Base}"
TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Brush.State.Info}" />
</Style>
<Style
x:Key="Btn.Ghost.Success"
BasedOn="{StaticResource Btn.Ghost.Base}"
TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Brush.State.Success}" />
</Style>
<Style x:Key="DangerBtnStyle" TargetType="Button">
<Setter Property="Background" Value="#DC3545" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="Margin" Value="2" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#C82333" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="#BD2130" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="#E2E6EA" />
<Setter Property="Foreground" Value="#6C757D" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- TabItem 样式 -->
<Style x:Key="Style.TabItem.Modern" TargetType="TabItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Grid
x:Name="Root"
Background="{TemplateBinding Background}"
SnapsToDevicePixels="True">
<ContentPresenter
x:Name="ContentSite"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ContentSource="Header"
RecognizesAccessKey="True" />
<Border
x:Name="SelectionUnderline"
Height="2"
VerticalAlignment="Bottom"
Background="{DynamicResource Brush.Brand}"
Visibility="Hidden" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter TargetName="Root" Property="Background" Value="#1AFFFFFF" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Brand}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter TargetName="SelectionUnderline" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Disable}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 窄边滚动条 -->
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="8" />
<Setter Property="MinWidth" Value="8" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="true">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border
Margin="2,0"
Background="{DynamicResource Brush.Text.Disabled}"
CornerRadius="2" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="1" />
</Trigger>
<Trigger Property="IsMouseOver" Value="False">
<Setter Property="Opacity" Value="0.6" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 滑块样式 -->
<Style x:Key="Style.CheckBox.Switch" TargetType="CheckBox">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Input}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="10,0,0,0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
x:Name="Track"
Width="44"
Height="24"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
CornerRadius="12">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.6" />
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<Grid
Width="44"
Height="24"
HorizontalAlignment="Left">
<Ellipse
x:Name="Knob"
Width="20"
Height="20"
Margin="2,0,0,0"
HorizontalAlignment="Left"
Fill="White"
Stroke="#DDDDDD"
StrokeThickness="0.5">
<Ellipse.Effect>
<DropShadowEffect
BlurRadius="5"
Direction="270"
Opacity="0.3"
ShadowDepth="1.5"
Color="Black" />
</Ellipse.Effect>
<Ellipse.RenderTransform>
<TranslateTransform x:Name="KnobTranslate" X="0" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<ContentPresenter
Grid.Column="1"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Content}"
TextBlock.Foreground="{TemplateBinding Foreground}" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Track" Property="Background" Value="{DynamicResource Brush.State.Success}" />
<Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource Brush.State.Success}" />
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="KnobTranslate"
Storyboard.TargetProperty="X"
To="20"
Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="KnobTranslate"
Storyboard.TargetProperty="X"
To="0"
Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True" />
<Condition Property="IsChecked" Value="False" />
</MultiTrigger.Conditions>
<Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource Brush.Text.Primary}" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.Accent}" />
@@ -105,38 +559,6 @@
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Width" Value="10" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="Transparent">
<Track x:Name="PART_Track" IsDirectionReversed="true">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border
Margin="2,0"
Background="{DynamicResource Brush.Text.Disabled}"
CornerRadius="4" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ListView">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
@@ -173,17 +595,34 @@
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.GridViewHeader.Flat" TargetType="GridViewColumnHeader">
<Style TargetType="DataGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="RowHeaderWidth" Value="0" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="VerticalGridLinesBrush" Value="Transparent" />
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="CanUserAddRows" Value="False" />
<Setter Property="CanUserResizeRows" Value="False" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="RowHeight" Value="36" />
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Header}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="Padding" Value="10,0" />
<Setter Property="Height" Value="40" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Panel}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="BorderThickness" Value="0,0,1,1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<ControlTemplate TargetType="DataGridColumnHeader">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
@@ -197,45 +636,144 @@
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.ListViewItem.Table" TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="10,0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="Transparent" BorderThickness="0">
<ContentPresenter Margin="{TemplateBinding Padding}" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Brand}" />
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Selected}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryBtnStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.Brand}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Inverse}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="15,6" />
<Setter Property="Height" Value="32" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Opacity" Value="0.9" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Opacity" Value="0.8" />
<Setter TargetName="border" Property="RenderTransform">
<Setter.Value>
<ScaleTransform CenterX="0.5" CenterY="0.5" ScaleX="0.98" ScaleY="0.98" />
</Setter.Value>
</Setter>
<Setter TargetName="border" Property="RenderTransformOrigin" Value="0.5,0.5" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="{DynamicResource Brush.Bg.L4}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Disabled}" />
<Setter Property="Opacity" Value="0.6" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.GridViewHeader.Flat" TargetType="GridViewColumnHeader">
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Panel}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.ListViewItem.Table" TargetType="ListViewItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="5,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="MinHeight" Value="35" />
<Setter Property="BorderBrush" Value="#1AFFFFFF" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border
x:Name="Bd"
Padding="4,0"
Height="30"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Rectangle
x:Name="AccentBar"
Width="3"
Margin="-4,0,0,0"
HorizontalAlignment="Left"
Fill="{DynamicResource Brush.Accent}"
Visibility="Collapsed" />
<GridViewRowPresenter VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Grid>
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="true">
<GridViewRowPresenter VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
<Trigger Property="IsMouseOver" Value="true">
<Setter TargetName="Bd" Property="Background" Value="#1AFFFFFF" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Bg.L4}" />
<Setter TargetName="AccentBar" Property="Visibility" Value="Visible" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Accent}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="SemiBold" />
<Trigger Property="IsSelected" Value="true">
<Setter TargetName="Bd" Property="Background" Value="#33007ACC" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Brush.Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -252,7 +790,7 @@
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="30" />
<Setter Property="Height" Value="36" />
</Style>
<Style x:Key="Style.TextBox.CodeEditor" TargetType="TextBox">

View File

@@ -0,0 +1,177 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public class CameraEditViewModel : INotifyPropertyChanged
{
// 编辑中的数据副本
private CameraEditInfo _editingDto;
// 定义一个事件,通知 View 关闭窗口(或者 UserControl 所在的宿主)
public event Action<bool, CameraEditInfo> RequestClose;
public CameraEditInfo EditingDto
{
get => _editingDto;
set { _editingDto = value; OnPropertyChanged(); }
}
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
private bool _isAdd = false;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="sourceDto">如果是编辑模式,传入源对象;如果是新增,传入 null</param>
public CameraEditViewModel(CameraEditInfo sourceDto = null)
{
// 初始化枚举列表
InitBrandOptions();
if (sourceDto != null)
{
// 编辑模式:深拷贝数据,避免直接修改源对象
EditingDto = CloneDto(sourceDto);
}
else
{
// 新增模式:创建默认对象
EditingDto = new CameraEditInfo
{
Name = "新设备",
Port = 8000,
ChannelIndex = 1
};
}
if (EditingDto.Id == 0)
_isAdd = true;
SaveCommand = new RelayCommand(ExecuteSave, CanSave);
CancelCommand = new RelayCommand(ExecuteCancel);
}
private bool CanSave(object obj)
{
// 简单验证ID必须大于0IP不能为空
if (EditingDto == null) return false;
return EditingDto.Id > 0 && !string.IsNullOrWhiteSpace(EditingDto.IpAddress);
}
private bool IsSaving;
private async void ExecuteSave(object obj)
{
if (IsSaving) return;
try
{
IsSaving = true; // 开启 Loading
bool isSuccess = false;
if (_isAdd)
{
isSuccess = await ApiClient.Instance.Cameras.CreateCameraAsync(EditingDto, "摄像头-新增");
}
else
{
isSuccess = await ApiClient.Instance.Cameras.UpdateCameraAsync(EditingDto, "摄像头-编辑");
_isAdd = false;
}
// 1. 调用 Repository (ApiClient)
// 注意:这里调用的是我们刚才在 Repository 中定义的 UpdateCameraAsync
if (isSuccess)
{
// 2. 成功:通知外部关闭面板,并刷新列表
RequestClose?.Invoke(true, EditingDto);
}
else
{
// 3. 失败:弹窗提示
MessageBox.Show("保存失败,请检查服务节点日志。", "系统提示", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
catch (Exception ex)
{
MessageBox.Show($"保存过程中发生异常:\n{ex.Message}", "系统错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsSaving = false; // 关闭 Loading
}
}
private void ExecuteCancel(object obj)
{
// false 表示取消操作
RequestClose?.Invoke(false, null);
}
/// <summary>
/// 手动深拷贝 DTO防止引用传递导致界面未点保存就修改了列表
/// 生产环境建议用 AutoMapper 或 JSON 序列化实现
/// </summary>
private CameraEditInfo CloneDto(CameraEditInfo source)
{
return new CameraEditInfo
{
Id = source.Id,
Name = source.Name,
Brand = source.Brand,
Location = source.Location,
IpAddress = source.IpAddress,
Port = source.Port,
Username = source.Username,
Password = source.Password,
RenderHandle = source.RenderHandle,
ChannelIndex = source.ChannelIndex,
RtspPath = source.RtspPath,
MainboardIp = source.MainboardIp,
MainboardPort = source.MainboardPort,
StreamType = source.StreamType,
UseGrayscale = source.UseGrayscale,
EnhanceImage = source.EnhanceImage,
AllowCompress = source.AllowCompress,
AllowExpand = source.AllowExpand,
TargetResolution = source.TargetResolution
};
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
// 1. 定义一个简单的内部类或结构,用于 ComboBox 显示
public class BrandOption
{
public string Label { get; set; } // 显示的文本 (Description)
public int Value { get; set; } // 实际的值 (int)
}
// 2. 数据源属性
public List<BrandOption> BrandOptions { get; private set; }
private void InitBrandOptions()
{
// 遍历 DeviceBrand 枚举的所有值
BrandOptions = Enum.GetValues(typeof(DeviceBrand))
.Cast<DeviceBrand>()
.Select(e => new BrandOption
{
// 获取 Description ("海康威视")
Label = EnumHelper.GetDescription(e),
// 获取 int 值 (1)
Value = (int)e
})
.ToList();
}
}
}

View File

@@ -0,0 +1,253 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public class CameraImageSubscriptionViewModels : INotifyPropertyChanged
{
private readonly WebApiCameraModel _camera;
public event Action RequestClose;
// 用于绑定 ComboBox 的类型列表
public Dictionary<int, string> SubscriptionTypes { get; } = new Dictionary<int, string>
{
{ 0, "本地窗口预览" },
{ 1, "本地录像" },
{ 2, "句柄渲染 (嵌入)" },
{ 3, "网络转发 (TCP/UDP)" },
{ 4, "Web 推流" }
};
// --- 数据源 ---
public ObservableCollection<SubscriptionDto> Subscriptions { get; set; } = new ObservableCollection<SubscriptionDto>();
// --- 编辑表单属性 ---
private string _editAppId;
public string EditAppId
{
get => _editAppId;
set { _editAppId = value; OnPropertyChanged(); }
}
private int _editType;
public int EditType
{
get => _editType;
set
{
_editType = value;
OnPropertyChanged();
// 切换类型时,控制 UI 上 IP/Port 输入框的显示
OnPropertyChanged(nameof(IsNetworkType));
OnPropertyChanged(nameof(IsRecordType));
}
}
private int _editFps = 25;
public int EditFps
{
get => _editFps;
set { _editFps = value; OnPropertyChanged(); }
}
private string _editMemo;
public string EditMemo
{
get => _editMemo;
set { _editMemo = value; OnPropertyChanged(); }
}
// 网络参数
private string _editTargetIp = "127.0.0.1";
public string EditTargetIp
{
get => _editTargetIp;
set { _editTargetIp = value; OnPropertyChanged(); }
}
private int _editTargetPort = 8080;
public int EditTargetPort
{
get => _editTargetPort;
set { _editTargetPort = value; OnPropertyChanged(); }
}
// --- 界面显隐控制 ---
// 只有类型是 3或4 时显示网络设置
public bool IsNetworkType => EditType == 3 || EditType == 4;
// 只有类型是 1 时显示录像设置 (这里简化,暂时只演示网络显隐)
public bool IsRecordType => EditType == 1;
// --- 命令 ---
public ICommand LoadCommand { get; }
public ICommand SaveCommand { get; }
public ICommand DeleteCommand { get; }
public ICommand ClearFormCommand { get; }
public CameraImageSubscriptionViewModels(WebApiCameraModel camera)
{
_camera = camera;
LoadCommand = new RelayCommand(ExecuteLoad);
SaveCommand = new RelayCommand(ExecuteSave);
DeleteCommand = new RelayCommand(ExecuteDelete);
ClearFormCommand = new RelayCommand(obj => ResetForm());
// 初始化加载
ExecuteLoad(null);
}
private async void ExecuteLoad(object obj)
{
var list = await ApiClient.Instance.Cameras.GetSubscriptionsAsync(_camera.Id);
Subscriptions.Clear();
foreach (var item in list)
{
Subscriptions.Add(item);
}
}
private async void ExecuteSave(object obj)
{
// 1. 基础校验
if (string.IsNullOrWhiteSpace(EditAppId))
{
MessageBox.Show("AppId 不能为空", "提示");
return;
}
// 2. 构建 DTO
// 注意:这里需要根据你 DTO 的定义进行类型转换和默认值处理
var dto = new SubscriptionDto
{
AppId = EditAppId,
// ★★★ 修改:直接赋值 int不需要强转枚举 ★★★
Type = EditType,
DisplayFps = EditFps,
Memo = EditMemo ?? string.Empty,
// ... 网络参数 ...
TargetIp = IsNetworkType ? (EditTargetIp ?? string.Empty) : string.Empty,
TargetPort = IsNetworkType ? EditTargetPort : 0,
// ★★★ 修改:直接赋值 int ★★★
Protocol = 0, // 0 代表 TCP
// ... 其他参数 ...
Handle = string.Empty,
SavePath = ((SubscriptionType)EditType == SubscriptionType.LocalRecord) ? @"C:\Temp\Video" : string.Empty,
RecordDuration = 0
};
// 3. 发送请求
bool success = await ApiClient.Instance.Cameras.UpdateSubscriptionAsync(_camera.Id, dto);
if (success)
{
MessageBox.Show("保存成功", "提示");
ExecuteLoad(null); // 刷新列表
// ResetForm(); // 可选:保存后清空表单
}
else
{
MessageBox.Show("保存失败,请检查网络或参数。", "错误");
}
}
private async void ExecuteDelete(object parameter)
{
// 支持两种方式删除:
// 1. 传入 parameter (点击列表中的删除按钮)
// 2. 根据当前表单的 AppId (如果在编辑模式)
string appIdToDelete = parameter as string;
if (string.IsNullOrEmpty(appIdToDelete)) return;
if (MessageBox.Show($"确定要停止并注销 [{appIdToDelete}] 吗?", "确认", MessageBoxButton.YesNo) != MessageBoxResult.Yes)
return;
// 构造删除请求:关键是 DisplayFps = 0
var dto = new SubscriptionDto
{
AppId = appIdToDelete,
Type = 0,
DisplayFps = 0, // <--- 核心0 代表注销
Memo = "User Deleted"
};
bool success = await ApiClient.Instance.Cameras.UpdateSubscriptionAsync(_camera.Id, dto);
if (success)
{
ExecuteLoad(null);
}
}
private void ResetForm()
{
// 生成一个随机 AppId 方便测试
EditAppId = $"Client_{DateTime.Now:HHmmss}";
EditType = 0;
EditFps = 25;
EditMemo = "";
EditTargetIp = "127.0.0.1";
EditTargetPort = 8080;
}
// --- INotifyPropertyChanged 实现 (略,使用你的基类或标准实现) ---
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// 1. 添加一个字段
private SubscriptionDto _selectedSubscription;
// 2. 添加 SelectedSubscription 属性
public SubscriptionDto SelectedSubscription
{
get => _selectedSubscription;
set
{
// 如果两次选中同一个,不做处理
if (_selectedSubscription == value) return;
_selectedSubscription = value;
OnPropertyChanged();
// ★★★ 核心逻辑:一旦选中项发生变化,就填充表单 ★★★
if (_selectedSubscription != null)
{
FillForm(_selectedSubscription);
}
}
}
// 3. 编写填充表单的辅助方法
private void FillForm(SubscriptionDto dto)
{
// 这里我们将 DTO 的值“复制”到编辑属性中
// 这样做的好处是:修改下方文本框时,不会立即改变列表里的显示,只有点击“保存”后才刷新
EditAppId = dto.AppId;
EditType = dto.Type; // 之前改成了 int直接赋值即可
EditFps = dto.DisplayFps; // 注意:后端返回可能是 targetFps前端 DTO 映射已修复
EditMemo = dto.Memo;
// 网络参数回显
EditTargetIp = dto.TargetIp;
EditTargetPort = dto.TargetPort;
// 触发一下 UI 状态刷新(比如网络参数的显隐)
OnPropertyChanged(nameof(IsNetworkType));
}
}
}

View File

@@ -0,0 +1,369 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public class CameraImgProcViewModel : INotifyPropertyChanged
{
private readonly WebApiCameraModel _sourceCamera;
private readonly CameraEditInfo _editInfo;
public event Action RequestClose;
public string Title => $"{_sourceCamera.Name} - 图像处理";
public string SourceResolutionText => $"{_sourceCamera.Width} x {_sourceCamera.Height}";
// --- 状态控制属性 ---
// 源是否是高清 (>= 1080P)
public bool IsSourceHighRes => _sourceCamera.Width >= 1920 || _sourceCamera.Height >= 1080;
// 是否允许勾选“允许放大” (UI 绑定 IsEnabled)
public bool CanCheckExpand => !IsSourceHighRes;
// 滑块是否可用 (只有开启了缩小 或 开启了放大,才允许拖动)
// ★★★ 修正:使用 AllowEnlarge ★★★
public bool IsSliderEnabled => AllowShrink || AllowEnlarge;
// 允许缩小
public bool AllowShrink
{
get => _editInfo.AllowShrink;
set
{
if (_editInfo.AllowShrink != value)
{
_editInfo.AllowShrink = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsSliderEnabled));
ValidateAndSyncSlider();
}
}
}
// ★★★ 修正:属性名为 AllowEnlarge对应 Model 中的 AllowEnlarge ★★★
public bool AllowEnlarge
{
get => _editInfo.AllowEnlarge;
set
{
if (_editInfo.AllowEnlarge != value)
{
_editInfo.AllowEnlarge = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsSliderEnabled));
// 开关变化时,重新校验当前尺寸是否合法
ValidateAndSyncSlider();
}
}
}
// 图像增强
public bool EnhanceImage
{
get => _editInfo.EnhanceImage;
set
{
if (_editInfo.EnhanceImage != value)
{
_editInfo.EnhanceImage = value;
OnPropertyChanged();
}
}
}
// --- 分辨率与缩放属性 ---
private int _targetWidth;
public int TargetWidth
{
get => _targetWidth;
set
{
if (SetProperty(ref _targetWidth, value))
{
if (IsRatioLocked && _sourceCamera.Width > 0)
{
_targetHeight = (int)((double)value / _sourceCamera.Width * _sourceCamera.Height);
OnPropertyChanged(nameof(TargetHeight));
}
ValidateAndSyncSlider();
}
}
}
private int _targetHeight;
public int TargetHeight
{
get => _targetHeight;
set
{
if (SetProperty(ref _targetHeight, value))
{
if (IsRatioLocked && _sourceCamera.Height > 0)
{
_targetWidth = (int)((double)value / _sourceCamera.Height * _sourceCamera.Width);
OnPropertyChanged(nameof(TargetWidth));
}
ValidateAndSyncSlider();
}
}
}
private bool _isRatioLocked = true;
public bool IsRatioLocked
{
get => _isRatioLocked;
set => SetProperty(ref _isRatioLocked, value);
}
private double _scalePercent = 100;
public double ScalePercent
{
get => _scalePercent;
set
{
if (SetProperty(ref _scalePercent, value))
{
UpdateResolutionByScale(value);
}
}
}
// [新增] 亮度调节属性
public int Brightness
{
get => _editInfo.Brightness;
set
{
if (_editInfo.Brightness != value)
{
_editInfo.Brightness = value;
OnPropertyChanged();
// 如果需要,可以在这里限制范围,例如 0-100
}
}
}
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
public ICommand ApplyPresetCommand { get; }
public CameraImgProcViewModel(WebApiCameraModel source, CameraEditInfo detail)
{
_sourceCamera = source;
// 此时 detail 可能是不完整的,先赋值防止空引用
_editInfo = detail ?? new CameraEditInfo { Id = source.Id };
SaveCommand = new RelayCommand(ExecuteSave);
CancelCommand = new RelayCommand(o => RequestClose?.Invoke());
ApplyPresetCommand = new RelayCommand(ExecutePreset);
InitializeState();
LoadDataAsync();
}
// 2. 实现加载逻辑
private async void LoadDataAsync()
{
// 调用 Repository
var remoteInfo = await ApiClient.Instance.Cameras.GetImageProcessingAsync(_sourceCamera.Id);
if (remoteInfo != null)
{
// --- A. 更新 ViewModel 的开关属性 ---
// 注意Setter 会自动触发 OnPropertyChanged 和 ValidateAndSyncSlider
AllowShrink = remoteInfo.AllowShrink;
AllowEnlarge = remoteInfo.AllowEnlarge; // 这里会自动更新 IsSliderEnabled
EnhanceImage = remoteInfo.EnhanceImage;
// --- B. 更新分辨率 (解析 "W x H") ---
if (!string.IsNullOrWhiteSpace(remoteInfo.TargetResolution))
{
var parts = remoteInfo.TargetResolution.Split('x');
if (parts.Length == 2 && int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h))
{
// 赋值 TargetWidth/Height 会自动触发 ValidateAndSyncSlider
// 从而更新 ScalePercent 和 滑块位置
TargetWidth = w;
TargetHeight = h;
}
}
}
}
private void InitializeState()
{
// 1. 强制规则:如果源分辨率 >= 1080P不允许放大
if (IsSourceHighRes)
{
_editInfo.AllowEnlarge = false; // ★★★ 修正引用 ★★★
}
// 2. 检查历史设置
// ★★★ 修正引用 ★★★
bool hasPreviousSettings = _editInfo.AllowShrink || _editInfo.AllowEnlarge;
if (hasPreviousSettings)
{
if (!string.IsNullOrWhiteSpace(_editInfo.TargetResolution))
{
var parts = _editInfo.TargetResolution.ToLower().Split('x');
if (parts.Length == 2 && int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h))
{
_targetWidth = w;
_targetHeight = h;
}
}
}
else
{
// 默认逻辑
if (_sourceCamera.Width > 1280)
{
_targetWidth = 1280;
_targetHeight = _sourceCamera.Width > 0
? (int)((double)1280 / _sourceCamera.Width * _sourceCamera.Height)
: 720;
}
else
{
_targetWidth = _sourceCamera.Width;
_targetHeight = _sourceCamera.Height;
}
}
ValidateAndSyncSlider();
}
private void UpdateResolutionByScale(double percent)
{
if (_sourceCamera.Width <= 0 || _sourceCamera.Height <= 0) return;
double w = _sourceCamera.Width * (percent / 100.0);
double h = _sourceCamera.Height * (percent / 100.0);
_targetWidth = (int)w;
_targetHeight = (int)h;
OnPropertyChanged(nameof(TargetWidth));
OnPropertyChanged(nameof(TargetHeight));
}
private void ValidateAndSyncSlider()
{
if (_sourceCamera.Width <= 0) return;
// ★★★ 修正引用:使用 AllowEnlarge ★★★
int maxWidth = AllowEnlarge ? 1920 : _sourceCamera.Width;
int maxHeight = AllowEnlarge ? 1080 : _sourceCamera.Height;
int minWidth = 160;
bool changed = false;
// 规则A如果未开启放大且当前 > 源 -> 强制回退到源
// ★★★ 修正引用 ★★★
if (!AllowEnlarge && (_targetWidth > _sourceCamera.Width))
{
_targetWidth = _sourceCamera.Width;
_targetHeight = _sourceCamera.Height;
changed = true;
}
if (!AllowShrink && (_targetWidth < _sourceCamera.Width))
{
_targetWidth = _sourceCamera.Width;
_targetHeight = _sourceCamera.Height;
changed = true;
}
if (_targetWidth > 1920) { _targetWidth = 1920; changed = true; }
if (_targetHeight > 1080) { _targetHeight = 1080; changed = true; }
if (_targetWidth < minWidth) { _targetWidth = minWidth; changed = true; }
if (changed)
{
OnPropertyChanged(nameof(TargetWidth));
OnPropertyChanged(nameof(TargetHeight));
}
double currentPercent = ((double)_targetWidth / _sourceCamera.Width) * 100.0;
if (Math.Abs(_scalePercent - currentPercent) > 0.1)
{
_scalePercent = currentPercent;
OnPropertyChanged(nameof(ScalePercent));
}
}
private void ExecutePreset(object parameter)
{
if (parameter is string preset)
{
var parts = preset.Split('x');
if (parts.Length != 2) return;
if (int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h))
{
if (w > _sourceCamera.Width)
{
if (IsSourceHighRes)
{
MessageBox.Show("当前源分辨率已超过或等于 1080P不支持放大。", "提示");
return;
}
AllowEnlarge = true; // ★★★ 修正引用 ★★★
}
else if (w < _sourceCamera.Width)
{
AllowShrink = true;
}
TargetWidth = w;
TargetHeight = h;
IsRatioLocked = true;
}
}
}
private async void ExecuteSave(object obj)
{
// 1. 准备数据 (拼接分辨率字符串)
_editInfo.TargetResolution = $"{TargetWidth}x{TargetHeight}";
// 2. 调用专用的 Processing 接口
bool success = await ApiClient.Instance.Cameras.UpdateImageProcessingAsync(_editInfo); // <--- 换成这就行
if (success)
{
AppGlobal.RequestRefresh();
RequestClose?.Invoke();
}
else
{
MessageBox.Show("保存图像处理配置失败,请检查网络或后端日志。", "错误");
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string name = null)
{
if (Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}

View File

@@ -0,0 +1,145 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard;
public class CameraItemTopViewModel : INotifyPropertyChanged
{
private WebApiCameraModel _camera;
/// <summary>
/// 当前选中的摄像头数据
/// </summary>
public WebApiCameraModel Camera
{
get => _camera;
set
{
if (_camera != value)
{
_camera = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSelection)); // 通知界面是否有选中项
}
}
}
public ICommand EditDeviceCommand { get; }
/// <summary>
/// 辅助属性:用于界面判断是否显示内容
/// </summary>
public bool HasSelection => Camera != null;
public ICommand TogglePlayCommand { get; }
public ICommand PtzCommand { get; } // [新增]
// [新增] 图像处理命令
public ICommand ImageProcessCommand { get; }
// [新增] 图像订阅命令
public ICommand ImageSubscribeCommand { get; }
public CameraItemTopViewModel()
{
// 绑定命令到执行方法
TogglePlayCommand = new RelayCommand<object>(async _ => await ExecuteTogglePlayAsync());
// 2. 初始化命令
EditDeviceCommand = new RelayCommand<object>(ExecuteEditDevice);
PtzCommand = new RelayCommand(ExecutePtz);
DeleteCommand = new RelayCommand(ExecuteDelete);
// 初始化新命令
ImageProcessCommand = new RelayCommand(ExecuteImageProcess);
ImageSubscribeCommand = new RelayCommand(ExecuteImageSubscribe);
}
// [新增] 执行图像处理
private void ExecuteImageProcess(object obj)
{
if (Camera == null) return;
// 触发全局事件,打开右侧面板
AppGlobal.RequestImgProc(this.Camera);
}
// [新增] 执行图像订阅
private void ExecuteImageSubscribe(object obj)
{
if (Camera == null) return;
// 不再直接 MessageBox而是像 Edit/Ptz 一样发出全局请求
// 这会将操作权交给 MainViewModel由它在右侧面板加载 View
AppGlobal.RequestSubscription(this.Camera);
}
private void ExecutePtz(object obj)
{
// 这里可以加个判断,比如 Brand=RTSP 或 File 可能不支持云台
// if (Model.Brand == (int)DeviceBrand.File) return;
AppGlobal.RequestPtz(this.Camera);
}
private async Task ExecuteTogglePlayAsync()
{
if (Camera == null) return;
// 1. 物理离线检查
if (!Camera.IsPhysicalOnline)
{
// 提示:设备断网,无法发送指令
MessageBox.Show("设备物理离线,无法操作");
return;
}
try
{
// 发送控制指令
var useServiceNode = AppGlobal.UseServiceNode;
if (useServiceNode != null)
{
bool isPlaying = Camera.Status.Equals("Playing");
bool isSuccess = await ApiClient.Instance.Cameras.ControlPowerAsync(
Camera.Id,
!isPlaying,
"设备面板"
);
}
}
catch (Exception ex)
{
}
}
/// <summary>
/// 执行编辑逻辑
/// </summary>
private void ExecuteEditDevice(object obj)
{
if (Camera == null) return;
AppGlobal.RequestEdit(Camera);
}
// 2. 自身的删除命令 (不需要参数,因为我手里有 Model)
public ICommand DeleteCommand { get; }
private void ExecuteDelete(object obj)
{
// 直接把手里的数据发出去
AppGlobal.RequestDelete(this.Camera);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = "")
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View File

@@ -0,0 +1,259 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Threading; // 引入 DispatcherTimer 命名空间
namespace SHH.CameraDashboard
{
public class CameraListViewModel : INotifyPropertyChanged
{
// 存储从 API 获取的所有原始数据
private readonly ObservableCollection<WebApiCameraModel> _allCameras;
// 提供给界面绑定的视图 (支持搜索过滤)
public ICollectionView FilteredCameras { get; private set; }
#region --- ---
private bool _isLoading;
public bool IsLoading
{
get => _isLoading;
set { _isLoading = value; OnPropertyChanged(nameof(IsLoading)); }
}
private WebApiCameraModel _selectedCamera;
public WebApiCameraModel SelectedCamera
{
get => _selectedCamera;
set { _selectedCamera = value; OnPropertyChanged(nameof(SelectedCamera)); }
}
private string _searchText;
public string SearchText
{
get => _searchText;
set
{
_searchText = value;
OnPropertyChanged(nameof(SearchText));
// 输入文字时立即触发过滤
FilteredCameras.Refresh();
}
}
#endregion
#region --- ---
// 下拉框的数据源
public ObservableCollection<ServiceNodeModel> NodeOptions
=> AppGlobal.ServiceNodes;
private ServiceNodeModel _selectedNode;
public ServiceNodeModel SelectedNode
{
get => _selectedNode;
set
{
if (_selectedNode != value)
{
_selectedNode = value;
OnPropertyChanged(nameof(SelectedNode));
AppGlobal.UseServiceNode = value;
// 切换节点时,自动重新加载数据
_ = LoadDataAsync();
}
}
}
#endregion
public ICommand RefreshCommand { get; }
public CameraListViewModel()
{
_allCameras = new ObservableCollection<WebApiCameraModel>();
// 初始化 CollectionView
FilteredCameras = CollectionViewSource.GetDefaultView(_allCameras);
FilteredCameras.Filter = FilterCameras;
AddCameraCommand = new RelayCommand(ExecuteAddCamera);
DeleteCommand = new RelayCommand(ExecuteDelete);
RefreshCommand = new RelayCommand<object>(async _ => await LoadDataAsync());
// --- 新增:定时器 ---
// 使用 DispatcherTimer 确保在 UI 线程执行,避免跨线程操作集合报错
var timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromSeconds(2); // 这里设置间隔,例如 2 秒
timer.Tick += async (s, e) => await LoadDataAsync();
timer.Start();
}
/// <summary>
/// 核心:加载数据逻辑 (无刷版本)
/// </summary>
public async Task LoadDataAsync()
{
// 如果正在加载中,跳过本次(防止并发)
if (IsLoading) return;
try
{
// 【改动1】只有第一次加载列表为空时才显示 Loading 转圈
// 这样定时刷新时,界面就不会弹出遮罩层
if (_allCameras.Count == 0) IsLoading = true;
// 确定要查询的目标节点列表
var targetNodes = new List<ServiceNodeModel>();
if (SelectedNode == null || SelectedNode.ServiceNodeIp == "ALL")
{
if (AppGlobal.ServiceNodes != null)
targetNodes.AddRange(AppGlobal.ServiceNodes);
}
else
{
targetNodes.Add(SelectedNode);
}
if (targetNodes.Count == 0)
{
// 如果没有节点,清空列表
if (_allCameras.Count > 0) _allCameras.Clear();
return;
}
// 创建并发任务列表
var tasks = new List<Task<List<WebApiCameraModel>?>>();
foreach (var node in targetNodes)
{
if (string.IsNullOrWhiteSpace(node.ServiceNodeIp) || string.IsNullOrWhiteSpace(node.ServiceNodePort))
continue;
tasks.Add(ApiClient.Instance.Cameras.GetListByAddressAsync(node.ServiceNodeIp, node.ServiceNodePort, "左侧列表刷新"));
}
var results = await Task.WhenAll(tasks);
// 【改动2】先将所有新数据收集到一个临时列表中不要直接操作界面集合
var newLatestData = new List<WebApiCameraModel>();
foreach (var list in results)
{
if (list != null)
{
newLatestData.AddRange(list);
}
}
// 【改动3】执行无刷更新 (智能合并)
if (newLatestData.Count > 0)
UpdateCollectionNoFlash(newLatestData);
}
finally
{
IsLoading = false;
}
}
/// <summary>
/// 辅助方法:比对新旧数据,只更新变化的,实现“无刷”
/// </summary>
private void UpdateCollectionNoFlash(List<WebApiCameraModel> newData)
{
// 1. 删除:界面上有,但新数据里没有的 (说明设备断开或移除了)
// 使用 ToList() 避免在遍历时修改集合报错
var itemsToRemove = _allCameras.Where(old => !newData.Any(n => n.Id == old.Id)).ToList();
foreach (var item in itemsToRemove)
{
_allCameras.Remove(item);
}
// 2. 更新或新增
foreach (var newCam in newData)
{
// 尝试在界面列表中找这个 ID
var oldCam = _allCameras.FirstOrDefault(x => x.Id == newCam.Id);
if (oldCam != null)
{
// --- [更新] ---
// 找到老对象,手动更新它的属性。
// 注意:你的 WebApiCameraModel 必须实现了 INotifyPropertyChanged
// 这里的赋值才会让界面文字发生变化,否则界面不动。
oldCam.Name = newCam.Name;
oldCam.IpAddress = newCam.IpAddress;
oldCam.Brand = newCam.Brand;
oldCam.Status = newCam.Status;
oldCam.IsOnline = newCam.IsOnline;
oldCam.IsPhysicalOnline = newCam.IsPhysicalOnline;
oldCam.IsRunning = newCam.IsRunning;
oldCam.Width = newCam.Width;
oldCam.Height = newCam.Height;
oldCam.RealFps = newCam.RealFps;
oldCam.TotalFrames = newCam.TotalFrames;
oldCam.StreamType = newCam.StreamType;
// 如果有其他变化的字段(如帧率),继续在这里赋值...
}
else
{
// --- [新增] ---
// 没找到,说明是新上线的设备,添加到列表
_allCameras.Add(newCam);
}
}
}
/// <summary>
/// 本地搜索过滤逻辑
/// </summary>
private bool FilterCameras(object obj)
{
if (obj is WebApiCameraModel camera)
{
// 搜索框为空显示所有
if (string.IsNullOrWhiteSpace(SearchText)) return true;
string lowerSearch = SearchText.ToLower();
// 匹配 名称、IP 或 品牌
return (camera.Name?.ToLower().Contains(lowerSearch) == true) ||
(camera.IpAddress?.Contains(lowerSearch) == true) ||
(camera.Brand?.ToLower().Contains(lowerSearch) == true);
}
return false;
}
public ICommand AddCameraCommand { get; }
private void ExecuteAddCamera(object obj)
{
// 简单直接:通知全世界“我要添加摄像头!”
AppGlobal.RequestAdd();
}
// [新增] 删除命令
public ICommand DeleteCommand { get; }
private void ExecuteDelete(object obj)
{
// 这里的 obj 就是从界面传过来的 WebApiCameraModel
if (obj is WebApiCameraModel camera)
{
AppGlobal.RequestDelete(camera);
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View File

@@ -0,0 +1,133 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public class CameraPtzViewModel : INotifyPropertyChanged
{
private readonly WebApiCameraModel _camera;
public string Title => $"{_camera.Name} - 云台控制";
public ICommand StartCommand { get; }
public ICommand StopCommand { get; }
// 用于关闭面板
public ICommand CloseCommand { get; }
public ICommand SyncTimeCommand { get; }
public ICommand RebootCommand { get; }
// ★★★ [新增] 关闭请求事件,通知 MainViewModel 关闭面板
public event Action? RequestClose;
public CameraPtzViewModel(WebApiCameraModel camera)
{
_camera = camera;
// 使用非泛型 RelayCommand但在 Execute 中强转参数
StartCommand = new RelayCommand(ExecuteStart);
StopCommand = new RelayCommand(ExecuteStop);
// 关闭逻辑:可以简单地请求刷新或置空 MainVM 的属性(这里暂用刷新模拟)
CloseCommand = new RelayCommand(o => RequestClose?.Invoke());
// [新增] 初始化运维命令
SyncTimeCommand = new RelayCommand(ExecuteSyncTime);
RebootCommand = new RelayCommand(ExecuteReboot);
}
// [新增] 执行校时
private async void ExecuteSyncTime(object obj)
{
if (_camera == null) return;
// 可以加个 IsBusy 状态防止连点
bool success = await ApiClient.Instance.Cameras.SyncTimeAsync(_camera.Id);
if (success)
MessageBox.Show("校时指令已发送成功!", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
else
MessageBox.Show("校时失败,请检查网络或日志。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
// [新增] 执行重启
private async void ExecuteReboot(object obj)
{
if (_camera == null) return;
// 重启是高风险操作,必须弹窗确认
var result = MessageBox.Show(
$"确定要重启设备 \"{_camera.Name}\" 吗?\n\n注意\n1. 设备将断开连接约 60 秒。\n2. 录像可能会中断。",
"确认重启",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
bool success = await ApiClient.Instance.Cameras.RebootCameraAsync(_camera.Id);
if (success)
MessageBox.Show("重启指令已发送,设备正在重启中...", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
else
MessageBox.Show("重启指令发送失败。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
private async void ExecuteStart(object parameter)
{
if (parameter is PtzAction action)
{
System.Diagnostics.Debug.WriteLine($"PTZ START: {action}");
// 模式 A: 雨刷 (Wiper) -> 点动模式 (持续 5秒)
if (action == PtzAction.Wiper)
{
await SendPtzRequest(new PtzControlDto
{
Action = action,
Duration = 5000
});
}
// 模式 B: 持续动作 (Up/Down/Zoom...) -> Start
else
{
await SendPtzRequest(new PtzControlDto
{
Action = action,
Stop = false,
Speed = 5
});
}
}
}
private async void ExecuteStop(object parameter)
{
if (parameter is PtzAction action)
{
// 雨刷是自动复位的,不需要发停止
if (action == PtzAction.Wiper) return;
System.Diagnostics.Debug.WriteLine($"PTZ STOP: {action}");
// 发送停止指令Action=动作名, Stop=true
await SendPtzRequest(new PtzControlDto
{
Action = action,
Stop = true
});
}
}
private async Task SendPtzRequest(PtzControlDto payload)
{
if (_camera == null) return;
await ApiClient.Instance.Cameras.PtzControlAsync(_camera.Id, payload);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View File

@@ -0,0 +1,188 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
/// <summary>
/// 客户端配置向导的 ViewModel
/// 实现了 <see cref="IOverlayClosable"/> 接口,用于与父窗口的蒙板交互。
/// </summary>
public class WizardClientsViewModel : INotifyPropertyChanged, IOverlayClosable
{
/// <summary>
/// 绑定到 ListView 的数据源
/// </summary>
public static ObservableCollection<ServiceNodeModel> ServiceNodes
=> AppGlobal.ServiceNodes;
#region --- ---
private string _statusText = "准备就绪";
/// <summary>
/// 获取或设置向导的当前状态文本。
/// </summary>
public string StatusText
{
get => _statusText;
set { _statusText = value; OnPropertyChanged(); }
}
#endregion
#region --- ---
/// <summary>
/// 获取用于确认操作的命令。
/// </summary>
public ICommand ConfirmCommand { get; }
/// <summary>
/// 获取用于取消操作的命令。
/// </summary>
public ICommand CancelCommand { get; }
// 新增一行的命令
public ICommand AddNodeCommand { get; }
public ICommand DeleteNodeCommand { get; }
public ICommand CheckCommand { get; }
#endregion
#region --- ---
/// <summary>
/// 初始化 <see cref="WizardClientsViewModel"/> 类的新实例。
/// </summary>
public WizardClientsViewModel()
{
// 实现新增逻辑
AddNodeCommand = new RelayCommand<object>(_ =>
{
// 创建新行对象并添加到集合
var newNode = new ServiceNodeModel
{
ServiceNodeName = "新节点",
ServiceNodeIp = "0.0.0.0",
ServiceNodePort = "5000",
Status = "未检测"
};
ServiceNodes.Add(newNode);
});
// 删除逻辑实现
DeleteNodeCommand = new RelayCommand<ServiceNodeModel>(node =>
{
if (node != null && ServiceNodes.Contains(node))
{
ServiceNodes.Remove(node);
}
});
// 初始化确认命令
ConfirmCommand = new RelayCommand<object>(async _ =>
{
try
{
// 2. 【核心代码】调用通用存储服务
// 泛型 T 自动推断为 ObservableCollection<ServiceNodeModel>
await LocalStorageService.SaveAsync(
AppPaths.ServiceNodesConfig, // 路径Configs/service_nodes.json
ServiceNodes // 数据:当前的列表对象
);
// 4. 关闭当前弹窗
RequestClose?.Invoke();
}
catch (Exception ex)
{
MessageBox.Show($"保存失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
});
CheckCommand = new RelayCommand<object>(async _ => await ExecuteCheckAsync());
// 初始化取消命令
CancelCommand = new RelayCommand<object>(_ =>
{
// 直接触发关闭请求事件,取消并关闭向导
RequestClose?.Invoke();
});
}
#endregion
#region --- IOverlayClosable ---
/// <summary>
/// 当需要关闭蒙板时发生。
/// </summary>
public event Action? RequestClose;
#endregion
#region --- INotifyPropertyChanged ---
/// <summary>
/// 当属性值更改时发生。
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 引发 <see cref="PropertyChanged"/> 事件。
/// </summary>
/// <param name="name">更改的属性名称。</param>
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
#endregion
private async Task ExecuteCheckAsync()
{
foreach (var node in ServiceNodes)
{
if (string.IsNullOrWhiteSpace(node.ServiceNodeIp) || string.IsNullOrWhiteSpace(node.ServiceNodePort))
continue;
node.Status = "正在获取信息...";
// 1. 调用 ApiClient 获取列表 (代码不需要变,因为 Repository 已经封装好了)
var cameras = await ApiClient.Instance.Cameras.GetListByAddressAsync(
node.ServiceNodeIp,
node.ServiceNodePort,
"向导页面"
);
// 2. 根据返回的详细数据,生成更智能的状态描述
if (cameras.Count > 0)
{
// 统计在线的摄像头数量
int onlineCount = 0;
// 统计主要品牌 (例如: HikVision)
string firstBrand = cameras[0].Brand;
foreach (var cam in cameras)
{
if (cam.IsOnline) onlineCount++;
}
// 状态显示示例:只能是在线,其它的影响界面上色
node.Status = $"在线";
}
else
{
// 列表为空,或者是网络不通导致 Repository 返回了空列表
// 为了区分是“无数据”还是“网络不通”,其实 Repository 可以优化返回 null 或抛异常,
// 但目前的架构返回空列表最安全。
node.Status = "离线或无设备";
}
}
}
}
}

View File

@@ -1,4 +1,6 @@
namespace SHH.CameraSdk;
using System.ComponentModel;
namespace SHH.CameraSdk;
/// <summary>
/// 视频源物理/逻辑品牌类型
@@ -9,6 +11,7 @@ public enum DeviceBrand
/// <summary>
/// 未知
/// </summary>
[Description("未知")]
Unknown = 0,
/// <summary>
@@ -16,47 +19,54 @@ public enum DeviceBrand
/// 技术路径:基于海康私有 SDK (HCNetSDK.dll / PlayCtrl.dll)。
/// 特性支持全功能控制PTZ、对讲、配置、报警回传
/// </summary>
HikVision,
[Description("海康威视")]
HikVision = 1,
/// <summary>
/// 大华 (Dahua)
/// 技术路径:基于大华私有 SDK (dhnetsdk.dll / dhplay.dll)。
/// 特性:支持全功能控制,与海康私有协议不兼容。
/// </summary>
Dahua,
[Description("大华")]
Dahua = 2,
/// <summary>
/// USB 摄像头 / 虚拟摄像头
/// 技术路径:基于 DirectShow 或 Windows Media Foundation。
/// 特性:通常通过 OpenCV (VideoCapture) 或 DirectShowLib 直接读取本地硬件引用。
/// </summary>
Usb,
[Description("USB")]
Usb = 3,
/// <summary>
/// 标准 RTSP 流媒体
/// 技术路径:基于标准 RTSP/RTP 协议 (RFC 2326)。
/// 特性:跨品牌兼容,通常使用 FFmpeg 或 GStreamer 库取流,仅支持音视频,不支持云台控制。
/// </summary>
RtspGeneral,
[Description("RTSP")]
RtspGeneral = 4,
/// <summary>
/// 三恒自研 WebSocket 流
/// 技术路径:基于 WebSocket 传输的自定义二进制或 Base64 帧。
/// 特性:专用于 Web 或云端推送场景的私有流媒体格式。
/// </summary>
WebSocketShine,
[Description("三恒WebSocket")]
WebSocketShine = 5,
/// <summary>
/// 本地视频文件
/// 技术路径:基于文件 IO 的离线解码。
/// 特性:常用于算法演示、回放模拟,支持 Mp4, Avi, Mkv 等容器格式。
/// </summary>
File,
[Description("文件")]
File = 6,
/// <summary>
/// 未知/通用标准 (ONVIF)
/// 技术路径:基于标准 ONVIF WebService。
/// 特性:用于接入非主流厂商但符合 ONVIF 标准的设备,支持基础 PTZ。
/// </summary>
OnvifGeneral
[Description("通用标准")]
OnvifGeneral = 7
}

View File

@@ -326,6 +326,64 @@ public class CamerasController : ControllerBase
});
}
[HttpGet("{id}/subscriptions")]
public IActionResult GetSubscriptions(long id)
{
// 1. 检查设备是否存在
var camera = _manager.GetDevice(id);
if (camera == null)
{
return NotFound(new { error = $"Camera {id} not found." });
}
// 2. 从设备的 FrameController 获取当前活跃的订阅
// 注意FrameController.GetCurrentRequirements() 返回的是 List<FrameRequirement>
// 它可以直接被序列化为 JSON
var subs = camera.Controller.GetCurrentRequirements();
return Ok(subs);
}
// =============================================================
// 6. 新增:彻底删除/注销订阅 (RESTful DELETE)
// URL: DELETE /api/cameras/{id}/subscriptions/{appId}
// =============================================================
[HttpDelete("{id}/subscriptions/{appId}")]
public IActionResult RemoveSubscription(long id, string appId)
{
// 1. 检查设备是否存在
var device = _manager.GetDevice(id);
if (device == null)
{
return NotFound(new { error = $"Camera {id} not found." });
}
// 2. 获取流控控制器
var controller = device.Controller;
if (controller == null)
{
// 如果设备本身没有控制器(比如离线或不支持),也算删除成功
return Ok(new { success = true, message = "Device has no controller, nothing to remove." });
}
// 3. 执行注销逻辑 (核心)
// 从 FrameController 的分发列表中移除
controller.Unregister(appId);
// 4. 清理显示资源 (重要!)
// 参考您 UpdateSubscription 中的逻辑,必须同时停止 DisplayManager否则窗口关不掉
_displayManager.StopDisplay(appId);
// 5. 记录审计日志
device.AddAuditLog($"用户指令:彻底注销订阅 [{appId}]");
return Ok(new
{
success = true,
message = $"Subscription {appId} has been removed and display stopped."
});
}
// 1. 获取单个设备详情(用于编辑回填)
[HttpGet("{id}")]
public IActionResult GetDevice(int id)