具备界面基础功能

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,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 = "离线或无设备";
}
}
}
}
}