具备界面基础功能

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

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