具备界面基础功能
This commit is contained in:
18
SHH.CameraDashboard/Services/WebApis/ApiClient.cs
Normal file
18
SHH.CameraDashboard/Services/WebApis/ApiClient.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public class ApiClient
|
||||
{
|
||||
// 单例模式
|
||||
public static ApiClient Instance { get; } = new ApiClient();
|
||||
|
||||
// 各个模块的访问入口
|
||||
public CameraRepository Cameras { get; }
|
||||
public MonitorRepository Monitor { get; }
|
||||
|
||||
private ApiClient()
|
||||
{
|
||||
Cameras = new CameraRepository();
|
||||
Monitor = new MonitorRepository();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
public partial class CameraRepository
|
||||
{
|
||||
#region GetListByAddressAsync 获取摄像头列表
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 动态检测方法。
|
||||
/// 专门用于向导界面,根据临时输入的 IP 地址和端口号来获取摄像头列表。
|
||||
/// </summary>
|
||||
/// <param name="ipAddress">目标服务器的 IP 地址。</param>
|
||||
/// <param name="port">目标服务器的端口号。</param>
|
||||
/// <param name="pageName">调用此方法的页面或步骤名称,用于日志记录。</param>
|
||||
/// <returns>
|
||||
/// 一个异步任务,其结果包含一个 <see cref="WebApiCameraModel"/> 对象的列表。
|
||||
/// - 如果成功获取到列表,返回该列表(可能为空)。
|
||||
/// - 如果获取过程中发生任何异常(如网络错误、服务器无响应等),返回 <c>null</c>。
|
||||
/// </returns>
|
||||
public async Task<List<WebApiCameraModel>?> GetListByAddressAsync(string ipAddress, string port, string pageName)
|
||||
{
|
||||
// 1. 统一管理并拼接请求 URL
|
||||
// 在这里将基础地址与 API 路径组合,ViewModel 无需关心具体的路由细节。
|
||||
// WebApiRoutes.Cameras.Root 是一个假设的常量,例如 "/api/Cameras"。
|
||||
string requestUrl = $"http://{ipAddress}:{port}{WebApiRoutes.Cameras.Root}";
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 调用底层的 WebApiService 发送 GET 请求
|
||||
// 传入 "Wizard" 相关的模块名,便于在日志中区分请求来源。
|
||||
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, moduleName: $"摄像头列表-{pageName}", default, true);
|
||||
|
||||
// 3. 使用 JsonHelper 进行反序列化
|
||||
// 利用 JsonHelper 的防御性实现,即使 JSON 格式错误或内容为 "null",也不会抛出异常。
|
||||
var cameraList = JsonHelper.Deserialize<List<WebApiCameraModel>>(jsonResponse);
|
||||
|
||||
// 4. 确保返回的列表不为 null
|
||||
// 如果反序列化结果为 null(例如,API 返回 "null"),则返回一个空列表,
|
||||
// 这样调用方可以安全地遍历,而无需进行 null 检查。
|
||||
return cameraList ?? new List<WebApiCameraModel>();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 5. 捕获所有异常
|
||||
// 在动态检测阶段,网络不通、服务器宕机等都是预期内的情况。
|
||||
// 返回 null 作为明确的失败信号,ViewModel 可以据此向用户显示“连接失败”等提示。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ControlPowerAsync 摄像头启停
|
||||
|
||||
/// <summary>
|
||||
/// 调用服务端的接口来控制指定摄像头的启停状态(开机/关机)。
|
||||
/// </summary>
|
||||
/// <param name="ipAddress">目标服务器的 IP 地址。</param>
|
||||
/// <param name="port">目标服务器的端口号。</param>
|
||||
/// <param name="cameraId">要控制的摄像头的唯一ID。</param>
|
||||
/// <param name="enable">true 表示开机,false 表示关机。</param>
|
||||
/// <param name="pageName">调用此方法的页面或功能名称,用于日志记录。</param>
|
||||
/// <returns>
|
||||
/// 一个异步任务,其结果为一个布尔值:
|
||||
/// - <c>true</c>:表示操作成功(HTTP 响应为 200 OK)。
|
||||
/// - <c>false</c>:表示操作失败(如网络错误、服务器返回 4xx/5xx 错误等)。
|
||||
/// </returns>
|
||||
public async Task<bool> ControlPowerAsync(long cameraId, bool enable, string pageName)
|
||||
{
|
||||
var useServiceNode = AppGlobal.UseServiceNode;
|
||||
if (useServiceNode == null)
|
||||
return false;
|
||||
|
||||
var ipAddress = useServiceNode.ServiceNodeIp;
|
||||
var port = useServiceNode.ServiceNodePort;
|
||||
|
||||
// 1. 拼接请求 URL
|
||||
// 将控制参数(cameraId 和 enable)作为 URL 的一部分发送。
|
||||
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}/power?enabled={enable}";
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 调用底层服务发送 POST 请求
|
||||
// 关键点:
|
||||
// a. 即使服务端不关心请求体(Body),`PostAsync` 方法也要求一个非 null 的 JSON 字符串。
|
||||
// 因此,我们传入一个空的 JSON 对象 "{}" 作为占位符。
|
||||
// b. 我们不检查响应内容,因为如果请求失败(例如,服务器返回 404 Not Found 或 500 Internal Server Error),
|
||||
// `WebApiService` 会抛出异常。
|
||||
await WebApiService.Instance.PostAsync(requestUrl, "{}", moduleName: $"设备启停-{pageName}");
|
||||
|
||||
// 3. 如果代码执行到这里,说明请求成功且没有抛出异常。
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 4. 捕获所有可能发生的异常
|
||||
// 这包括网络连接问题、DNS解析失败、服务器无响应或返回错误状态码等。
|
||||
// 记录异常信息到调试输出,以便于排查问题。
|
||||
System.Diagnostics.Debug.WriteLine($"控制摄像头电源失败 [IP: {ipAddress}, CameraId: {cameraId}]: {ex.Message}");
|
||||
|
||||
// 返回 false 通知调用方操作已失败。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public partial class CameraRepository
|
||||
{
|
||||
#region GetCameraDetailsAsync 取单个摄像头详情
|
||||
|
||||
/// <summary>
|
||||
/// 获取摄像头的详细配置信息。
|
||||
/// 此方法封装了完整的 API 调用和 JSON 到 DTO 的手动映射过程。
|
||||
/// </summary>
|
||||
/// <param name="cameraId">要查询的摄像头的唯一 ID。</param>
|
||||
/// <returns>
|
||||
/// 一个异步任务,其结果是一个 <see cref="CameraEditInfo"/> 对象,包含摄像头的详细配置。
|
||||
/// 如果配置节点信息不存在、API 请求失败或 JSON 解析失败,则返回 <c>null</c>。
|
||||
/// </returns>
|
||||
public async Task<CameraEditInfo?> GetCameraDetailsAsync(long cameraId)
|
||||
{
|
||||
// 1. 从全局数据中获取当前使用的服务节点信息
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null)
|
||||
{
|
||||
// 如果没有配置服务节点,则无法获取信息
|
||||
return null;
|
||||
}
|
||||
|
||||
var ipAddress = serviceNode.ServiceNodeIp;
|
||||
var port = serviceNode.ServiceNodePort;
|
||||
|
||||
// 2. 拼接 API 请求的 URL
|
||||
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}";
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 调用 WebApiService 发送 GET 请求,获取原始 JSON 字符串
|
||||
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, "GetDetail");
|
||||
|
||||
// 4. 检查返回的 JSON 是否为空
|
||||
if (string.IsNullOrEmpty(jsonResponse))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. 将原始 JSON 字符串手动映射到目标 DTO 对象
|
||||
// 这种方式可以精确控制每个字段的转换,处理类型不匹配等问题。
|
||||
return MapJsonToEditDto(jsonResponse);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 6. 捕获所有可能的异常(网络错误、服务器错误等)
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] 获取摄像头详情失败 (ID: {cameraId}): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateCameraAsync 更新单个摄像头
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 更新摄像头的配置信息。
|
||||
/// 此方法负责将 <see cref="CameraEditInfo"/> 对象转换为 API 所需的 JSON 格式,并发送 PUT 请求。
|
||||
/// </summary>
|
||||
/// <param name="dto">包含摄像头新配置信息的 DTO 对象。</param>
|
||||
/// <returns>
|
||||
/// 一个异步任务,其结果为一个布尔值:
|
||||
/// - <c>true</c>:表示更新成功(HTTP 响应为 200 OK 或其他成功状态码)。
|
||||
/// - <c>false</c>:表示更新失败(如 DTO 为 null、网络错误、服务器返回错误等)。
|
||||
/// </returns>
|
||||
public async Task<bool> UpdateCameraAsync(CameraEditInfo dto, string pageName)
|
||||
{
|
||||
// 1. 防御性检查:确保传入的 DTO 对象不为 null
|
||||
if (dto == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 从全局数据中获取当前使用的服务节点信息
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null)
|
||||
{
|
||||
// 如果没有配置服务节点,则无法获取信息
|
||||
return false;
|
||||
}
|
||||
|
||||
var ipAddress = serviceNode.ServiceNodeIp;
|
||||
var port = serviceNode.ServiceNodePort;
|
||||
|
||||
|
||||
// 2. 拼接 PUT 请求的 URL
|
||||
// URL 格式: http://{ip}:{port}/api/Cameras/{id}
|
||||
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras/{dto.Id}";
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 将 DTO 对象手动映射为 API 所需的 JSON 字符串
|
||||
// 这一步至关重要,因为它处理了特殊的字段转换,例如:
|
||||
// 将 DTO 中的 `Brand` (int) 转换为 JSON 中的 `"brand": "HikVision"` (string)。
|
||||
string jsonPayload = MapDtoToEditJson(dto);
|
||||
|
||||
// 4. 调用 WebApiService 发送 PUT 请求
|
||||
// 注意:请确保您的 `WebApiService` 类中存在 `PutAsync` 方法。
|
||||
// 如果不存在,可以考虑添加一个,或者在某些 RESTful 设计中,也可以使用 `PostAsync` 代替。
|
||||
await WebApiService.Instance.PutAsync(requestUrl, jsonPayload, pageName);
|
||||
|
||||
// 5. 如果代码执行到这里,说明请求成功且没有抛出异常
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 6. 捕获所有可能的异常(网络错误、服务器错误、序列化错误等)
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] 更新摄像头配置失败 (ID: {dto.Id}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateCameraAsync 新增单个摄像头
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 创建一个新的摄像头配置。
|
||||
/// 此方法负责将 <see cref="CameraEditInfo"/> 对象转换为 API 所需的 JSON 格式,并发送 POST 请求到摄像头资源集合的根路径。
|
||||
/// </summary>
|
||||
/// <param name="nodeIp">目标服务器节点的 IP 地址。</param>
|
||||
/// <param name="nodePort">目标服务器节点的端口号。</param>
|
||||
/// <param name="dto">包含新摄像头配置信息的 DTO 对象。</param>
|
||||
/// <returns>
|
||||
/// 一个异步任务,其结果为一个布尔值:
|
||||
/// - <c>true</c>:表示创建成功(HTTP 响应为 201 Created 或其他成功状态码)。
|
||||
/// - <c>false</c>:表示创建失败(如 DTO 为 null、网络错误、服务器返回错误等)。
|
||||
/// </returns>
|
||||
public async Task<bool> CreateCameraAsync(CameraEditInfo dto, string pageName)
|
||||
{
|
||||
// 1. 防御性检查:确保传入的 DTO 对象不为 null
|
||||
if (dto == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 从全局数据中获取当前使用的服务节点信息
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null)
|
||||
{
|
||||
// 如果没有配置服务节点,则无法获取信息
|
||||
return false;
|
||||
}
|
||||
|
||||
var ipAddress = serviceNode.ServiceNodeIp;
|
||||
var port = serviceNode.ServiceNodePort;
|
||||
|
||||
// 2. 拼接 POST 请求的 URL
|
||||
// URL 格式: http://{ip}:{port}/api/Cameras
|
||||
// 注意:创建新资源通常是 POST 到资源集合的根路径,而不是单个资源的路径。
|
||||
// 此时 DTO 中的 `Id` 字段通常应为 0 或默认值,由服务器在创建时生成新的唯一 ID。
|
||||
string requestUrl = $"http://{ipAddress}:{port}/api/Cameras";
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 将 DTO 对象手动映射为 API 所需的 JSON 字符串
|
||||
// 复用与更新操作相同的映射逻辑,确保数据格式的一致性。
|
||||
string jsonPayload = MapDtoToEditJson(dto);
|
||||
|
||||
// 4. 调用 WebApiService 发送 POST 请求
|
||||
await WebApiService.Instance.PostAsync(requestUrl, jsonPayload, pageName);
|
||||
|
||||
// 5. 如果代码执行到这里,说明请求成功且没有抛出异常
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 6. 捕获所有可能的异常(网络错误、服务器错误、序列化错误等)
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] 创建新摄像头失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 删除摄像头
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteCameraAsync(long cameraId)
|
||||
{
|
||||
// 1. 从全局数据中获取当前使用的服务节点信息
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null)
|
||||
{
|
||||
// 如果没有配置服务节点,则无法获取信息
|
||||
return false;
|
||||
}
|
||||
|
||||
var ipAddress = serviceNode.ServiceNodeIp;
|
||||
var port = serviceNode.ServiceNodePort;
|
||||
|
||||
// URL: http://{ip}:{port}/api/Cameras/{id}
|
||||
string url = $"http://{ipAddress}:{port}/api/Cameras/{cameraId}";
|
||||
|
||||
try
|
||||
{
|
||||
// 发送 DELETE 请求
|
||||
await WebApiService.Instance.DeleteAsync(url, "DeleteCamera");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] 删除失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#region MapJsonToEditDto
|
||||
|
||||
/// <summary>
|
||||
/// 私有辅助方法:手动解析 JSON 字符串并映射到 <see cref="CameraEditInfo"/> DTO。
|
||||
/// 此方法能精确处理类型转换和提供默认值,避免自动反序列化时因类型不匹配而失败。
|
||||
/// </summary>
|
||||
/// <param name="json">从 API 获取的原始 JSON 字符串。</param>
|
||||
/// <returns>一个填充了数据的 <see cref="CameraEditInfo"/> 对象。即使解析失败,也会返回一个对象实例。</returns>
|
||||
private CameraEditInfo MapJsonToEditDto(string json)
|
||||
{
|
||||
// 初始化一个 DTO 对象,用于存储映射后的数据
|
||||
var cameraDto = new CameraEditInfo();
|
||||
|
||||
try
|
||||
{
|
||||
// 使用 System.Text.Json 的 JsonDocument 进行高性能的只读解析
|
||||
using (JsonDocument doc = JsonDocument.Parse(json))
|
||||
{
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
// --- 基础字段映射 ---
|
||||
// 使用 TryGetProperty 安全地获取属性,避免因字段不存在而抛出异常
|
||||
if (root.TryGetProperty("id", out var idElement)) cameraDto.Id = idElement.GetInt64();
|
||||
if (root.TryGetProperty("name", out var nameElement)) cameraDto.Name = nameElement.GetString() ?? string.Empty;
|
||||
if (root.TryGetProperty("ipAddress", out var ipElement)) cameraDto.IpAddress = ipElement.GetString();
|
||||
if (root.TryGetProperty("username", out var userElement)) cameraDto.Username = userElement.GetString();
|
||||
if (root.TryGetProperty("password", out var passElement)) cameraDto.Password = passElement.GetString();
|
||||
if (root.TryGetProperty("channelIndex", out var chElement)) cameraDto.ChannelIndex = chElement.GetInt32();
|
||||
if (root.TryGetProperty("rtspPath", out var rtspElement)) cameraDto.RtspPath = rtspElement.GetString();
|
||||
if (root.TryGetProperty("mainboardIp", out var mainIpElement)) cameraDto.MainboardIp = mainIpElement.GetString();
|
||||
if (root.TryGetProperty("mainboardPort", out var mainPortElement)) cameraDto.MainboardPort = mainPortElement.GetInt32();
|
||||
if (root.TryGetProperty("streamType", out var streamElement)) cameraDto.StreamType = streamElement.GetInt32();
|
||||
|
||||
// --- 类型转换 ---
|
||||
// 将 JSON 中的 int 类型端口号转换为 DTO 中的 ushort 类型
|
||||
if (root.TryGetProperty("port", out var portElement)) cameraDto.Port = (ushort)portElement.GetInt32();
|
||||
|
||||
// --- 特殊逻辑处理 ---
|
||||
// 将 JSON 中的品牌字符串(如 "HikVision")转换为 DTO 中的整数枚举
|
||||
if (root.TryGetProperty("brand", out var brandElement))
|
||||
{
|
||||
cameraDto.Brand = ParseBrandToEditInt(brandElement.GetString());
|
||||
}
|
||||
|
||||
// --- 设置默认值 ---
|
||||
// 为未从 API 获取或有特定默认值的字段设置初始值
|
||||
cameraDto.Location = string.Empty;
|
||||
cameraDto.UseGrayscale = false;
|
||||
cameraDto.EnhanceImage = true;
|
||||
cameraDto.AllowCompress = true;
|
||||
cameraDto.TargetResolution = string.Empty;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获 JSON 解析或映射过程中的任何异常
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] JSON 解析或映射到 DTO 时发生异常: {ex.Message}");
|
||||
// 即使发生异常,也返回一个对象实例,而不是 null,以防止上层代码出现空引用异常
|
||||
}
|
||||
|
||||
return cameraDto;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseBrandToEditInt
|
||||
|
||||
/// <summary>
|
||||
/// [读] API 字符串 -> DTO 枚举值 (int)
|
||||
/// 将 WebAPI 返回的 "HikVision" 等字符串解析为 DeviceBrand 枚举对应的 int
|
||||
/// </summary>
|
||||
private int ParseBrandToEditInt(string brandApiString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(brandApiString))
|
||||
return (int)DeviceBrand.Unknown;
|
||||
|
||||
// 统一转小写进行匹配,防止大小写差异
|
||||
var lowerName = brandApiString.Trim().ToLower();
|
||||
|
||||
// 1. 尝试直接通过枚举名解析 (例如 API 返回 "HikVision", 枚举也是 HikVision)
|
||||
// 这样如果 API 返回 "Usb",且枚举也有 "Usb",就能自动匹配
|
||||
if (Enum.TryParse(brandApiString, true, out DeviceBrand result))
|
||||
{
|
||||
return (int)result;
|
||||
}
|
||||
|
||||
return (int)DeviceBrand.Unknown;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ConvertBrandToEditString
|
||||
|
||||
/// <summary>
|
||||
/// [写] DTO 枚举值 (int) -> API 字符串
|
||||
/// 保存时,将 int 转换为 WebAPI 需要的字符串标识
|
||||
/// </summary>
|
||||
private string ConvertBrandToEditString(int brandValue)
|
||||
{
|
||||
// 将 int 强转为枚举,方便 switch
|
||||
var brand = (DeviceBrand)brandValue;
|
||||
|
||||
switch (brand)
|
||||
{
|
||||
case DeviceBrand.HikVision:
|
||||
return "HikVision"; // API 期望的字符串
|
||||
|
||||
case DeviceBrand.Dahua:
|
||||
return "Dahua";
|
||||
|
||||
case DeviceBrand.RtspGeneral:
|
||||
return "RTSP"; // 假设 API 期望全大写
|
||||
|
||||
case DeviceBrand.Usb:
|
||||
return "Usb";
|
||||
|
||||
case DeviceBrand.WebSocketShine:
|
||||
return "WebSocket"; // 需确认 API 期望什么
|
||||
|
||||
case DeviceBrand.File:
|
||||
return "File";
|
||||
|
||||
case DeviceBrand.OnvifGeneral:
|
||||
return "Onvif";
|
||||
|
||||
case DeviceBrand.Unknown:
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MapDtoToEditJson
|
||||
|
||||
/// <summary>
|
||||
/// 辅助方法:将 DTO 转换为 API 需要的 JSON 字符串
|
||||
/// </summary>
|
||||
private string MapDtoToEditJson(CameraEditInfo dto)
|
||||
{
|
||||
// 根据后端约定的最新格式构建匿名对象
|
||||
// 特点:
|
||||
// 1. brand 直接传 int
|
||||
// 2. 扩展参数(useGrayscale等) 直接放在根节点,不需要 vendorArguments 包裹
|
||||
var apiModel = new
|
||||
{
|
||||
id = dto.Id,
|
||||
name = dto.Name ?? "",
|
||||
|
||||
// ★ 修正1:直接使用 int 值,不再转换成 "HikVision" 字符串
|
||||
brand = dto.Brand,
|
||||
|
||||
location = dto.Location ?? "",
|
||||
ipAddress = dto.IpAddress,
|
||||
port = dto.Port,
|
||||
username = dto.Username ?? "",
|
||||
password = dto.Password ?? "",
|
||||
|
||||
// ★ 修正2:根据样例,renderHandle 传 0 (通常这是运行时句柄,保存时无意义)
|
||||
renderHandle = 0,
|
||||
|
||||
channelIndex = dto.ChannelIndex,
|
||||
rtspPath = dto.RtspPath ?? "",
|
||||
mainboardIp = dto.MainboardIp ?? "",
|
||||
mainboardPort = dto.MainboardPort,
|
||||
streamType = dto.StreamType,
|
||||
|
||||
// ★ 修正3:扁平化处理,直接放在根层级
|
||||
useGrayscale = dto.UseGrayscale,
|
||||
enhanceImage = dto.EnhanceImage,
|
||||
allowCompress = dto.AllowCompress,
|
||||
allowExpand = dto.AllowExpand,
|
||||
targetResolution = dto.TargetResolution ?? "",
|
||||
|
||||
allowShrink = dto.AllowShrink,
|
||||
allowEnlarge = dto.AllowEnlarge,
|
||||
};
|
||||
|
||||
return JsonHelper.Serialize(apiModel);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public partial class CameraRepository
|
||||
{
|
||||
public async Task<bool> UpdateImageProcessingAsync(CameraEditInfo dto)
|
||||
{
|
||||
if (dto == null) return false;
|
||||
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
// 1. 使用专用的 Processing 路由
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Processing(dto.Id.ToString())}";
|
||||
|
||||
// 2. 解析分辨率字符串 (例如 "1920x1080" -> 1920, 1080)
|
||||
int width = 1280; // 默认值
|
||||
int height = 720;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dto.TargetResolution))
|
||||
{
|
||||
var parts = dto.TargetResolution.ToLower().Split('x');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
int.TryParse(parts[0], out width);
|
||||
int.TryParse(parts[1], out height);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 构建精简的专用 JSON Payload
|
||||
// 只包含图像处理相关的字段,不发送其他干扰信息
|
||||
var payload = new
|
||||
{
|
||||
// --- 分辨率 (拆分成 int) ---
|
||||
targetWidth = width,
|
||||
targetHeight = height,
|
||||
|
||||
// 确保这些字段名与后端 Processing 接口的定义一致
|
||||
useGrayscale = dto.UseGrayscale,
|
||||
enhanceImage = dto.EnhanceImage,
|
||||
|
||||
// 缩放控制
|
||||
allowShrink = dto.AllowShrink,
|
||||
allowEnlarge = dto.AllowEnlarge, // 这里使用你确认过的 AllowEnlarge
|
||||
|
||||
// 目标分辨率
|
||||
targetResolution = dto.TargetResolution ?? ""
|
||||
};
|
||||
|
||||
string jsonBody = JsonHelper.Serialize(payload);
|
||||
|
||||
// 3. 发送请求
|
||||
// 通常专用功能接口使用 POST 或 PUT。根据你的路由命名(Processing 是名词/动词),
|
||||
// 且是对资源的部分修改,PUT 的可能性较大;但如果是“执行处理”,也可能是 POST。
|
||||
// 建议先试 PUT (因为是 Update 操作),如果报 405 则改 POST。
|
||||
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "图像处理配置");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"更新图像处理配置失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 获取指定设备的图像处理配置
|
||||
/// 对应后端: GET /api/cameras/{id}/processing
|
||||
/// </summary>
|
||||
public async Task<CameraEditInfo> GetImageProcessingAsync(long id)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return null;
|
||||
|
||||
// 1. 拼接 URL: http://.../api/Cameras/1001/processing
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{id}/processing";
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 发送 GET 请求
|
||||
string jsonResponse = await WebApiService.Instance.GetAsync(requestUrl, "获取图像处理配置");
|
||||
if (string.IsNullOrEmpty(jsonResponse)) return null;
|
||||
|
||||
// 3. 解析后端返回的 ProcessingOptions JSON
|
||||
// 注意:这里定义一个临时类来匹配后端 JSON 结构,确保字段名一致
|
||||
var options = JsonHelper.Deserialize<ProcessingOptionsDto>(jsonResponse);
|
||||
if (options == null) return null;
|
||||
|
||||
// 4. 映射回 CameraEditInfo 给 ViewModel 使用
|
||||
return new CameraEditInfo
|
||||
{
|
||||
Id = id,
|
||||
// 将宽高拼回 "1920x1080" 格式
|
||||
TargetResolution = $"{options.targetWidth}x{options.targetHeight}",
|
||||
|
||||
// 映射开关 (注意字段名对应关系)
|
||||
AllowShrink = options.enableShrink,
|
||||
AllowEnlarge = options.enableExpand, // 后端叫 EnableExpand
|
||||
EnhanceImage = options.enableBrightness
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[Repository] 获取图像配置失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// [内部类] 用于接收后端 JSON 的 DTO
|
||||
// 字段名需与后端 ProcessingOptions 序列化出来的 JSON 字段一致 (通常是 camelCase)
|
||||
private class ProcessingOptionsDto
|
||||
{
|
||||
public int targetWidth { get; set; }
|
||||
public int targetHeight { get; set; }
|
||||
public bool enableShrink { get; set; }
|
||||
public bool enableExpand { get; set; } // 对应后端的 EnableExpand
|
||||
public bool enableBrightness { get; set; } // 对应后端的 EnableBrightness
|
||||
public int brightness { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public partial class CameraRepository
|
||||
{
|
||||
// 文件: Services\WebApis\CameraReps\CameraRepository.cs
|
||||
|
||||
public async Task<bool> UpdateSubscriptionAsync(long cameraId, SubscriptionDto dto)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
// URL: POST /api/Cameras/{id}/subscriptions
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions";
|
||||
|
||||
try
|
||||
{
|
||||
string jsonBody = JsonHelper.Serialize(dto);
|
||||
// 发送 POST 请求
|
||||
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "更新订阅配置");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"更新订阅失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 获取订阅列表
|
||||
/// </summary>
|
||||
public async Task<List<SubscriptionDto>> GetSubscriptionsAsync(long cameraId)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return new List<SubscriptionDto>();
|
||||
|
||||
// URL: GET /api/Cameras/{id}/subscriptions
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions";
|
||||
|
||||
try
|
||||
{
|
||||
string json = await WebApiService.Instance.GetAsync(requestUrl, "获取订阅列表");
|
||||
|
||||
// 如果返回空或null,返回空列表
|
||||
if (string.IsNullOrEmpty(json)) return new List<SubscriptionDto>();
|
||||
|
||||
var list = JsonHelper.Deserialize<List<SubscriptionDto>>(json);
|
||||
return list ?? new List<SubscriptionDto>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"获取订阅列表失败: {ex.Message}");
|
||||
return new List<SubscriptionDto>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [修改] 删除/注销订阅
|
||||
/// 改为标准的 DELETE 请求
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteSubscriptionAsync(long cameraId, string appId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(appId)) return false;
|
||||
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
// 拼接 URL: DELETE /api/Cameras/1001/subscriptions/Client_01
|
||||
// 注意:AppId 如果包含特殊字符,建议 UrlEncode,但一般 ID 都是字母数字
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Root}/{cameraId}/subscriptions/{appId}";
|
||||
|
||||
try
|
||||
{
|
||||
// 调用刚刚在 WebApiService 里加的 DeleteAsync
|
||||
await WebApiService.Instance.DeleteAsync(requestUrl, "注销订阅");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"注销订阅失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 视频流订阅配置请求对象
|
||||
/// 用于定义第三方应用或内部模块对指定相机流的消费需求
|
||||
/// </summary>
|
||||
public class SubscriptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 进程唯一标识 (如 "AI_Process_01"、"Main_Display_02")
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅业务类型。
|
||||
/// 决定了后端流控引擎后续的资源分配(如是否开启录像机或渲染器)。
|
||||
/// </summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示帧率需求 (单位: fps)
|
||||
/// <para>不需要显示则设为 0,控制器会自动注销该类型需求</para>
|
||||
/// </summary>
|
||||
public int DisplayFps
|
||||
{
|
||||
get => TargetFps;
|
||||
set => TargetFps = value;
|
||||
}
|
||||
|
||||
public int TargetFps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// [新增] 实际显示/分发帧率
|
||||
/// JSON: "realFps"
|
||||
/// </summary>
|
||||
[JsonPropertyName("realFps")]
|
||||
public double RealFps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注信息。
|
||||
/// 用于记录订阅的用途、申请人或关联业务系统。
|
||||
/// </summary>
|
||||
public string Memo { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 窗口句柄(HWND)。
|
||||
/// 仅在 Type 为 HandleDisplay 时必填。格式通常为十六进制或十进制字符串。
|
||||
/// </summary>
|
||||
public string Handle { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 录像持续时长(分钟,范围 1-60)。
|
||||
/// 仅在 Type 为 LocalRecord 时有效。
|
||||
/// </summary>
|
||||
public int RecordDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 录像文件存放绝对路径。
|
||||
/// 仅在 Type 为 LocalRecord 时有效,例如:C:\Recordings\Room01。
|
||||
/// </summary>
|
||||
public string SavePath { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 通讯方式协议。
|
||||
/// 仅在 Type 为 NetworkTrans 或 WebPush 时有效,默认为 Network。
|
||||
/// </summary>
|
||||
public int Protocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标接收端 IP 地址。
|
||||
/// 仅在 Type 为 NetworkTrans 或 WebPush 且 Protocol 为 Network 时必填。
|
||||
/// </summary>
|
||||
public string TargetIp { get; set; }
|
||||
= string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标接收端端口号。
|
||||
/// 仅在 Type 为 NetworkTrans 或 WebPush 时必填。
|
||||
/// </summary>
|
||||
public int TargetPort { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅业务类型枚举
|
||||
/// 描述视频流的最终去向和业务用途,用于帧分发策略的路由决策
|
||||
/// </summary>
|
||||
public enum SubscriptionType
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地窗口渲染
|
||||
/// <para>直接在服务器端显示器绘制(如 OpenCV Window、WinForm 控件)</para>
|
||||
/// </summary>
|
||||
[Description("本地窗口显示")]
|
||||
LocalWindow = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 本地录像存储
|
||||
/// <para>写入磁盘文件(如 MP4/AVI 格式,支持定时切割、循环覆盖)</para>
|
||||
/// </summary>
|
||||
[Description("本地录像存储")]
|
||||
LocalRecord = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 句柄绑定显示
|
||||
/// <para>渲染到指定 HWND 窗口句柄(如 SDK 硬件解码渲染到客户端控件)</para>
|
||||
/// </summary>
|
||||
[Description("句柄绑定显示")]
|
||||
HandleDisplay = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 自定义网络传输
|
||||
/// <para>通过私有协议转发给第三方系统(如工控机、告警服务器)</para>
|
||||
/// </summary>
|
||||
[Description("自定义网络传输")]
|
||||
NetworkTrans = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 网页端推流
|
||||
/// <para>转码为 Web 标准协议(如 WebRTC、HLS、RTMP)供浏览器播放</para>
|
||||
/// </summary>
|
||||
[Description("网页端推流")]
|
||||
WebPush = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 网络传输协议类型
|
||||
/// </summary>
|
||||
public enum TransportProtocol
|
||||
{
|
||||
/// <summary> 可靠传输 (默认) </summary>
|
||||
Tcp = 0,
|
||||
|
||||
/// <summary> 快速传输 (可能丢包/花屏) </summary>
|
||||
Udp = 1,
|
||||
|
||||
/// <summary> 组播 (节省带宽) </summary>
|
||||
Multicast = 2,
|
||||
|
||||
/// <summary> 内存交互 </summary>
|
||||
Memory = 99,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
public partial class CameraRepository
|
||||
{
|
||||
#region PtzControlAsync
|
||||
|
||||
/// <summary>
|
||||
/// 发送 PTZ 控制指令
|
||||
/// </summary>
|
||||
public async Task<bool> PtzControlAsync(long cameraId, PtzControlDto payload)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Ptz(cameraId.ToString())}";
|
||||
|
||||
try
|
||||
{
|
||||
string json = JsonHelper.Serialize(payload);
|
||||
await WebApiService.Instance.PostAsync(requestUrl, json, "PTZ控制");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"PTZ控制失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region 运维指令 (Maintenance)
|
||||
|
||||
/// <summary>
|
||||
/// [修正] 发送校时指令
|
||||
/// 修正点:后端 [FromBody] DateTime 需要接收带双引号的标准 ISO 时间字符串
|
||||
/// </summary>
|
||||
public async Task<bool> SyncTimeAsync(long cameraId)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Time(cameraId.ToString())}";
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 获取当前时间,格式化为标准 ISO 8601 (yyyy-MM-ddTHH:mm:ss)
|
||||
string timeStr = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss");
|
||||
|
||||
// 2. [关键] 手动构造 JSON Body,必须包含双引号
|
||||
// 例如发送的内容应该是: "2025-01-01T12:00:00"
|
||||
string jsonBody = $"\"{timeStr}\"";
|
||||
|
||||
// 3. 发送请求
|
||||
// 确保你的 WebApiService.PostAsync 会设置 Content-Type: application/json
|
||||
await WebApiService.Instance.PostAsync(requestUrl, jsonBody, "设备校时");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"校时失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送远程重启指令
|
||||
/// 接口定义: [HttpPost("{id}/reboot")] public async Task<IActionResult> RebootDevice(long id)
|
||||
/// 该接口没有 [FromBody] 参数,Body 可以为空或空 JSON
|
||||
/// </summary>
|
||||
public async Task<bool> RebootCameraAsync(long cameraId)
|
||||
{
|
||||
var serviceNode = AppGlobal.UseServiceNode;
|
||||
if (serviceNode == null) return false;
|
||||
|
||||
string requestUrl = $"http://{serviceNode.ServiceNodeIp}:{serviceNode.ServiceNodePort}{WebApiRoutes.Cameras.Reboot(cameraId.ToString())}";
|
||||
|
||||
try
|
||||
{
|
||||
await WebApiService.Instance.PostAsync(requestUrl, "{}", "远程重启");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"重启失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
139
SHH.CameraDashboard/Services/WebApis/Models/CameraEditInfo.cs
Normal file
139
SHH.CameraDashboard/Services/WebApis/Models/CameraEditInfo.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
|
||||
// ==============================================================================
|
||||
// 1. 物理与运行配置 DTO (对应 CRUD 操作)
|
||||
// 用于设备新增/全量配置查询,包含基础身份、连接信息、运行参数等全量字段
|
||||
// ==============================================================================
|
||||
public class CameraEditInfo
|
||||
{
|
||||
// --- 基础身份 (Identity) ---
|
||||
/// <summary>
|
||||
/// 设备唯一标识
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "设备ID不能为空")]
|
||||
[Range(1, long.MaxValue, ErrorMessage = "设备ID必须为正整数")]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备友好名称
|
||||
/// </summary>
|
||||
[MaxLength(64, ErrorMessage = "设备名称长度不能超过64个字符")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)
|
||||
/// </summary>
|
||||
[Range(0, 10, ErrorMessage = "品牌类型值必须在0-10范围内")]
|
||||
public int Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设备安装位置描述
|
||||
/// </summary>
|
||||
[MaxLength(128, ErrorMessage = "安装位置长度不能超过128个字符")]
|
||||
public string Location { get; set; } = string.Empty;
|
||||
|
||||
// --- 核心连接 (Connectivity) - 修改此类参数触发冷重启 ---
|
||||
/// <summary>
|
||||
/// 摄像头IP地址
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "IP地址不能为空")]
|
||||
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$",
|
||||
ErrorMessage = "请输入合法的IPv4地址")]
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SDK端口 (如海康默认8000)
|
||||
/// </summary>
|
||||
[Range(1, 65535, ErrorMessage = "端口号必须在1-65535范围内")]
|
||||
public ushort Port { get; set; } = 8000;
|
||||
|
||||
/// <summary>
|
||||
/// 登录用户名
|
||||
/// </summary>
|
||||
[MaxLength(32, ErrorMessage = "用户名长度不能超过32个字符")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
[MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public long RenderHandle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通道号 (通常为1)
|
||||
/// </summary>
|
||||
[Range(1, 32, ErrorMessage = "通道号必须在1-32范围内")]
|
||||
public int ChannelIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// RTSP流路径 (备用或非SDK模式使用)
|
||||
/// </summary>
|
||||
[MaxLength(256, ErrorMessage = "RTSP地址长度不能超过256个字符")]
|
||||
public string RtspPath { get; set; } = string.Empty;
|
||||
|
||||
// --- 主板关联信息 (Metadata) ---
|
||||
/// <summary>
|
||||
/// 关联主板IP地址
|
||||
/// </summary>
|
||||
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$",
|
||||
ErrorMessage = "请输入合法的IPv4地址")]
|
||||
public string MainboardIp { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 关联主板端口
|
||||
/// </summary>
|
||||
[Range(1, 65535, ErrorMessage = "主板端口号必须在1-65535范围内")]
|
||||
public int MainboardPort { get; set; } = 80;
|
||||
|
||||
// --- 运行时参数 (Runtime Options) - 支持热更新 ---
|
||||
/// <summary>
|
||||
/// 码流类型 (0:主码流, 1:子码流)
|
||||
/// </summary>
|
||||
[Range(0, 1, ErrorMessage = "码流类型只能是0(主码流)或1(子码流)")]
|
||||
public int StreamType { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用灰度图 (用于AI分析场景加速)
|
||||
/// </summary>
|
||||
public bool UseGrayscale { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用图像增强 (去噪/锐化等)
|
||||
/// </summary>
|
||||
public bool EnhanceImage { get; set; } = true;
|
||||
|
||||
// --- 画面变换 (Transform) - 支持热更新 ---
|
||||
/// <summary>
|
||||
/// 是否允许图像压缩 (降低带宽占用)
|
||||
/// </summary>
|
||||
public bool AllowCompress { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许图像放大 (提升渲染质量)
|
||||
/// </summary>
|
||||
public bool AllowExpand { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许图像缩小
|
||||
/// </summary>
|
||||
public bool AllowShrink { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许放大
|
||||
/// </summary>
|
||||
public bool AllowEnlarge { get; set; } = false;
|
||||
|
||||
// 给个默认值,比如 0 或 20
|
||||
public int Brightness { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 目标分辨率 (格式如 1920x1080,空则保持原图)
|
||||
/// </summary>
|
||||
[RegularExpression(@"^\d+x\d+$", ErrorMessage = "分辨率格式必须为 宽度x高度 (如 1920x1080)")]
|
||||
public string TargetResolution { get; set; } = string.Empty;
|
||||
}
|
||||
73
SHH.CameraDashboard/Services/WebApis/Models/DeviceBrand.cs
Normal file
73
SHH.CameraDashboard/Services/WebApis/Models/DeviceBrand.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频源物理/逻辑品牌类型
|
||||
/// 职责:用于工厂模式匹配具体的 IVideoSource 实现类,并定义基础通信协议栈
|
||||
/// </summary>
|
||||
public enum DeviceBrand
|
||||
{
|
||||
/// <summary>
|
||||
/// 未知
|
||||
/// </summary>
|
||||
[Description("未知")]
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 海康威视 (HikVision)
|
||||
/// 技术路径:基于海康私有 SDK (HCNetSDK.dll / PlayCtrl.dll)。
|
||||
/// 特性:支持全功能控制(PTZ、对讲、配置、报警回传)。
|
||||
/// </summary>
|
||||
[Description("海康威视")]
|
||||
HikVision = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 大华 (Dahua)
|
||||
/// 技术路径:基于大华私有 SDK (dhnetsdk.dll / dhplay.dll)。
|
||||
/// 特性:支持全功能控制,与海康私有协议不兼容。
|
||||
/// </summary>
|
||||
[Description("大华")]
|
||||
Dahua = 2,
|
||||
|
||||
/// <summary>
|
||||
/// USB 摄像头 / 虚拟摄像头
|
||||
/// 技术路径:基于 DirectShow 或 Windows Media Foundation。
|
||||
/// 特性:通常通过 OpenCV (VideoCapture) 或 DirectShowLib 直接读取本地硬件引用。
|
||||
/// </summary>
|
||||
[Description("USB")]
|
||||
Usb = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 标准 RTSP 流媒体
|
||||
/// 技术路径:基于标准 RTSP/RTP 协议 (RFC 2326)。
|
||||
/// 特性:跨品牌兼容,通常使用 FFmpeg 或 GStreamer 库取流,仅支持音视频,不支持云台控制。
|
||||
/// </summary>
|
||||
[Description("RTSP")]
|
||||
RtspGeneral = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 三恒自研 WebSocket 流
|
||||
/// 技术路径:基于 WebSocket 传输的自定义二进制或 Base64 帧。
|
||||
/// 特性:专用于 Web 或云端推送场景的私有流媒体格式。
|
||||
/// </summary>
|
||||
[Description("三恒WebSocket")]
|
||||
WebSocketShine = 5,
|
||||
|
||||
/// <summary>
|
||||
/// 本地视频文件
|
||||
/// 技术路径:基于文件 IO 的离线解码。
|
||||
/// 特性:常用于算法演示、回放模拟,支持 Mp4, Avi, Mkv 等容器格式。
|
||||
/// </summary>
|
||||
[Description("文件")]
|
||||
File = 6,
|
||||
|
||||
/// <summary>
|
||||
/// 未知/通用标准 (ONVIF)
|
||||
/// 技术路径:基于标准 ONVIF WebService。
|
||||
/// 特性:用于接入非主流厂商但符合 ONVIF 标准的设备,支持基础 PTZ。
|
||||
/// </summary>
|
||||
[Description("通用标准")]
|
||||
OnvifGeneral = 7
|
||||
}
|
||||
}
|
||||
20
SHH.CameraDashboard/Services/WebApis/Models/PtzAction.cs
Normal file
20
SHH.CameraDashboard/Services/WebApis/Models/PtzAction.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
/// <summary>
|
||||
/// PTZ 控制动作枚举
|
||||
/// </summary>
|
||||
public enum PtzAction
|
||||
{
|
||||
Stop = 0, // 停止动作
|
||||
Up, // 向上
|
||||
Down, // 向下
|
||||
Left, // 向左
|
||||
Right, // 向右
|
||||
ZoomIn, // 变倍+ (放大)
|
||||
ZoomOut, // 变倍- (缩小)
|
||||
FocusNear, // 聚焦近
|
||||
FocusFar, // 聚焦远
|
||||
IrisOpen, // 光圈大
|
||||
IrisClose, // 光圈小
|
||||
Wiper // 雨刷
|
||||
}
|
||||
20
SHH.CameraDashboard/Services/WebApis/Models/PtzControlDto.cs
Normal file
20
SHH.CameraDashboard/Services/WebApis/Models/PtzControlDto.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
public class PtzControlDto
|
||||
{
|
||||
// 确保将枚举序列化为字符串 (如 "Up") 而不是数字
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public PtzAction Action { get; set; }
|
||||
|
||||
// 是否是停止指令 (true=停止当前动作)
|
||||
public bool Stop { get; set; }
|
||||
|
||||
// 速度 (1-10)
|
||||
public int Speed { get; set; } = 5;
|
||||
|
||||
// 持续时间 (毫秒),用于点动模式 (如雨刷)
|
||||
public int Duration { get; set; } = 0;
|
||||
}
|
||||
181
SHH.CameraDashboard/Services/WebApis/Models/WebApiCameraModel.cs
Normal file
181
SHH.CameraDashboard/Services/WebApis/Models/WebApiCameraModel.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
/// <summary>
|
||||
/// 代表一个网络摄像头的模型,用于在 UI 上显示和监控其状态。
|
||||
/// 实现了 <see cref="INotifyPropertyChanged"/> 接口,当属性值改变时可以通知视图进行更新。
|
||||
/// </summary>
|
||||
public class WebApiCameraModel : INotifyPropertyChanged
|
||||
{
|
||||
#region --- INotifyPropertyChanged 实现 ---
|
||||
|
||||
/// <summary>
|
||||
/// 当属性值更改时发生。
|
||||
/// </summary>
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 引发 <see cref="PropertyChanged"/> 事件。
|
||||
/// </summary>
|
||||
/// <param name="propertyName">已更改的属性名称。如果未提供,则使用调用方成员的名称。</param>
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置属性值的通用方法。只有当值发生变化时,才会更新字段并通知属性更改。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">属性的类型。</typeparam>
|
||||
/// <param name="field">对存储属性值的字段的引用。</param>
|
||||
/// <param name="value">属性的新值。</param>
|
||||
/// <param name="propertyName">属性的名称。</param>
|
||||
/// <returns>如果值已更改,则返回 <c>true</c>;否则返回 <c>false</c>。</returns>
|
||||
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 摄像头属性 ---
|
||||
|
||||
private int _id;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的唯一标识符。
|
||||
/// </summary>
|
||||
public int Id
|
||||
{
|
||||
get => _id;
|
||||
set => SetProperty(ref _id, value);
|
||||
}
|
||||
|
||||
private string _name = string.Empty;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的自定义名称。
|
||||
/// </summary>
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => SetProperty(ref _name, value);
|
||||
}
|
||||
|
||||
private string _ipAddress = string.Empty;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的 IP 地址。
|
||||
/// </summary>
|
||||
public string IpAddress
|
||||
{
|
||||
get => _ipAddress;
|
||||
set => SetProperty(ref _ipAddress, value);
|
||||
}
|
||||
|
||||
private string _brand = string.Empty;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的品牌。
|
||||
/// </summary>
|
||||
public string Brand
|
||||
{
|
||||
get => _brand;
|
||||
set => SetProperty(ref _brand, value);
|
||||
}
|
||||
|
||||
private string _status = string.Empty;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的综合状态描述(例如:"在线", "离线", "连接中")。
|
||||
/// </summary>
|
||||
public string Status
|
||||
{
|
||||
get => _status;
|
||||
set => SetProperty(ref _status, value);
|
||||
}
|
||||
|
||||
private bool _isOnline;
|
||||
/// <summary>
|
||||
/// 获取或设置一个值,该值指示摄像头的逻辑连接状态(API 是否可达)。
|
||||
/// </summary>
|
||||
public bool IsOnline
|
||||
{
|
||||
get => _isOnline;
|
||||
set => SetProperty(ref _isOnline, value);
|
||||
}
|
||||
|
||||
private bool _isPhysicalOnline;
|
||||
/// <summary>
|
||||
/// 获取或设置一个值,该值指示摄像头的物理连接状态(例如:通过 ICMP ping 检测)。
|
||||
/// </summary>
|
||||
public bool IsPhysicalOnline
|
||||
{
|
||||
get => _isPhysicalOnline;
|
||||
set => SetProperty(ref _isPhysicalOnline, value);
|
||||
}
|
||||
|
||||
private bool _isRunning;
|
||||
/// <summary>
|
||||
/// 获取或设置一个值,该值指示摄像头的流媒体服务是否正在运行。
|
||||
/// </summary>
|
||||
public bool IsRunning
|
||||
{
|
||||
get => _isRunning;
|
||||
set => SetProperty(ref _isRunning, value);
|
||||
}
|
||||
|
||||
private int _width;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头视频流的宽度(分辨率)。
|
||||
/// </summary>
|
||||
public int Width
|
||||
{
|
||||
get => _width;
|
||||
set => SetProperty(ref _width, value);
|
||||
}
|
||||
|
||||
private int _height;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头视频流的高度(分辨率)。
|
||||
/// </summary>
|
||||
public int Height
|
||||
{
|
||||
get => _height;
|
||||
set => SetProperty(ref _height, value);
|
||||
}
|
||||
|
||||
private int _realFps;
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头当前的实际帧率(FPS)。
|
||||
/// </summary>
|
||||
public int RealFps
|
||||
{
|
||||
get => _realFps;
|
||||
set => SetProperty(ref _realFps, value);
|
||||
}
|
||||
|
||||
private long _totalFrames;
|
||||
/// <summary>
|
||||
/// 获取或设置自启动以来收到的总帧数。
|
||||
/// </summary>
|
||||
public long TotalFrames
|
||||
{
|
||||
get => _totalFrames;
|
||||
set => SetProperty(ref _totalFrames, value);
|
||||
}
|
||||
|
||||
private int _streamType;
|
||||
/// <summary>
|
||||
/// 获取或设置视频流的类型(例如:0 = 主码流, 1 = 子码流)。
|
||||
/// </summary>
|
||||
public int StreamType
|
||||
{
|
||||
get => _streamType;
|
||||
set => SetProperty(ref _streamType, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
38
SHH.CameraDashboard/Services/WebApis/MonitorRepository.cs
Normal file
38
SHH.CameraDashboard/Services/WebApis/MonitorRepository.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public class MonitorRepository
|
||||
{
|
||||
private const string MODULE = "MonitorAPI";
|
||||
public async Task<MonitorWebApiData> GetDashboardDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await WebApiService.Instance.GetAsync(WebApiRoutes.Monitor.Dashboard, MODULE);
|
||||
|
||||
// 1. 反序列化
|
||||
var data = JsonHelper.Deserialize<MonitorWebApiData>(json);
|
||||
|
||||
// 2. 【核心修复】空合并运算符
|
||||
// 如果 data 为 null,立即 new 一个空对象返回
|
||||
return data ?? new MonitorWebApiData();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 3. 【兜底】发生任何异常(断网/解析错误),返回空对象
|
||||
// 保证 ViewModel 拿到永远是实例
|
||||
return new MonitorWebApiData();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetSnapshotUrlAsync(string id)
|
||||
{
|
||||
// 假设返回的是图片 URL 或者是 Base64
|
||||
return await WebApiService.Instance.GetAsync(WebApiRoutes.Monitor.Snapshot(id), MODULE);
|
||||
}
|
||||
}
|
||||
|
||||
public class MonitorWebApiData
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
34
SHH.CameraDashboard/Services/WebApis/Routes.cs
Normal file
34
SHH.CameraDashboard/Services/WebApis/Routes.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public static class WebApiRoutes
|
||||
{
|
||||
// 摄像头模块
|
||||
public static class Cameras
|
||||
{
|
||||
public const string Root = "/api/Cameras";
|
||||
public static string ById(string id) => $"{Root}/{id}";
|
||||
public static string BindHandle(string id) => $"{Root}/{id}/bind-handle";
|
||||
public static string Power(string id) => $"{Root}/{id}/power";
|
||||
public static string Options(string id) => $"{Root}/{id}/options";
|
||||
public static string Subscriptions(string id) => $"{Root}/{id}/subscriptions";
|
||||
public static string Logs(string id) => $"{Root}/{id}/logs";
|
||||
public static string Processing(string id) => $"{Root}/{id}/processing";
|
||||
public static string Time(string id) => $"{Root}/{id}/time";
|
||||
public static string Reboot(string id) => $"{Root}/{id}/reboot";
|
||||
public static string Ptz(string id) => $"{Root}/{id}/ptz";
|
||||
}
|
||||
|
||||
// 监控模块
|
||||
public static class Monitor
|
||||
{
|
||||
public const string Root = "/api/Monitor";
|
||||
public const string All = $"{Root}/all";
|
||||
public const string Dashboard = $"{Root}/dashboard";
|
||||
public const string SystemLogs = $"{Root}/system-logs";
|
||||
public const string UpdateProcessing = $"{Root}/update-processing";
|
||||
public static string ById(string id) => $"{Root}/{id}";
|
||||
public static string Snapshot(string id) => $"{Root}/snapshot/{id}";
|
||||
public static string Diagnose(string id) => $"{Root}/diagnose/{id}";
|
||||
}
|
||||
}
|
||||
}
|
||||
299
SHH.CameraDashboard/Services/WebApis/WebApiService.cs
Normal file
299
SHH.CameraDashboard/Services/WebApis/WebApiService.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局 Web API 服务
|
||||
/// 职责:
|
||||
/// 1. 统一管理 HttpClient 实例,避免资源耗尽。
|
||||
/// 2. 封装标准的 CRUD (GET, POST, PUT, DELETE, PATCH) 操作。
|
||||
/// 3. 提供详细的请求日志(耗时、状态码、请求/响应内容)。
|
||||
/// 4. 处理超时和异常,提供一致的错误处理机制。
|
||||
/// </summary>
|
||||
public class WebApiService
|
||||
{
|
||||
#region --- 静态成员 ---
|
||||
|
||||
/// <summary>
|
||||
/// WebAPI 设为单例
|
||||
/// </summary>
|
||||
public static WebApiService Instance { get; } = new WebApiService();
|
||||
|
||||
/// <summary>
|
||||
/// 静态 HttpClient 实例。
|
||||
/// 最佳实践:应用程序应共享单个 HttpClient 实例以避免套接字耗尽。
|
||||
/// </summary>
|
||||
private static readonly HttpClient _client;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 实例成员 ---
|
||||
|
||||
/// <summary>
|
||||
/// 当一个请求完成(无论成功或失败)时发生。
|
||||
/// 可用于记录详细的 API 调用日志。
|
||||
/// </summary>
|
||||
public event Action<LogWebApiModel>? OnRequestCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// 日志内容的最大长度(5KB),防止过大的响应体撑爆内存。
|
||||
/// </summary>
|
||||
private const int MAX_LOG_LENGTH = 5120;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
private WebApiService()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 静态构造函数,用于初始化 HttpClient。
|
||||
/// </summary>
|
||||
static WebApiService()
|
||||
{
|
||||
// 配置 HttpClient 处理器
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
// 设置连接池寿命为 15 分钟。
|
||||
// 这有助于解决 DNS 变更后,HttpClient 仍使用旧 IP 的问题。
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(15)
|
||||
};
|
||||
|
||||
_client = new HttpClient(handler)
|
||||
{
|
||||
// 设置默认请求超时时间为 15 秒。
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 核心 CRUD 方法 ---
|
||||
|
||||
/// <summary>
|
||||
/// 发送一个 GET 请求。
|
||||
/// </summary>
|
||||
/// <param name="url">请求的完整 URL。</param>
|
||||
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
|
||||
/// <param name="token">用于取消操作的令牌。</param>
|
||||
/// <returns>成功时返回响应内容的字符串表示。</returns>
|
||||
public async Task<string> GetAsync(string url, string moduleName = "System", CancellationToken token = default, bool isAutoPost = false)
|
||||
{
|
||||
return await ExecuteRequestAsync(url, "GET", null, moduleName, token, async () =>
|
||||
{
|
||||
return await _client.GetAsync(url, token);
|
||||
}, isAutoPost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送一个 POST 请求。
|
||||
/// </summary>
|
||||
/// <param name="url">请求的完整 URL。</param>
|
||||
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
|
||||
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
|
||||
/// <param name="token">用于取消操作的令牌。</param>
|
||||
/// <returns>成功时返回响应内容的字符串表示。</returns>
|
||||
public async Task<string> PostAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
|
||||
{
|
||||
return await ExecuteRequestAsync(url, "POST", jsonBody, moduleName, token, async () =>
|
||||
{
|
||||
using var content = CreateJsonContent(jsonBody);
|
||||
return await _client.PostAsync(url, content, token);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送一个 PUT 请求(通常用于替换整个资源)。
|
||||
/// </summary>
|
||||
/// <param name="url">请求的完整 URL。</param>
|
||||
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
|
||||
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
|
||||
/// <param name="token">用于取消操作的令牌。</param>
|
||||
/// <returns>成功时返回响应内容的字符串表示。</returns>
|
||||
public async Task<string> PutAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
|
||||
{
|
||||
return await ExecuteRequestAsync(url, "PUT", jsonBody, moduleName, token, async () =>
|
||||
{
|
||||
using var content = CreateJsonContent(jsonBody);
|
||||
return await _client.PutAsync(url, content, token);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送一个 PATCH 请求(通常用于更新资源的部分属性)。
|
||||
/// </summary>
|
||||
/// <param name="url">请求的完整 URL。</param>
|
||||
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
|
||||
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
|
||||
/// <param name="token">用于取消操作的令牌。</param>
|
||||
/// <returns>成功时返回响应内容的字符串表示。</returns>
|
||||
public async Task<string> PatchAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
|
||||
{
|
||||
return await ExecuteRequestAsync(url, "PATCH", jsonBody, moduleName, token, async () =>
|
||||
{
|
||||
using var content = CreateJsonContent(jsonBody);
|
||||
return await _client.PatchAsync(url, content, token);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送一个 DELETE 请求。
|
||||
/// </summary>
|
||||
/// <param name="url">请求的完整 URL。</param>
|
||||
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
|
||||
/// <param name="token">用于取消操作的令牌。</param>
|
||||
/// <returns>成功时返回响应内容的字符串表示。</returns>
|
||||
public async Task<string> DeleteAsync(string url, string moduleName = "System", CancellationToken token = default)
|
||||
{
|
||||
return await ExecuteRequestAsync(url, "DELETE", null, moduleName, token, async () =>
|
||||
{
|
||||
return await _client.DeleteAsync(url, token);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 私有辅助方法 ---
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个 UTF-8 编码的 JSON 内容对象。
|
||||
/// </summary>
|
||||
private StringContent CreateJsonContent(string json)
|
||||
{
|
||||
return new StringContent(json ?? "", Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核心执行引擎,封装了所有 HTTP 请求的通用逻辑:计时、日志、异常处理。
|
||||
/// </summary>
|
||||
private async Task<string> ExecuteRequestAsync(
|
||||
string url,
|
||||
string method,
|
||||
string? requestData,
|
||||
string moduleName,
|
||||
CancellationToken token,
|
||||
Func<Task<HttpResponseMessage>> action,
|
||||
bool isAutoPost = false)
|
||||
{
|
||||
if (requestData == null)
|
||||
requestData = "(请求数据为空)";
|
||||
|
||||
var log = new LogWebApiModel
|
||||
{
|
||||
Time = DateTime.Now,
|
||||
Method = method,
|
||||
Url = url,
|
||||
RequestData = TruncateLog(requestData),
|
||||
AppModule = moduleName,
|
||||
IsAutoPost = isAutoPost,
|
||||
};
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// 执行具体的 HTTP 请求
|
||||
using var response = await action();
|
||||
|
||||
// 读取响应内容
|
||||
var responseString = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
// 更新日志
|
||||
log.StatusCode = ((int)response.StatusCode).ToString();
|
||||
log.ResponseData = TruncateLog(responseString);
|
||||
|
||||
// 如果 HTTP 状态码不是 2xx,则抛出异常
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return responseString;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获所有异常,更新日志状态为 "Error"
|
||||
log.StatusCode = "Error";
|
||||
log.ResponseData = $"Client Exception: {ex.Message}";
|
||||
|
||||
// 重新抛出异常,让调用者知道请求失败
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 无论成功或失败,都记录耗时并触发日志事件
|
||||
sw.Stop();
|
||||
log.ElapsedMilliseconds = sw.ElapsedMilliseconds;
|
||||
|
||||
// 安全地触发事件,防止订阅者抛出异常影响主线程
|
||||
try
|
||||
{
|
||||
OnRequestCompleted?.Invoke(log);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
// 记录日志系统自身的错误,但不中断程序
|
||||
Debug.WriteLine($"Log System Error: {logEx.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 截断过长的日志内容,并尝试格式化 JSON 以便阅读。
|
||||
/// </summary>
|
||||
private string TruncateLog(string? content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content)) return string.Empty;
|
||||
|
||||
// 1. 【新增步骤】尝试将内容格式化为漂亮的 JSON
|
||||
// 注意:格式化会增加空格和换行,导致长度变长,这是符合预期的
|
||||
string finalContent = TryFormatJson(content);
|
||||
|
||||
// 2. 截断逻辑 (基于格式化后的长度)
|
||||
if (finalContent.Length > MAX_LOG_LENGTH)
|
||||
{
|
||||
// 保留前 N 个字符,并在末尾添加提示
|
||||
return finalContent.Substring(0, MAX_LOG_LENGTH) +
|
||||
$"\r\n\r\n... [日志过长已截断,显示前 {MAX_LOG_LENGTH} 字符]";
|
||||
}
|
||||
|
||||
return finalContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试将字符串解析并格式化为缩进的 JSON。
|
||||
/// 如果解析失败(说明不是 JSON),则原样返回。
|
||||
/// </summary>
|
||||
private string TryFormatJson(string content)
|
||||
{
|
||||
// 简单的性能优化:如果不像 JSON(不以 { 或 [ 开头),直接跳过
|
||||
var trimmed = content.Trim();
|
||||
if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
|
||||
(trimmed.StartsWith("[") && trimmed.EndsWith("]")))
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用 JToken 解析,它可以处理 Object {} 和 Array []
|
||||
var token = JToken.Parse(content);
|
||||
|
||||
// 格式化输出 (Indented = 缩进模式)
|
||||
return token.ToString(Formatting.Indented);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解析失败(可能只是普通的文本消息,或者 HTML),忽略异常,返回原样
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user