增加摄像头中控台项目

This commit is contained in:
2025-12-30 10:53:02 +08:00
parent 471b8c50b6
commit de3adf0339
31 changed files with 2736 additions and 0 deletions

View File

@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts", "SHH.Contra
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.NetMQ", "SHH.NetMQ\SHH.NetMQ.csproj", "{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.NetMQ", "SHH.NetMQ\SHH.NetMQ.csproj", "{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraDashboard", "SHH.CameraDashboard\SHH.CameraDashboard.csproj", "{03C249D7-BCF1-404D-AD09-7AB39BA263AD}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Debug|Any CPU.Build.0 = Debug|Any CPU {FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.ActiveCfg = Release|Any CPU {FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.Build.0 = Release|Any CPU {FAC8E0CD-4BB3-4752-A406-CD3D2CE5FBB4}.Release|Any CPU.Build.0 = Release|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -0,0 +1,25 @@
<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.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.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,14 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace SHH.CameraDashboard
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
}

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,40 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows;
public static class ClipboardHelper
{
/// <summary>
/// 安全复制文本,包含冲突重试机制
/// </summary>
public static void SetText(string text)
{
if (string.IsNullOrEmpty(text)) return;
// 最多尝试 5 次,每次间隔 100 毫秒
for (int i = 0; i < 5; i++)
{
try
{
Clipboard.SetText(text);
return; // 复制成功,退出方法
}
catch (COMException ex)
{
// 如果是剪贴板被占用错误,等待后重试
if ((uint)ex.ErrorCode == 0x800401D0)
{
Thread.Sleep(100);
continue;
}
throw; // 其他 COM 错误则抛出
}
catch (Exception)
{
if (i == 4) throw; // 最后一次尝试失败则抛出
Thread.Sleep(100);
}
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
namespace SHH.CameraDashboard
{
public enum ThemeType { Dark, Light }
public static class ThemeManager
{
public static void ChangeTheme(ThemeType theme)
{
var appResources = Application.Current.Resources;
// 1. 找到旧的颜色字典并移除
// 我们通过检查 Source 路径来识别它
ResourceDictionary oldDict = null;
foreach (var dict in appResources.MergedDictionaries)
{
// 只要路径里包含 "Colors." 说明它是我们的皮肤文件
if (dict.Source != null && dict.Source.OriginalString.Contains("Themes/Colors."))
{
oldDict = dict;
break;
}
}
if (oldDict != null)
{
appResources.MergedDictionaries.Remove(oldDict);
}
// 2. 加载新字典
string dictName = theme switch
{
ThemeType.Light => "/Style/Themes/Colors.Light.xaml",
ThemeType.Dark => "/Style/Themes/Colors.Dark.xaml",
_ => "/Style/Themes/Colors.Dark.xaml" // 默认
};
var newDict = new ResourceDictionary
{
Source = new Uri(dictName, UriKind.Relative)
};
// 3. 添加到集合中 (建议加在最前面,或者根据索引位置)
appResources.MergedDictionaries.Add(newDict);
}
}
}

View File

@@ -0,0 +1,172 @@
<UserControl
x:Class="SHH.CameraDashboard.WizardControl"
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"
Height="550"
mc:Ignorable="d">
<Border
Background="{DynamicResource Brush.Bg.Window}"
BorderBrush="{DynamicResource Brush.Border.Focus}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.Normal}">
<Grid Margin="25">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,20">
<DockPanel LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontSize="{StaticResource Size.Font.Huge}"
FontWeight="Bold"
Foreground="{DynamicResource Brush.Text.Primary}"
Text="📡 服务节点配置" />
<Button
Width="30"
Height="30"
Click="Cancel_Click"
Content="✕"
DockPanel.Dock="Right"
Style="{StaticResource Btn.Ghost}" />
</DockPanel>
<TextBlock
Margin="0,10,0,0"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="请添加或修改后端的 IP 地址与端口,配置将用于聚合监控数据。" />
</StackPanel>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Padding="10"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1,1,1,0">
<DockPanel LastChildFill="False">
<TextBlock
VerticalAlignment="Center"
FontWeight="Bold"
Text="节点清单" />
<Button
Height="26"
Padding="15,0"
Background="{DynamicResource Brush.Accent}"
BorderThickness="0"
Click="AddNode_Click"
Content="+ 新增一行"
DockPanel.Dock="Right"
Style="{StaticResource {x:Type Button}}" />
</DockPanel>
</Border>
<ListView
x:Name="NodeList"
Grid.Row="1"
Background="Transparent"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="1"
ItemContainerStyle="{StaticResource Style.ListViewItem.Table}">
<ListView.View>
<GridView ColumnHeaderContainerStyle="{StaticResource Style.GridViewHeader.Flat}">
<GridViewColumn Width="180" Header="节点名称">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="180" Header="IP 地址">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Ip, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="100" Header="端口">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBox Style="{StaticResource Style.TextBox.InTable}" Text="{Binding Port, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="180" Header="连通性状态">
<GridViewColumn.CellTemplate>
<DataTemplate>
<Border
Padding="8,2"
HorizontalAlignment="Left"
Background="#11000000"
CornerRadius="4">
<TextBlock
VerticalAlignment="Center"
FontWeight="Bold"
Foreground="{Binding StatusColor}"
Text="{Binding Status}" />
</Border>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="80" Header="操作">
<GridViewColumn.CellTemplate>
<DataTemplate>
<Button
Width="45"
Height="24"
Padding="0"
Click="DeleteNode_Click"
Content="删除"
FontSize="11"
Style="{StaticResource Btn.Danger}" />
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</Grid>
<StackPanel
Grid.Row="2"
Margin="0,20,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<TextBlock
Margin="0,0,15,0"
VerticalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource Brush.Text.Secondary}"
Text="修改后请点击检测 →" />
<Button
Width="110"
Height="32"
Margin="0,0,10,0"
Click="Check_Click"
Content="🔍 立即检测"
Style="{StaticResource Btn.Ghost}" />
<Button
Width="120"
Height="32"
Background="{DynamicResource Brush.Accent}"
BorderThickness="0"
Click="Apply_Click"
Content="保存并应用"
Foreground="White" />
</StackPanel>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,94 @@
using SHH.CameraDashboard.Services;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Xml.Linq;
namespace SHH.CameraDashboard
{
public partial class WizardControl : UserControl
{
private const string ServerConfigFile = "servers.config.json";
// 绑定源
public ObservableCollection<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

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,67 @@
<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

@@ -0,0 +1,64 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace SHH.CameraDashboard
{
public partial class BottomDockControl : UserControl
{
private bool _isExpanded = false;
public BottomDockControl()
{
InitializeComponent();
// 核心修正:这里必须使用 WebApiDiag因为它对应你 XAML 里的 x:Name
if (this.WebApiDiag != null)
{
// 订阅 DiagnosticControl 抛出的关闭事件
this.WebApiDiag.RequestCollapse += (s, e) =>
{
// 当子页面点击“关闭”按钮时,执行收回面板的方法
HideExpandedPanel();
};
}
}
// 逻辑:隐藏上方的大面板
private void HideExpandedPanel()
{
ExpandedPanel.Visibility = Visibility.Collapsed;
ArrowIcon.Text = "▲"; // 箭头恢复向上
}
// 接收全局日志,分发给内部控件,并更新状态栏摘要
public void PushLog(ApiLogEntry log)
{
// 1. 推送给内部的诊断控件 (详细列表)
WebApiDiag.PushLog(log);
// 2. 更新底部状态栏 (摘要)
string statusIcon = log.IsSuccess ? "✅" : "❌";
LatestLogText.Text = $"{statusIcon} [{log.Time:HH:mm:ss}] {log.Method} {log.Url} ({log.StatusCode})";
LatencyText.Text = $"{log.DurationMs}ms";
// 如果失败,可以将状态栏背景变红一下(可选)
if (!log.IsSuccess)
{
// 这里简单处理,如果想要复杂的动画可以使用 Storyboard
LatestLogText.Foreground = new SolidColorBrush(Colors.Yellow);
}
else
{
LatestLogText.Foreground = Brushes.White;
}
}
private void TogglePanel_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_isExpanded = !_isExpanded;
ExpandedPanel.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed;
ArrowIcon.Text = _isExpanded ? "▼" : "▲";
}
}
}

View File

@@ -0,0 +1,247 @@
<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

@@ -0,0 +1,148 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
// 简单的包装类,用于 ComboBox 显示
public class ServerOption
{
public string Name { get; set; }
public string Ip { get; set; }
public int Port { get; set; }
// 修改显示属性:如果有名字就显示 名字(IP),没有就显示 IP
public string DisplayText => string.IsNullOrEmpty(Name) ? $"{Ip}:{Port}" : $"{Name} ({Ip})";
}
public partial class CameraListControl : UserControl
{
// 所有摄像头数据(原始全集)
private List<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

@@ -0,0 +1,199 @@
<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

@@ -0,0 +1,52 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace SHH.CameraDashboard
{
public partial class DeviceHomeControl : UserControl
{
private CameraInfo _currentDevice;
public DeviceHomeControl()
{
InitializeComponent();
}
// 供外部调用,切换显示的设备
public void UpdateDevice(CameraInfo device)
{
_currentDevice = device;
if (device == null)
{
EmptyView.Visibility = Visibility.Visible;
DetailView.Visibility = Visibility.Collapsed;
return;
}
// 切换到详情视图
EmptyView.Visibility = Visibility.Collapsed;
DetailView.Visibility = Visibility.Visible;
// 绑定数据到界面控件
TxtName.Text = device.DisplayName;
TxtIp.Text = device.IpAddress;
TxtStreamUrl.Text = $"rtsp://{device.IpAddress}:554/live/main";
// 根据状态切换颜色和文字
if (device.Status == "Playing" || device.Status == "Connected")
{
TxtStatus.Text = "在线运行";
TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(78, 201, 176)); // Green
BadgeStatus.Background = new SolidColorBrush(Color.FromArgb(30, 78, 201, 176));
}
else
{
TxtStatus.Text = "离线/断开";
TxtStatus.Foreground = new SolidColorBrush(Color.FromRgb(244, 71, 71)); // Red
BadgeStatus.Background = new SolidColorBrush(Color.FromArgb(30, 244, 71, 71));
}
}
}
}

View File

@@ -0,0 +1,83 @@
<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

@@ -0,0 +1,106 @@
using System.Windows;
using System.Windows.Controls;
namespace SHH.CameraDashboard.Controls
{
public partial class DiagnosticControl : UserControl
{
public event EventHandler RequestCollapse;
private ApiLogEntry _selectedItem;
private bool _isInitialized = false;
public DiagnosticControl()
{
InitializeComponent();
_isInitialized = true;
}
// 外部MainWindow或BottomDock调用此方法推送日志
public void PushLog(ApiLogEntry entry)
{
this.Dispatcher.Invoke(() => {
// 确保 XAML 中的 ListView 名称是 LogList
LogList.Items.Insert(0, entry);
// 限制日志数量,防止内存溢出(可选)
if (LogList.Items.Count > 100) LogList.Items.RemoveAt(100);
});
}
private void Close_Click(object sender, RoutedEventArgs e)
{
RequestCollapse?.Invoke(this, EventArgs.Empty);
}
private void LogList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!_isInitialized) return;
_selectedItem = LogList.SelectedItem as ApiLogEntry;
UpdateDetailView();
}
private void Tab_Checked(object sender, RoutedEventArgs e)
{
if (!_isInitialized) return;
UpdateDetailView();
}
private void UpdateDetailView()
{
// 防御性编程:检查所有可能为 null 的 UI 元素
if (TxtEmpty == null || TxtContent == null || BtnReq == null) return;
if (_selectedItem == null)
{
TxtEmpty.Visibility = Visibility.Visible;
TxtContent.Visibility = Visibility.Collapsed; // 隐藏编辑框更美观
TxtContent.Text = string.Empty;
return;
}
TxtEmpty.Visibility = Visibility.Collapsed;
TxtContent.Visibility = Visibility.Visible;
// 根据切换按钮显示对应内容
TxtContent.Text = (BtnReq.IsChecked == true)
? _selectedItem.RequestBody
: _selectedItem.ResponseBody;
}
private void Clear_Click(object sender, RoutedEventArgs e)
{
// 具体的清空逻辑
LogList.Items.Clear();
_selectedItem = null;
UpdateDetailView();
}
private void Copy_Click(object sender, RoutedEventArgs e)
{
// 如果没选中或内容本身为空,不执行复制
if (_selectedItem == null || TxtContent == null || string.IsNullOrEmpty(TxtContent.Text)) return;
// 使用之前定义的带重试机制的 Helper
ClipboardHelper.SetText(TxtContent.Text);
}
// 右键菜单复制逻辑
private void CopySummary_Click(object sender, RoutedEventArgs e)
{
if (LogList.SelectedItem is ApiLogEntry item)
ClipboardHelper.SetText($"[{item.Time}] {item.Url} - {item.StatusCode}");
}
private void CopyRequest_Click(object sender, RoutedEventArgs e)
{
if (LogList.SelectedItem is ApiLogEntry item)
ClipboardHelper.SetText(item.RequestBody ?? "");
}
private void CopyResponse_Click(object sender, RoutedEventArgs e)
{
if (LogList.SelectedItem is ApiLogEntry item)
ClipboardHelper.SetText(item.ResponseBody ?? "");
}
}
}

View File

@@ -0,0 +1,244 @@
<Window
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="SHH 视频中控"
Width="1366"
Height="768"
Background="{DynamicResource Brush.Bg.Window}"
FontFamily="{StaticResource Font.Normal}"
Foreground="{DynamicResource Brush.Text.Primary}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<WindowChrome.WindowChrome>
<WindowChrome
CaptionHeight="0"
CornerRadius="0"
GlassFrameThickness="0"
ResizeBorderThickness="5" />
</WindowChrome.WindowChrome>
<Grid x:Name="RootGrid">
<Grid x:Name="AppLayout">
<Grid.RowDefinitions>
<RowDefinition Height="45" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border
Grid.Row="0"
Background="{DynamicResource Brush.Bg.Panel}"
BorderBrush="{DynamicResource Brush.Border}"
BorderThickness="0,0,0,1"
MouseDown="OnTitleBarMouseDown">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Column="0"
Margin="10,0"
Orientation="Horizontal">
<Button
Width="40"
Click="ToggleSidebar"
Content="≡"
FontSize="18"
FontWeight="Bold"
Style="{StaticResource Btn.Ghost}"
ToolTip="展开/收起侧边栏" />
</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
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="打开配置向导" />
<Button
Width="40"
Margin="0,0,15,0"
Click="ToggleTheme"
Content="🎨"
Style="{StaticResource Btn.Ghost}"
ToolTip="切换皮肤 (Dark/Light)" />
<Button
Width="40"
Click="OnMinimize"
Content="—"
Style="{StaticResource Btn.Ghost}"
ToolTip="最小化" />
<Button
Width="40"
Margin="5,0,0,0"
Click="OnClose"
Content="✕"
Style="{StaticResource Btn.Danger}"
ToolTip="关闭" />
</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" />
<Border
Grid.Column="0"
Width="1"
HorizontalAlignment="Right"
Background="{DynamicResource Brush.Border}" />
<Grid
Grid.Column="1"
Background="{DynamicResource Brush.Bg.Window}"
ClipToBounds="True">
<ctrl:DeviceHomeControl x:Name="DeviceHome" Margin="0" />
<Border
x:Name="RightConfigPanel"
Width="350"
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>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="*" />
<RowDefinition Height="60" />
</Grid.RowDefinitions>
<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>
</Grid>
</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>
</Window>

View File

@@ -0,0 +1,107 @@
using System;
using System.Windows;
using System.Windows.Input;
namespace SHH.CameraDashboard
{
public partial class MainWindow : Window
{
private bool _isDarkTheme = true;
public MainWindow()
{
InitializeComponent();
// 1. 【日志联动】HttpService -> 底部报警栏 (精准匹配你提供的逻辑)
// 确保你的 ApiLogEntry 字段已根据我之前的建议对齐
HttpService.OnApiLog += (log) =>
{
Application.Current.Dispatcher.Invoke(() =>
{
BottomDock.PushLog(log);
});
};
// 2. 【设备联动】侧边栏选中 -> 设备首页详情
Sidebar.OnDeviceSelected += (device) =>
{
// 更新右侧 主视图
DeviceHome.UpdateDevice(device);
// 联动:向诊断日志推送一条“选中设备”的虚拟日志(可选)
BottomDock.LatestLogText.Text = $"当前选中设备: {device.DisplayName} ({device.IpAddress})";
// 如果右侧配置面板开着,最好关掉它
RightConfigPanel.Visibility = Visibility.Collapsed;
};
}
// ============================
// 1. 窗口基础操作 (拖拽/最小化/关闭)
// ============================
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left)
this.DragMove();
}
private void OnMinimize(object sender, RoutedEventArgs e) => this.WindowState = WindowState.Minimized;
private void OnClose(object sender, RoutedEventArgs e) => this.Close();
// ============================
// 2. 界面交互逻辑
// ============================
// 切换侧边栏展开/收起
private void ToggleSidebar(object sender, RoutedEventArgs e)
{
if (Sidebar.Width > 0)
Sidebar.Width = 0;
else
Sidebar.Width = 250;
}
// 切换主题 (使用你定义的 ThemeManager)
private void ToggleTheme(object sender, RoutedEventArgs e)
{
_isDarkTheme = !_isDarkTheme;
// 确保 ThemeManager.ChangeTheme 逻辑未变动
ThemeManager.ChangeTheme(_isDarkTheme ? ThemeType.Dark : ThemeType.Light);
}
// 打开/关闭右侧配置面板
private void OpenRightPanel(object sender, RoutedEventArgs e) => RightConfigPanel.Visibility = Visibility.Visible;
private void CloseRightPanel(object sender, RoutedEventArgs e) => RightConfigPanel.Visibility = Visibility.Collapsed;
// ============================
// 3. 向导模态框逻辑 (完整保留)
// ============================
private void OpenWizard(object sender, RoutedEventArgs e)
{
var wizard = new WizardControl();
// 当向导请求关闭时
wizard.RequestClose += (s, args) =>
{
CloseModal();
// 关键:向导可能修改了全局服务器列表,通知侧边栏刷新
Sidebar.ReloadServers();
};
ModalContainer.Content = wizard;
ModalLayer.Visibility = Visibility.Visible;
}
private void CloseModal_Click(object sender, MouseButtonEventArgs e)
{
// 点击遮罩层背景关闭
CloseModal();
}
private void CloseModal()
{
ModalLayer.Visibility = Visibility.Collapsed;
ModalContainer.Content = null;
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Windows.Media;
namespace SHH.CameraDashboard
{
// 日志实体,用于在事件中传递详细信息
public class ApiLogEntry
{
public DateTime Time { get; set; } = DateTime.Now;
public string Method { get; set; } // GET, POST
public string Url { get; set; }
public int StatusCode { get; set; }
public long DurationMs { get; set; } // 耗时(毫秒)
public string RequestBody { get; set; } // 发送的内容
public string ResponseBody { get; set; } // 接收的内容
public string ErrorMessage { get; set; } // 异常信息
// 辅助属性:是否成功
public bool IsSuccess => StatusCode >= 200 && StatusCode < 300;
// 辅助属性:显示颜色
public Brush StatusColor => IsSuccess
? new SolidColorBrush(Color.FromRgb(78, 201, 176)) // 绿色
: new SolidColorBrush(Color.FromRgb(244, 71, 71)); // 红色
}
}

View File

@@ -0,0 +1,31 @@
namespace SHH.CameraDashboard;
public class CameraInfo
{
// --- 原始 JSON 属性 ---
public int Id { get; set; }
public string Name { get; set; }
public string IpAddress { get; set; }
public DeviceBrand Brand { get; set; }
public string Status { get; set; } // "Playing", "Disconnected" 等
public bool IsPhysicalOnline { get; set; } // 物理在线 (网络)
public bool IsOnline { get; set; } // 业务在线 (登录)
public bool IsRunning { get; set; } // 正在运行 (拉流)
public int RealFps { get; set; }
public int Width { get; set; }
public int Height { get; set; }
// --- UI 分离状态逻辑 ---
// 状态 1: 登录状态 (在线/离线)
public string OnlineStatusText => (IsPhysicalOnline && IsOnline) ? "在线" : "离线";
// 状态 2: 运行状态 (运行/停止)
public string RunningStatusText => IsRunning ? "运行中" : "已停止";
// 品牌信息
public string BrandName => Brand.ToString();
public string DisplayName => string.IsNullOrEmpty(Name) ? IpAddress : Name;
public string MediaDetail => IsRunning && Width > 0 ? $"{Width}x{Height} | {RealFps}fps" : "无信号";
}

View File

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

View File

@@ -0,0 +1,87 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
namespace SHH.CameraDashboard
{
// 1. 单个节点的数据模型
public class ServerNode : INotifyPropertyChanged
{
private string _name = "新节点"; // 默认名称
public string Name
{
get => _name;
set { _name = value; OnPropertyChanged(); }
}
private string _ip = "127.0.0.1";
public string Ip
{
get => _ip;
set
{
if (_ip != value)
{
_ip = value;
OnPropertyChanged(); // 通知界面更新
}
}
}
private int _port = 5000;
public int Port
{
get => _port;
set
{
if (_port != value)
{
_port = value;
OnPropertyChanged();
}
}
}
private string _status = "未检测";
public string Status
{
get => _status;
set
{
_status = value;
OnPropertyChanged();
OnPropertyChanged(nameof(StatusColor)); // 状态变了,颜色也要跟着变
}
}
public Brush StatusColor
{
get
{
switch (Status)
{
case "✅ 连接成功":
return new SolidColorBrush(Color.FromRgb(78, 201, 176)); // 绿色
case "❌ 状态码异常":
return new SolidColorBrush(Color.FromRgb(255, 140, 0)); // 橙色 (警告)
case "❌ 无法连接":
return new SolidColorBrush(Color.FromRgb(244, 71, 71)); // 红色 (故障)
case "⏳ 检测中...":
return new SolidColorBrush(Color.FromRgb(86, 156, 214)); // 蓝色
default:
return new SolidColorBrush(Color.FromRgb(153, 153, 153)); // 灰色
}
}
}
public void SetResult(bool success, string msg)
{
Status = msg;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View File

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

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,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<Page Remove="Style\Themes\Colors.Dark.xaml" />
<Page Remove="Style\Themes\Colors.Light.xaml" />
<Page Remove="Style\Themes\Sizes.xaml" />
<Page Remove="Style\Themes\Styles.xaml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Resource Include="Style\Themes\Colors.Light.xaml">
<Generator>MSBuild:Compile</Generator>
</Resource>
</ItemGroup>
<ItemGroup>
<Resource Include="Style\Themes\Colors.Dark.xaml" />
<Resource Include="Style\Themes\Sizes.xaml" />
<Resource Include="Style\Themes\Styles.xaml" />
</ItemGroup>
<ItemGroup>
<Folder Include="Converters\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,119 @@
using Newtonsoft.Json;
using System.Diagnostics;
using System.Net.Http;
using System.Text;
namespace SHH.CameraDashboard
{
public static class HttpService
{
// 单例 HttpClient避免端口耗尽
private static readonly HttpClient _client;
// 【关键】日志事件UI层订阅这个事件来显示日志
public static event Action<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,52 @@
using System.IO;
using Newtonsoft.Json;
namespace SHH.CameraDashboard.Services
{
public static class StorageService
{
// 基础目录C:\Users\Name\AppData\Local\SHH_Dashboard\
private static readonly string BaseDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SHH_CameraDashboard");
/// <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,56 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="C.Brand">#007ACC</Color>
<Color x:Key="C.Brand.Hover">#1C97EA</Color>
<Color x:Key="C.Brand.Pressed">#005A9E</Color>
<Color x:Key="C.Success">#4EC9B0</Color>
<Color x:Key="C.Warning">#FFCA28</Color>
<Color x:Key="C.Danger">#F44747</Color>
<Color x:Key="C.Bg.L1">#1E1E1E</Color>
<Color x:Key="C.Bg.L2">#252526</Color>
<Color x:Key="C.Bg.L3">#333333</Color>
<Color x:Key="C.Bg.L4">#3E3E42</Color>
<Color x:Key="C.Text.Primary">#D4D4D4</Color>
<Color x:Key="C.Text.Secondary">#858585</Color>
<Color x:Key="C.Text.Disable">#555555</Color>
<Color x:Key="C.Text.Inverse">#FFFFFF</Color>
<Color x:Key="C.Border">#3E3E42</Color>
<Color x:Key="C.Border.Focus">#007ACC</Color>
<Color x:Key="Color.Bg.L1">#1E1E1E</Color>
<Color x:Key="Color.Bg.L2">#252526</Color>
<Color x:Key="Color.Bg.L3">#2D2D30</Color>
<Color x:Key="Color.Bg.L4">#3E3E42</Color>
<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}" />
<SolidColorBrush x:Key="Brush.Bg.L4" Color="{StaticResource Color.Bg.L4}" />
<SolidColorBrush x:Key="Brush.Bg.Window" Color="{StaticResource C.Bg.L1}" />
<SolidColorBrush x:Key="Brush.Bg.Panel" Color="{StaticResource C.Bg.L2}" />
<SolidColorBrush x:Key="Brush.Bg.Input" Color="{StaticResource C.Bg.L3}" />
<SolidColorBrush x:Key="Brush.Bg.Hover" Color="{StaticResource C.Bg.L4}" />
<SolidColorBrush x:Key="Brush.Text.Primary" Color="{StaticResource C.Text.Primary}" />
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="{StaticResource C.Text.Secondary}" />
<SolidColorBrush x:Key="Brush.Text.Disabled" Color="{StaticResource C.Text.Disable}" />
<SolidColorBrush x:Key="Brush.Text.Inverse" Color="{StaticResource C.Text.Inverse}" />
<SolidColorBrush x:Key="Brush.Brand" Color="{StaticResource C.Brand}" />
<SolidColorBrush x:Key="Brush.Accent" Color="{StaticResource C.Brand}" />
<SolidColorBrush x:Key="Brush.Accent.Hover" Color="{StaticResource C.Brand.Hover}" />
<SolidColorBrush x:Key="Brush.Accent.Pressed" Color="{StaticResource C.Brand.Pressed}" />
<SolidColorBrush x:Key="Brush.Status.Success" Color="{StaticResource C.Success}" />
<SolidColorBrush x:Key="Brush.Status.Warning" Color="{StaticResource C.Warning}" />
<SolidColorBrush x:Key="Brush.Status.Danger" Color="{StaticResource C.Danger}" />
<SolidColorBrush x:Key="Brush.Border" Color="{StaticResource C.Border}" />
<SolidColorBrush x:Key="Brush.Border.Focus" Color="{StaticResource C.Border.Focus}" />
</ResourceDictionary>

View File

@@ -0,0 +1,56 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="C.Brand">#005FB8</Color>
<Color x:Key="C.Brand.Hover">#2B88D8</Color>
<Color x:Key="C.Brand.Pressed">#004080</Color>
<Color x:Key="C.Success">#107C10</Color>
<Color x:Key="C.Warning">#D13438</Color>
<Color x:Key="C.Danger">#A80000</Color>
<Color x:Key="C.Bg.L1">#FFFFFF</Color>
<Color x:Key="C.Bg.L2">#F3F3F3</Color>
<Color x:Key="C.Bg.L3">#FFFFFF</Color>
<Color x:Key="C.Bg.L4">#E1E1E1</Color>
<Color x:Key="C.Text.Primary">#201F1E</Color>
<Color x:Key="C.Text.Secondary">#605E5C</Color>
<Color x:Key="C.Text.Disable">#A19F9D</Color>
<Color x:Key="C.Text.Inverse">#FFFFFF</Color>
<Color x:Key="C.Border">#D2D0CE</Color>
<Color x:Key="C.Border.Focus">#005FB8</Color>
<Color x:Key="Color.Bg.L1">#F5F5F7</Color>
<Color x:Key="Color.Bg.L2">#FFFFFF</Color>
<Color x:Key="Color.Bg.L3">#F0F0F0</Color>
<Color x:Key="Color.Bg.L4">#E5E5E5</Color>
<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}" />
<SolidColorBrush x:Key="Brush.Bg.L4" Color="{StaticResource Color.Bg.L4}" />
<SolidColorBrush x:Key="Brush.Bg.Window" Color="{StaticResource C.Bg.L1}" />
<SolidColorBrush x:Key="Brush.Bg.Panel" Color="{StaticResource C.Bg.L2}" />
<SolidColorBrush x:Key="Brush.Bg.Input" Color="{StaticResource C.Bg.L3}" />
<SolidColorBrush x:Key="Brush.Bg.Hover" Color="{StaticResource C.Bg.L4}" />
<SolidColorBrush x:Key="Brush.Text.Primary" Color="{StaticResource C.Text.Primary}" />
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="{StaticResource C.Text.Secondary}" />
<SolidColorBrush x:Key="Brush.Text.Disabled" Color="{StaticResource C.Text.Disable}" />
<SolidColorBrush x:Key="Brush.Text.Inverse" Color="{StaticResource C.Text.Inverse}" />
<SolidColorBrush x:Key="Brush.Brand" Color="{StaticResource C.Brand}" />
<SolidColorBrush x:Key="Brush.Accent" Color="{StaticResource C.Brand}" />
<SolidColorBrush x:Key="Brush.Accent.Hover" Color="{StaticResource C.Brand.Hover}" />
<SolidColorBrush x:Key="Brush.Accent.Pressed" Color="{StaticResource C.Brand.Pressed}" />
<SolidColorBrush x:Key="Brush.Status.Success" Color="{StaticResource C.Success}" />
<SolidColorBrush x:Key="Brush.Status.Warning" Color="{StaticResource C.Warning}" />
<SolidColorBrush x:Key="Brush.Status.Danger" Color="{StaticResource C.Danger}" />
<SolidColorBrush x:Key="Brush.Border" Color="{StaticResource C.Border}" />
<SolidColorBrush x:Key="Brush.Border.Focus" Color="{StaticResource C.Border.Focus}" />
</ResourceDictionary>

View File

@@ -0,0 +1,23 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<FontFamily x:Key="Font.Normal">Microsoft YaHei, Segoe UI</FontFamily>
<FontFamily x:Key="Font.Code">Consolas, Cascadia Code, Courier New</FontFamily>
<sys:Double x:Key="Size.Font.Small">12</sys:Double>
<sys:Double x:Key="Size.Font.Normal">14</sys:Double>
<sys:Double x:Key="Size.Font.Large">18</sys:Double>
<sys:Double x:Key="Size.Font.Huge">24</sys:Double>
<CornerRadius x:Key="Radius.Small">3</CornerRadius>
<CornerRadius x:Key="Radius.Normal">5</CornerRadius>
<CornerRadius x:Key="Radius.Circle">100</CornerRadius>
<sys:Double x:Key="Height.Input">32</sys:Double>
<sys:Double x:Key="Height.Button">32</sys:Double>
<Thickness x:Key="Thickness.Border">1</Thickness>
<Thickness x:Key="Thickness.Focus">2</Thickness>
</ResourceDictionary>

View File

@@ -0,0 +1,378 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.Accent}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Inverse}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="15,0" />
<Setter Property="Height" Value="{StaticResource Height.Button}" />
<Setter Property="FontSize" Value="{StaticResource Size.Font.Normal}" />
<Setter Property="FontFamily" Value="{StaticResource Font.Normal}" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.Normal}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource Brush.Accent.Hover}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource Brush.Accent.Pressed}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="{DynamicResource Brush.Bg.Panel}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Disabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="Btn.Ghost"
BasedOn="{StaticResource {x:Type Button}}"
TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
</Trigger>
</Style.Triggers>
</Style>
<Style
x:Key="Btn.Danger"
BasedOn="{StaticResource {x:Type Button}}"
TargetType="Button">
<Setter Property="Background" Value="{DynamicResource Brush.Status.Danger}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Inverse}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.8" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.6" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Input}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="BorderThickness" Value="{StaticResource Thickness.Border}" />
<Setter Property="Padding" Value="8,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="{StaticResource Height.Input}" />
<Setter Property="FontFamily" Value="{StaticResource Font.Code}" />
<Setter Property="CaretBrush" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource Radius.Small}">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource Brush.Text.Secondary}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource Brush.Border.Focus}" />
<Setter TargetName="border" Property="BorderThickness" Value="{StaticResource Thickness.Focus}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</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" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
</Style>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border
x:Name="Bd"
Padding="8,4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<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.Panel}" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
</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="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">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.ListViewItem.Table" TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="MinHeight" Value="35" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border
x:Name="Bd"
Padding="4,0"
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>
</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="AccentBar" Property="Visibility" Value="Visible" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Accent}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="SemiBold" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="Style.TextBox.InTable"
BasedOn="{StaticResource {x:Type TextBox}}"
TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Input}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="30" />
</Style>
<Style x:Key="Style.TextBox.CodeEditor" TargetType="TextBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontFamily" Value="Consolas, Cascadia Code, Courier New, monospace" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Padding" Value="10" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="VerticalContentAlignment" Value="Top" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="HorizontalScrollBarVisibility" Value="Auto" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}" BorderThickness="0">
<ScrollViewer x:Name="PART_ContentHost" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="TabRadioStyle"
BasedOn="{StaticResource {x:Type ToggleButton}}"
TargetType="RadioButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Secondary}" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Padding" Value="15,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Background" Value="{DynamicResource Brush.Bg.L4}" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Hover}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Style.TextBox.Search" TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource Brush.Bg.Input}" />
<Setter Property="Foreground" Value="{DynamicResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Height" Value="28" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Padding" Value="25,0,5,0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Grid>
<Border
x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<TextBlock
Margin="8,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontSize="11"
Foreground="{DynamicResource Brush.Text.Secondary}"
IsHitTestVisible="False"
Text="🔍" />
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" />
<TextBlock
x:Name="Watermark"
Margin="28,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Text.Secondary}"
IsHitTestVisible="False"
Opacity="0.5"
Text="{TemplateBinding Tag}"
Visibility="Collapsed" />
</Grid>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Watermark" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Brush.Accent}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Brush.Bg.Panel}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>