支持通过网页增加、删除、修改摄像头配置信息

支持摄像头配置信息中句柄的设置,并实测有效
This commit is contained in:
2025-12-28 08:07:55 +08:00
parent 3718465463
commit 2ee25a4f7c
25 changed files with 2298 additions and 75 deletions

View File

@@ -0,0 +1,69 @@
namespace SHH.CameraSdk
{
/// <summary>
/// 图像预处理配置模型
/// 用于定义相机采集后的原始帧在分发给订阅者之前的通用处理参数
/// </summary>
public class PreprocessConfig
{
#region --- (Resolution & Scale) ---
/// <summary>
/// 目标输出宽度(单位:像素)
/// 范围约束176 - 1920 px
/// </summary>
public int Width { get; set; }
/// <summary>
/// 目标输出高度(单位:像素)
/// 范围约束44 - 1080 px
/// </summary>
public int Height { get; set; }
/// <summary>
/// 等比缩放倍率(基于原始分辨率的系数)
/// 例如0.5 表示缩小一半1.2 表示放大 20%
/// </summary>
public double Scale { get; set; } = 1.0;
/// <summary>
/// 是否锁定等比缩放
/// true: 修改宽度时高度按比例自动调整false: 允许拉伸或压缩变形
/// </summary>
public bool IsAspectRatio { get; set; } = true;
#endregion
#region --- (Business Constraints) ---
/// <summary>
/// 是否允许缩小图像
/// 默认为 true。若为 false则 Target 尺寸不得低于原始分辨率
/// </summary>
public bool AllowShrink { get; set; } = true;
/// <summary>
/// 是否启用放大功能
/// 默认为 false。若未开启当目标尺寸大于原始分辨率时将强制回退到原始尺寸
/// </summary>
public bool AllowEnlarge { get; set; } = false;
#endregion
#region --- (Image Enhancement) ---
/// <summary>
/// 是否开启图像增量(亮度/对比度补偿)
/// 只有此项为 true 时Brightness 增益参数才会生效
/// </summary>
public bool EnableGain { get; set; } = false;
/// <summary>
/// 图像增量百分比Gain/Gamma 调节)
/// 范围0 - 100%。用于在暗光环境下提升画面可见度
/// </summary>
public int Brightness { get; set; } = 0;
#endregion
}
}

View File

@@ -2,28 +2,36 @@
{ {
public class ProcessingOptions public class ProcessingOptions
{ {
// --- 缩放控制 --- // ==========================================
// 1. 尺寸控制参数
/// <summary> 是否允许缩小 (默认 True: 节约性能与带宽) </summary> // ==========================================
public bool EnableShrink { get; set; } = true;
/// <summary> 是否允许放大 (默认 False: 防止性能浪费与失真) </summary>
public bool EnableExpand { get; set; } = false;
/// <summary> 目标宽度 </summary> /// <summary> 目标宽度 </summary>
public int TargetWidth { get; set; } = 640; public int TargetWidth { get; set; } = 1280;
/// <summary> 目标高度 </summary> /// <summary> 目标高度 </summary>
public int TargetHeight { get; set; } = 360; public int TargetHeight { get; set; } = 720;
// --- 增亮控制 --- /// <summary> 仅允许缩小 (如果原图比目标大,则缩放;否则不处理) </summary>
public bool EnableShrink { get; set; } = true;
/// <summary> 是否启用图像增强 </summary> /// <summary> 仅允许放大 (如果原图比目标小,则缩放;否则不处理) </summary>
public bool EnableEnhance { get; set; } = false; // 默认关闭,按需开启 public bool EnableExpand { get; set; } = false;
/// <summary> 增亮强度 (0-100, 默认30) </summary>
public double BrightnessLevel { get; set; } = 30.0;
// ==========================================
// 2. 画质增强参数
// ==========================================
/// <summary> 是否启用图像增亮 </summary>
public bool EnableBrightness { get; set; } = false;
/// <summary> 增亮百分比 (建议范围 0-100对应增加的像素亮度值) </summary>
public int Brightness { get; set; } = 0;
// 默认实例
[JsonIgnore]
public static ProcessingOptions Default => new ProcessingOptions(); public static ProcessingOptions Default => new ProcessingOptions();
} }
} }

View File

@@ -35,8 +35,7 @@ public class VideoSourceConfig
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
/// <summary> 渲染句柄(可选):用于硬解码时直接绑定显示窗口,提升渲染性能 </summary> /// <summary> 渲染句柄(可选):用于硬解码时直接绑定显示窗口,提升渲染性能 </summary>
[JsonIgnore] public long RenderHandle { get; set; }
public IntPtr RenderHandle { get; set; } = IntPtr.Zero;
/// <summary> 物理通道号IPC 通常为 1NVR 对应接入的摄像头通道索引) </summary> /// <summary> 物理通道号IPC 通常为 1NVR 对应接入的摄像头通道索引) </summary>
public int ChannelIndex { get; set; } = 1; public int ChannelIndex { get; set; } = 1;
@@ -44,6 +43,13 @@ public class VideoSourceConfig
/// <summary> 默认码流类型0 = 主码流(高清)1 = 子码流(低带宽) </summary> /// <summary> 默认码流类型0 = 主码流(高清)1 = 子码流(低带宽) </summary>
public int StreamType { get; set; } = 0; public int StreamType { get; set; } = 0;
/// <summary> 关联的主板IP </summary>
public string MainboardIp { get; set; }
= string.Empty;
/// <summary>关联的主板端口</summary>
public int MainboardPort { get; set; }
/// <summary> Rtsp 播放路径 </summary> /// <summary> Rtsp 播放路径 </summary>
public string RtspPath { get; set; } = string.Empty; public string RtspPath { get; set; } = string.Empty;
@@ -136,6 +142,8 @@ public class VideoSourceConfig
Password = this.Password, Password = this.Password,
RenderHandle = this.RenderHandle, RenderHandle = this.RenderHandle,
ChannelIndex = this.ChannelIndex, ChannelIndex = this.ChannelIndex,
MainboardIp = this.MainboardIp,
MainboardPort = this.MainboardPort,
RtspPath = this.RtspPath, RtspPath = this.RtspPath,
StreamType = this.StreamType, StreamType = this.StreamType,
Transport = this.Transport, Transport = this.Transport,

View File

@@ -8,11 +8,15 @@ public class CamerasController : ControllerBase
{ {
private readonly CameraManager _manager; private readonly CameraManager _manager;
// 1. 新增:我们需要配置管理器
private readonly ProcessingConfigManager _configManager;
// 构造函数注入管理器 // 构造函数注入管理器
public CamerasController(CameraManager manager, DisplayWindowManager displayManager) public CamerasController(CameraManager manager, DisplayWindowManager displayManager, ProcessingConfigManager configManager)
{ {
_manager = manager; _manager = manager;
_displayManager = displayManager; _displayManager = displayManager;
_configManager = configManager;
} }
// ========================================================================== // ==========================================================================
@@ -191,7 +195,10 @@ public class CamerasController : ControllerBase
Password = dto.Password, Password = dto.Password,
ChannelIndex = dto.ChannelIndex, ChannelIndex = dto.ChannelIndex,
StreamType = dto.StreamType, StreamType = dto.StreamType,
RtspPath = dto.RtspPath RtspPath = dto.RtspPath,
MainboardPort = dto.MainboardPort,
MainboardIp = dto.MainboardIp,
RenderHandle =dto.RenderHandle,
}; };
} }
@@ -212,8 +219,7 @@ public class CamerasController : ControllerBase
ChannelIndex = dto.ChannelIndex, ChannelIndex = dto.ChannelIndex,
Brand = dto.Brand, Brand = dto.Brand,
RtspPath = dto.RtspPath, RtspPath = dto.RtspPath,
MainboardIp = dto.MainboardIp,
MainboardPort = dto.MainboardPort,
// ========================================== // ==========================================
// 2. 热更新参数 (运行时属性) // 2. 热更新参数 (运行时属性)
@@ -222,6 +228,9 @@ public class CamerasController : ControllerBase
Location = dto.Location, Location = dto.Location,
StreamType = dto.StreamType, StreamType = dto.StreamType,
MainboardIp = dto.MainboardIp,
MainboardPort = dto.MainboardPort,
RenderHandle = dto.RenderHandle,
// 注意:通常句柄是通过 bind-handle 接口单独绑定的, // 注意:通常句柄是通过 bind-handle 接口单独绑定的,
// 但如果 ConfigDto 里包含了上次保存的句柄,也可以映射 // 但如果 ConfigDto 里包含了上次保存的句柄,也可以映射
// RenderHandle = dto.RenderHandle, // RenderHandle = dto.RenderHandle,
@@ -281,7 +290,7 @@ public class CamerasController : ControllerBase
} }
// 5. 将需求注册到流控控制器 // 5. 将需求注册到流控控制器
controller.Register(dto.AppId, dto.DisplayFps); controller.Register(new FrameRequirement(dto));
// 6. 路由显示逻辑 (核心整合点) // 6. 路由显示逻辑 (核心整合点)
if (dto.Type == SubscriptionType.LocalWindow) if (dto.Type == SubscriptionType.LocalWindow)
@@ -333,4 +342,61 @@ public class CamerasController : ControllerBase
// var bytes = await cam.CaptureCurrentFrameAsync(); // var bytes = await cam.CaptureCurrentFrameAsync();
// return File(bytes, "image/jpeg"); // return File(bytes, "image/jpeg");
//} //}
// =============================================================
// 3. 新增:更新图像处理/分辨率参数的接口
// URL 示例: POST /api/cameras/1001/processing
// =============================================================
[HttpPost("{id}/processing")]
public IActionResult UpdateProcessingOptions(long id, [FromBody] ProcessingOptions options)
{
// A. 检查相机是否存在
var camera = _manager.GetDevice(id);
if (camera == null)
{
return NotFound(new { error = $"Camera {id} not found." });
}
// B. 参数校验 (防止宽高为0导致报错)
if (options.TargetWidth <= 0 || options.TargetHeight <= 0)
{
return BadRequest(new { error = "Target dimensions must be greater than 0." });
}
// C. 执行更新 (热更,立即生效)
// ScaleWorker 下一帧处理时会自动读取这个新配置
_configManager.UpdateOptions(id, options);
return Ok(new
{
success = true,
message = "Image processing options updated.",
currentConfig = options
});
}
// 在 CamerasController 类中添加
// =============================================================
// 新增:获取/回显图像处理参数的接口
// URL: GET /api/cameras/{id}/processing
// =============================================================
[HttpGet("{id}/processing")]
public IActionResult GetProcessingOptions(long id)
{
// 1. 检查相机是否存在
var camera = _manager.GetDevice(id);
if (camera == null)
{
return NotFound(new { error = $"Camera {id} not found." });
}
// 2. 从配置管理器中获取当前配置
// 注意ProcessingConfigManager 内部应该处理好逻辑:
// 如果该设备还没配过,它会自动返回 new ProcessingOptions() (默认值)
var options = _configManager.GetOptions(id);
// 3. 返回 JSON 给前端
return Ok(options);
}
} }

View File

@@ -61,6 +61,8 @@ public class CameraConfigDto
[MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")] [MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")]
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public long RenderHandle { get; set; }
/// <summary> /// <summary>
/// 通道号 (通常为1) /// 通道号 (通常为1)
/// </summary> /// </summary>

View File

@@ -41,16 +41,18 @@ public class DeviceUpdateDto
/// <summary>RTSP流地址 (非SDK模式下使用)</summary> /// <summary>RTSP流地址 (非SDK模式下使用)</summary>
[MaxLength(256, ErrorMessage = "RTSP地址长度不能超过 256 个字符")] [MaxLength(256, ErrorMessage = "RTSP地址长度不能超过 256 个字符")]
public string? RtspPath { get; set; } public string RtspPath { get; set; }
= string.Empty;
/// <summary>关联的主板IP (用于联动控制)</summary> /// <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?)?$", [RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$",
ErrorMessage = "IPv4地址")] ErrorMessage = "IPv4地址")]
public string? MainboardIp { get; set; } public string MainboardIp { get; set; }
= string.Empty;
/// <summary>关联的主板端口</summary> /// <summary>关联的主板端口</summary>
[Range(1, 65535, ErrorMessage = "主板端口号必须在 1-65535 范围内")] [Range(1, 65535, ErrorMessage = "主板端口号必须在 1-65535 范围内")]
public int? MainboardPort { get; set; } public int MainboardPort { get; set; }
// ============================================================================== // ==============================================================================
// 2. 热更新参数 (Hot Update) // 2. 热更新参数 (Hot Update)
@@ -71,7 +73,7 @@ public class DeviceUpdateDto
/// <summary>渲染句柄 (IntPtr 的 Long 形式)</summary> /// <summary>渲染句柄 (IntPtr 的 Long 形式)</summary>
[Range(0, long.MaxValue, ErrorMessage = "渲染句柄必须是非负整数")] [Range(0, long.MaxValue, ErrorMessage = "渲染句柄必须是非负整数")]
public long? RenderHandle { get; set; } public long RenderHandle { get; set; }
// ============================================================================== // ==============================================================================
// 3. 图像处理参数 (Image Processing - Hot Update) // 3. 图像处理参数 (Image Processing - Hot Update)

View File

@@ -1,10 +1,4 @@
using System; namespace SHH.CameraSdk
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SHH.CameraSdk
{ {
public class UpdateProcessingRequest public class UpdateProcessingRequest
{ {
@@ -13,7 +7,7 @@ namespace SHH.CameraSdk
public bool EnableExpand { get; set; } public bool EnableExpand { get; set; }
public int TargetWidth { get; set; } public int TargetWidth { get; set; }
public int TargetHeight { get; set; } public int TargetHeight { get; set; }
public bool EnableEnhance { get; set; } public bool EnableBrightness { get; set; }
public double BrightnessLevel { get; set; } public int Brightness { get; set; }
} }
} }

View File

@@ -43,6 +43,8 @@ public class MonitorController : ControllerBase
Status = c.Status.ToString(), Status = c.Status.ToString(),
c.IsPhysicalOnline, c.IsPhysicalOnline,
c.RealFps, c.RealFps,
c.Width,
c.Height,
c.TotalFrames, c.TotalFrames,
c.Config.Name, c.Config.Name,
c.Config.IpAddress, c.Config.IpAddress,
@@ -69,23 +71,34 @@ public class MonitorController : ControllerBase
[HttpGet("{id}")] [HttpGet("{id}")]
public IActionResult GetDeviceDetail(long id) public IActionResult GetDeviceDetail(long id)
{ {
var device = _cameraManager.GetDevice(id); var d = _cameraManager.GetDevice(id);
if (device == null) return NotFound($"设备 ID: {id} 不存在"); if (d == null) return NotFound($"设备 ID: {id} 不存在");
return Ok(new return Ok(new
{ {
device.Id, d.Id,
Status = device.Status.ToString(), Status = d.Status.ToString(),
device.IsOnline, d.IsOnline,
device.RealFps, d.IsPhysicalOnline,
device.TotalFrames, d.RealFps,
device.Config.Name, d.Width,
device.Config.IpAddress, d.Height,
d.TotalFrames,
d.Config.Name,
d.Config.IpAddress,
// --- 新增:将内存中的订阅需求列表传给前端 --- // --- 新增:将内存中的订阅需求列表传给前端 ---
Requirements = device.Controller.GetCurrentRequirements().Select(r => new { Requirements = d.Controller.GetCurrentRequirements().Select(r => new {
r.AppId, r.AppId,
r.TargetFps, r.TargetFps,
r.LastActive r.LastActive,
r.RealFps,
r.Memo,
r.SavePath,
r.Handle,
r.TargetIp,
r.TargetPort,
r.Protocol,
r.Type,
}) })
}); });
} }
@@ -171,8 +184,8 @@ public class MonitorController : ControllerBase
EnableExpand = request.EnableExpand, EnableExpand = request.EnableExpand,
TargetWidth = request.TargetWidth, TargetWidth = request.TargetWidth,
TargetHeight = request.TargetHeight, TargetHeight = request.TargetHeight,
EnableEnhance = request.EnableEnhance, EnableBrightness = request.EnableBrightness,
BrightnessLevel = request.BrightnessLevel Brightness = request.Brightness
}; };
// 3. 提交给配置管理器 (实时生效) // 3. 提交给配置管理器 (实时生效)

View File

@@ -267,9 +267,13 @@ public class CameraManager : IDisposable, IAsyncDisposable
if (dto.ChannelIndex != null) newConfig.ChannelIndex = dto.ChannelIndex.Value; if (dto.ChannelIndex != null) newConfig.ChannelIndex = dto.ChannelIndex.Value;
if (dto.StreamType != null) newConfig.StreamType = dto.StreamType.Value; if (dto.StreamType != null) newConfig.StreamType = dto.StreamType.Value;
if (dto.Name != null) newConfig.Name = dto.Name; if (dto.Name != null) newConfig.Name = dto.Name;
if (dto.RenderHandle != null) newConfig.RenderHandle = (IntPtr)dto.RenderHandle.Value;
if (dto.Brand != null) newConfig.Brand = (DeviceBrand)dto.Brand; if (dto.Brand != null) newConfig.Brand = (DeviceBrand)dto.Brand;
newConfig.RtspPath = dto.RtspPath;
newConfig.MainboardIp = dto.MainboardIp;
newConfig.MainboardPort = dto.MainboardPort;
newConfig.RenderHandle = dto.RenderHandle;
// 4. 判定冷热更新 // 4. 判定冷热更新
bool needColdRestart = bool needColdRestart =
newConfig.IpAddress != oldConfig.IpAddress || newConfig.IpAddress != oldConfig.IpAddress ||
@@ -306,7 +310,7 @@ public class CameraManager : IDisposable, IAsyncDisposable
var options = new DynamicStreamOptions var options = new DynamicStreamOptions
{ {
StreamType = dto.StreamType, StreamType = dto.StreamType,
RenderHandle = dto.RenderHandle.HasValue ? (IntPtr)dto.RenderHandle : null RenderHandle = (IntPtr)dto.RenderHandle
}; };
device.ApplyOptions(options); device.ApplyOptions(options);
} }

View File

@@ -34,6 +34,30 @@ public class FrameController
_accumulators.TryRemove(appId, out _); _accumulators.TryRemove(appId, out _);
} }
// 修改 Register 方法,接收整个 Requirement 对象或多个参数
public void Register(FrameRequirement req)
{
_requirements.AddOrUpdate(req.AppId,
_ => req, // 如果不存在,直接添加整个对象
(_, old) =>
{
// 如果已存在,更新关键业务字段,同时保留统计状态
old.TargetFps = req.TargetFps;
old.Memo = req.Memo;
old.Handle = req.Handle;
old.Type = req.Type;
old.SavePath = req.SavePath;
// 注意:不要覆盖 old.RealFps保留之前的统计值
return old;
});
// 如果是降频(<=20确保积分器存在
if (req.TargetFps <= 20)
{
_accumulators.GetOrAdd(req.AppId, 0);
}
}
public void Unregister(string appId) public void Unregister(string appId)
{ {
if (string.IsNullOrWhiteSpace(appId)) return; if (string.IsNullOrWhiteSpace(appId)) return;
@@ -100,6 +124,9 @@ public class FrameController
// 扣除成本,保留余数 (余数是精度的关键) // 扣除成本,保留余数 (余数是精度的关键)
acc -= LOGICAL_BASE_FPS; acc -= LOGICAL_BASE_FPS;
// 【核心修复】在此处触发统计RealFps 才会开始跳动
req.UpdateRealFps();
req.LastCaptureTick = currentTick; req.LastCaptureTick = currentTick;
} }
@@ -120,6 +147,6 @@ public class FrameController
// --------------------------------------------------------- // ---------------------------------------------------------
public List<dynamic> GetCurrentRequirements() public List<dynamic> GetCurrentRequirements()
{ {
return _requirements.Values.Select(r => new { r.AppId, r.TargetFps, LastActive = r.LastCaptureTick }).ToList<dynamic>(); return _requirements.Values.Select(r => new { r.AppId, r.TargetFps, r.RealFps, LastActive = r.LastCaptureTick, r.Memo, r.SavePath, r.Handle, r.TargetIp, r.TargetPort, r.Protocol, r.Type }).ToList<dynamic>();
} }
} }

View File

@@ -7,6 +7,36 @@
/// </summary> /// </summary>
public class FrameRequirement public class FrameRequirement
{ {
public FrameRequirement()
{
}
public FrameRequirement(SubscriptionDto dto)
{
// 1. 核心标识
this.AppId = dto.AppId;
this.Type = dto.Type;
// 2. 流控参数
this.TargetFps = dto.DisplayFps;
// 3. 业务动态参数
this.Memo = dto.Memo;
this.Handle = dto.Handle;
this.RecordDuration = dto.RecordDuration;
this.SavePath = dto.SavePath;
// 4. 网络转发相关参数 (补全)
this.Protocol = dto.Protocol;
this.TargetIp = dto.TargetIp;
this.TargetPort = dto.TargetPort;
// 5. 初始化内部统计状态
this.LastCaptureTick = Environment.TickCount64;
this._lastFpsCalcTick = Environment.TickCount64;
this.IsActive = true;
}
#region --- (Subscriber Core Identification) --- #region --- (Subscriber Core Identification) ---
/// <summary> 订阅者唯一ID如 "Client_A"、"AI_Service"、"WPF_Display_Main" </summary> /// <summary> 订阅者唯一ID如 "Client_A"、"AI_Service"、"WPF_Display_Main" </summary>
@@ -81,16 +111,20 @@ public class FrameRequirement
/// <remarks> 每当成功分发一帧后调用,内部自动按秒计算 RealFps </remarks> /// <remarks> 每当成功分发一帧后调用,内部自动按秒计算 RealFps </remarks>
public void UpdateRealFps() public void UpdateRealFps()
{ {
_frameCount++; try
long currentTick = Environment.TickCount64;
long elapsed = currentTick - _lastFpsCalcTick;
if (elapsed >= 1000) // 达到 1 秒周期
{ {
RealFps = Math.Round(_frameCount / (elapsed / 1000.0), 1); _frameCount++;
_frameCount = 0; long currentTick = Environment.TickCount64;
_lastFpsCalcTick = currentTick; long elapsed = currentTick - _lastFpsCalcTick;
if (elapsed >= 1000) // 达到 1 秒周期
{
RealFps = Math.Round(_frameCount / (elapsed / 1000.0), 1);
_frameCount = 0;
_lastFpsCalcTick = currentTick;
}
} }
catch{ }
} }
#endregion #endregion

View File

@@ -28,7 +28,7 @@ public class EnhanceWorker : BaseWorker
var options = _configManager.GetOptions(deviceId); var options = _configManager.GetOptions(deviceId);
// 2. 检查开关:如果没开启增强,直接跳过 // 2. 检查开关:如果没开启增强,直接跳过
if (!options.EnableEnhance) return; if (!options.EnableBrightness) return;
// 3. 确定操作对象 // 3. 确定操作对象
// 策略:如果上一站生成了 TargetMat (缩放图),我们处理缩放图; // 策略:如果上一站生成了 TargetMat (缩放图),我们处理缩放图;
@@ -36,22 +36,16 @@ public class EnhanceWorker : BaseWorker
// 通常 UI 预览场景下,如果不缩放,直接处理 4K 原图会非常卡。 // 通常 UI 预览场景下,如果不缩放,直接处理 4K 原图会非常卡。
// 建议:仅当 TargetMat 存在时处理,或者强制 clone 一份原图作为 TargetMat // 建议:仅当 TargetMat 存在时处理,或者强制 clone 一份原图作为 TargetMat
Mat srcMat = frame.TargetMat; Mat srcMat;
bool createdNew = false; if (frame.TargetMat != null)
srcMat = frame.TargetMat;
// 如果没有 TargetMat (上一站透传了),但开启了增亮 else
// 我们必须基于原图生成一个 TargetMat否则下游 UI 拿不到处理结果
if (srcMat == null || srcMat.IsDisposed)
{
// 注意:处理 4K 原图非常耗时,生产环境建议这里做个限制
srcMat = frame.InternalMat; srcMat = frame.InternalMat;
createdNew = true; // 标记我们需要 Attach 新的
}
// 4. 执行增亮 // 4. 执行增亮
Mat brightMat = new Mat(); Mat brightMat = new Mat();
// Alpha=1.0, Beta=配置值 // Alpha=1.0, Beta=配置值
srcMat.ConvertTo(brightMat, -1, 1.0, options.BrightnessLevel); srcMat.ConvertTo(brightMat, -1, 1.0, options.Brightness);
// 5. 挂载结果 // 5. 挂载结果
// 这会自动释放上一站生成的旧 TargetMat (如果存在) // 这会自动释放上一站生成的旧 TargetMat (如果存在)

View File

@@ -31,9 +31,12 @@ namespace SHH.CameraSdk
// 1. 获取实时配置 (极快,内存读取) // 1. 获取实时配置 (极快,内存读取)
var options = _configManager.GetOptions(deviceId); var options = _configManager.GetOptions(deviceId);
Mat sourceMat = frame.InternalMat;
if (sourceMat.Empty()) return;
// 2. 原始尺寸 // 2. 原始尺寸
int srcW = frame.InternalMat.Width; int srcW = sourceMat.Width;
int srcH = frame.InternalMat.Height; int srcH = sourceMat.Height;
// 3. 目标尺寸 // 3. 目标尺寸
int targetW = options.TargetWidth; int targetW = options.TargetWidth;

View File

@@ -32,6 +32,6 @@ public class ProcessingConfigManager
Console.WriteLine($"[ConfigManager] 设备 {deviceId} 预处理参数已更新: " + Console.WriteLine($"[ConfigManager] 设备 {deviceId} 预处理参数已更新: " +
$"Expand={newOptions.EnableExpand} Shrink:{newOptions.EnableShrink} 分辨率:({newOptions.TargetWidth}x{newOptions.TargetHeight}), " + $"Expand={newOptions.EnableExpand} Shrink:{newOptions.EnableShrink} 分辨率:({newOptions.TargetWidth}x{newOptions.TargetHeight}), " +
$"Enhance={newOptions.EnableEnhance}"); $"EnableBrightness}}={newOptions.EnableBrightness}");
} }
} }

View File

@@ -18,6 +18,13 @@ public abstract class BaseVideoSource : IVideoSource, IAsyncDisposable, IDeviceC
private volatile bool _isPhysicalOnline; private volatile bool _isPhysicalOnline;
public bool IsPhysicalOnline => _isPhysicalOnline; public bool IsPhysicalOnline => _isPhysicalOnline;
/// <summary>
/// 图像预处理配置(缩放、增量等)
/// 放置在基类中确保所有接入协议HIK/DH/RTSP均可共享处理逻辑
/// </summary>
public PreprocessConfig PreprocessSettings { get; set; }
= new PreprocessConfig();
string IDeviceConnectivity.IpAddress => _config.IpAddress; string IDeviceConnectivity.IpAddress => _config.IpAddress;
// 允许哨兵从外部更新 _isOnline 字段 // 允许哨兵从外部更新 _isOnline 字段
@@ -240,6 +247,10 @@ public abstract class BaseVideoSource : IVideoSource, IAsyncDisposable, IDeviceC
StreamType = source.StreamType, StreamType = source.StreamType,
Transport = source.Transport, Transport = source.Transport,
ConnectionTimeoutMs = source.ConnectionTimeoutMs, ConnectionTimeoutMs = source.ConnectionTimeoutMs,
MainboardIp = source.MainboardIp,
MainboardPort = source.MainboardPort,
RtspPath = source.RtspPath,
RenderHandle = source.RenderHandle,
// Dictionary 深拷贝:防止外部修改影响内部 // Dictionary 深拷贝:防止外部修改影响内部
VendorArguments = source.VendorArguments != null VendorArguments = source.VendorArguments != null
? new Dictionary<string, string>(source.VendorArguments) ? new Dictionary<string, string>(source.VendorArguments)

View File

@@ -228,7 +228,7 @@ public class HikVideoSource : BaseVideoSource
{ {
var previewInfo = new HikNativeMethods.NET_DVR_PREVIEWINFO var previewInfo = new HikNativeMethods.NET_DVR_PREVIEWINFO
{ {
hPlayWnd = IntPtr.Zero, hPlayWnd = (IntPtr)_config.RenderHandle,
lChannel = _config.ChannelIndex, lChannel = _config.ChannelIndex,
dwStreamType = (uint)_config.StreamType, dwStreamType = (uint)_config.StreamType,
bBlocked = false bBlocked = false

View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>设备控制台</title>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
body { background: #fff; margin: 0; padding: 0; font-family: "Segoe UI", sans-serif; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
.modal-header-custom { padding: 12px 20px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
.content-body { flex: 1; padding: 20px; overflow-y: auto; display: flex; gap: 20px; }
/* 左侧:云台控制区 */
.ptz-section { flex: 0 0 240px; display: flex; flex-direction: column; align-items: center; border-right: 1px solid #eee; padding-right: 20px; }
.d-pad { position: relative; width: 180px; height: 180px; background: #f1f3f5; border-radius: 50%; margin-bottom: 20px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.05); }
.d-btn { position: absolute; width: 50px; height: 50px; border: none; background: #fff; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); color: #495057; transition: 0.1s; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.d-btn:active { background: #e9ecef; transform: scale(0.95); box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); color: #0d6efd; }
.d-btn i { font-size: 1.5rem; }
.btn-up { top: 10px; left: 65px; }
.btn-down { bottom: 10px; left: 65px; }
.btn-left { top: 65px; left: 10px; }
.btn-right { top: 65px; right: 10px; }
.btn-center { top: 65px; left: 65px; border-radius: 50%; background: #e7f1ff; color: #0d6efd; font-weight: bold; font-size: 0.8rem; }
.zoom-ctrl { display: flex; width: 100%; gap: 10px; justify-content: center; }
.zoom-btn { flex: 1; padding: 8px; border: 1px solid #dee2e6; background: #fff; border-radius: 6px; font-size: 0.9rem; font-weight: 600; color: #555; }
.zoom-btn:active { background: #f8f9fa; border-color: #adb5bd; }
/* 右侧:系统维护区 */
.sys-section { flex: 1; display: flex; flex-direction: column; gap: 15px; }
.func-card { border: 1px solid #e9ecef; border-radius: 8px; padding: 15px; background: #fff; transition: 0.2s; }
.func-card:hover { border-color: #dee2e6; box-shadow: 0 2px 8px rgba(0,0,0,0.02); }
.func-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 10px; color: #343a40; display: flex; align-items: center; }
.time-box { background: #f8f9fa; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 1.1rem; letter-spacing: 1px; color: #0d6efd; text-align: center; border: 1px solid #e9ecef; margin-bottom: 10px; }
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="modal-header-custom">
<h6 class="m-0 fw-bold"><i class="bi bi-joystick me-2 text-primary"></i>设备控制台</h6>
<div class="text-muted small">ID: {{ deviceId }}</div>
</div>
<div class="content-body">
<div class="ptz-section">
<h6 class="text-muted small mb-3 fw-bold">云台控制 (PTZ)</h6>
<div class="d-pad">
<button class="d-btn btn-up" @mousedown="ptz('up')" @mouseup="ptzStop"><i class="bi bi-caret-up-fill"></i></button>
<button class="d-btn btn-left" @mousedown="ptz('left')" @mouseup="ptzStop"><i class="bi bi-caret-left-fill"></i></button>
<button class="d-btn btn-center" @click="ptz('home')" title="回原点">HOME</button>
<button class="d-btn btn-right" @mousedown="ptz('right')" @mouseup="ptzStop"><i class="bi bi-caret-right-fill"></i></button>
<button class="d-btn btn-down" @mousedown="ptz('down')" @mouseup="ptzStop"><i class="bi bi-caret-down-fill"></i></button>
</div>
<div class="zoom-ctrl">
<button class="zoom-btn" @mousedown="ptz('zoomIn')" @mouseup="ptzStop"><i class="bi bi-zoom-in me-1"></i>放大</button>
<button class="zoom-btn" @mousedown="ptz('zoomOut')" @mouseup="ptzStop"><i class="bi bi-zoom-out me-1"></i>缩小</button>
</div>
<div class="mt-2 text-muted small" style="font-size: 0.75rem;"><i class="bi bi-info-circle me-1"></i>长按移动,松开停止</div>
</div>
<div class="sys-section">
<div class="func-card">
<div class="func-title"><i class="bi bi-clock-history me-2 text-success"></i>时间同步</div>
<div class="text-muted small mb-2">设备当前时间:</div>
<div class="time-box">{{ deviceTime || '--:--:--' }}</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" @click="getDeviceTime"><i class="bi bi-arrow-clockwise me-1"></i>刷新</button>
<button class="btn btn-sm btn-success flex-fill" @click="syncTime" :disabled="syncing">
<span v-if="syncing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-check2-circle me-1"></i>同步本机
</button>
</div>
</div>
<div class="func-card">
<div class="func-title"><i class="bi bi-tools me-2 text-warning"></i>系统维护</div>
<button class="btn btn-sm btn-light border w-100 text-start" @click="reboot"><i class="bi bi-bootstrap-reboot me-2"></i>重启设备</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, onMounted } = Vue;
let API_BASE = "";
createApp({
setup() {
const deviceId = ref(0);
const deviceTime = ref("");
const syncing = ref(false);
// PTZ控制
const ptz = async (action) => {
log(`PTZ: ${action}`);
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/ptz?action=${action}&speed=5`); } catch(e) {}
};
const ptzStop = async () => {
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/ptz?action=stop`); } catch(e) {}
};
// 校时逻辑
const getDeviceTime = async () => {
const now = new Date(); now.setMinutes(now.getMinutes() - 5); // 模拟
deviceTime.value = now.toLocaleTimeString();
};
const syncTime = async () => {
syncing.value = true;
try {
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/sync-time`, { time: new Date().toISOString() });
alert("指令已下发");
deviceTime.value = new Date().toLocaleTimeString();
} catch(e) { alert("失败: " + e.message); }
finally { syncing.value = false; }
};
const reboot = async () => {
if(confirm("确定要重启设备吗?")) {
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/reboot`); alert("重启中..."); } catch(e){}
}
};
const log = (msg) => window.parent.postMessage({ type: 'API_LOG', log: { method: 'PTZ', url: msg, status: 200, msg: 'Sent' } }, '*');
onMounted(() => {
window.addEventListener('message', (e) => {
if (e.data.type === 'LOAD_CTRL_DATA') {
if(e.data.apiBase) API_BASE = e.data.apiBase;
deviceId.value = e.data.deviceId;
getDeviceTime();
}
});
});
return { deviceId, deviceTime, syncing, ptz, ptzStop, getDeviceTime, syncTime, reboot };
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>设备配置</title>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
html, body {
height: 100%; margin: 0; padding: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
overflow: hidden; background: #fff;
}
#app { height: 100%; display: flex; flex-direction: column; }
/* 头部 */
.page-header {
padding: 15px 30px; border-bottom: 1px solid #e9ecef; background: #fff;
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
}
.page-title { font-size: 1.2rem; font-weight: 700; color: #343a40; display: flex; align-items: center; }
/* 内容区 */
.content-body {
flex: 1; overflow-y: auto; padding: 30px 40px;
max-width: 1200px; margin: 0 auto; width: 100%;
}
.content-body::-webkit-scrollbar { width: 8px; }
.content-body::-webkit-scrollbar-track { background: #f8f9fa; }
.content-body::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 4px; }
/* 表单控件 */
.form-label { font-weight: 600; color: #495057; margin-bottom: 6px; font-size: 0.85rem; }
.form-control, .form-select { padding: 9px 12px; border-radius: 4px; border-color: #dee2e6; font-size: 0.9rem; }
.form-control:focus { border-color: #86b7fe; box-shadow: 0 0 0 3px rgba(13,110,253,0.1); }
.form-control[readonly] { background-color: #f8f9fa; color: #6c757d; cursor: not-allowed; }
/* 亮色标题 */
.section-header {
display: flex; align-items: center; margin-top: 25px; margin-bottom: 15px;
padding-bottom: 8px; border-bottom: 2px solid #e7f1ff;
}
.section-header h6 { margin: 0; font-weight: 800; color: #0d6efd; font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.5px; }
/* 板卡区域 */
.board-card {
background-color: #fcfcfc; border: 1px solid #e9ecef;
border-left: 4px solid #0d6efd; border-radius: 6px;
padding: 15px 20px; margin-top: 10px;
}
/* 底部按钮栏 - 修改为两端对齐 */
.footer-bar {
padding: 15px 40px; border-top: 1px solid #e9ecef; background: #fff;
display: flex; justify-content: space-between; align-items: center;
flex-shrink: 0;
}
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="page-header">
<div class="page-title">
<i class="bi me-2 text-primary" :class="mode === 'add' ? 'bi-plus-circle-fill' : 'bi-pencil-square'"></i>
{{ pageTitle }}
</div>
<button class="btn btn-light border" @click="closeMode" title="关闭"><i class="bi bi-x-lg"></i></button>
</div>
<div class="content-body">
<div v-if="loadingData" class="text-center py-5 text-muted">
<span class="spinner-border text-primary"></span>
<p class="mt-3">正在读取配置...</p>
</div>
<form v-else class="row g-3">
<div class="col-12">
<div class="section-header" style="margin-top:0;"><h6><i class="bi bi-person-badge me-2"></i>基础信息</h6></div>
</div>
<div class="col-md-6">
<label class="form-label">设备名称 <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="form.name" placeholder="例如:北门入口监控" required>
</div>
<div class="col-md-3">
<label class="form-label">品牌/协议</label>
<select class="form-select" v-model.number="form.brand" @change="onBrandChange">
<option value="1">海康威视 (Hikvision)</option>
<option value="2">大华 (Dahua)</option>
<option value="4">标准 RTSP 流</option>
<option value="7">ONVIF</option>
<option value="3">USB 摄像头</option>
<option value="0">未知</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">默认码流</label>
<select class="form-select" v-model.number="form.streamType">
<option value="0">主码流 (Main)</option>
<option value="1">子码流 (Sub)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">设备 ID <span class="text-danger">*</span></label>
<input type="number" class="form-control fw-bold font-monospace"
v-model.number="form.id"
:readonly="mode === 'edit'"
min="1" placeholder="正整数">
</div>
<div class="col-md-9">
<label class="form-label">安装位置</label>
<input type="text" class="form-control" v-model="form.location" placeholder="例如:一号厂房东门">
</div>
<div class="col-12">
<div class="section-header"><h6><i class="bi bi-hdd-network me-2"></i>网络与连接 (Network)</h6></div>
</div>
<div class="col-12 row g-3 m-0 p-0" v-if="form.brand !== 4">
<div class="col-md-5">
<label class="form-label">IP 地址 <span class="text-danger">*</span></label>
<input type="text" class="form-control font-monospace" v-model="form.ipAddress" @input="tryAutoGenerateRtsp">
</div>
<div class="col-md-2">
<label class="form-label">端口</label>
<input type="number" class="form-control font-monospace" v-model.number="form.port">
</div>
<div class="col-md-3">
<label class="form-label">传输协议</label>
<select class="form-select font-monospace" v-model="form.transport">
<option value="Tcp">TCP (推荐)</option>
<option value="Udp">UDP</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">超时 (ms)</label>
<input type="number" class="form-control font-monospace" v-model.number="form.connectionTimeoutMs" step="1000">
</div>
<div class="col-md-3">
<label class="form-label">登录用户名</label>
<input type="text" class="form-control" v-model="form.username" placeholder="admin" @input="tryAutoGenerateRtsp">
</div>
<div class="col-md-3">
<label class="form-label">登录密码</label>
<input type="password" class="form-control" v-model="form.password" placeholder="••••••" @input="tryAutoGenerateRtsp">
</div>
<div class="col-md-3">
<label class="form-label">通道号</label>
<input type="number" class="form-control" v-model.number="form.channelIndex" min="1" @input="tryAutoGenerateRtsp">
</div>
<div class="col-md-3">
<label class="form-label">渲染句柄</label>
<input type="number" class="form-control font-monospace text-muted"
v-model.number="form.renderHandle"
:disabled="!isSdkBrand"
placeholder="0 (自动)">
</div>
<div class="col-12" v-if="isSdkBrand">
<div class="board-card">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="enableBoard" v-model="enableBoard">
<label class="form-check-label fw-bold text-dark" for="enableBoard">
关联主板 (Mainboard Linkage)
</label>
</div>
<div class="row g-3" v-if="enableBoard">
<div class="col-md-8">
<label class="form-label text-muted small">板卡 IP 地址</label>
<input type="text" class="form-control form-control-sm font-monospace"
v-model="form.mainboardIp" placeholder="例如192.168.1.200">
</div>
<div class="col-md-4">
<label class="form-label text-muted small">板卡端口</label>
<input type="number" class="form-control form-control-sm font-monospace"
v-model.number="form.mainboardPort" placeholder="80">
</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="section-header mt-4">
<h6><i class="bi bi-broadcast me-2"></i>RTSP 流配置</h6>
</div>
<div class="mb-2">
<label class="form-label">RTSP 地址 (RtspPath)</label>
<div class="input-group">
<span class="input-group-text bg-light font-monospace small">URL</span>
<input type="text" class="form-control font-monospace text-primary" v-model="form.rtspPath"
placeholder="rtsp://admin:123456@192.168.1.64:554/...">
<button class="btn btn-outline-secondary" type="button"
v-if="isSdkBrand" @click="forceGenerateRtsp"
title="强制重新生成">
<i class="bi bi-magic"></i> 自动生成
</button>
</div>
<div class="form-text text-muted small mt-1" v-if="form.brand !== 4">
<i class="bi bi-info-circle me-1"></i>SDK 模式下此为备用地址。如果字段为空,系统将自动生成标准路径。
</div>
</div>
</div>
<div style="height: 40px;"></div>
</form>
</div>
<div class="footer-bar">
<div>
<button v-if="mode === 'edit'" class="btn btn-outline-danger" @click="removeDevice" :disabled="submitting">
<i class="bi bi-trash3 me-1"></i> 删除设备
</button>
</div>
<div class="d-flex gap-2">
<button class="btn btn-light border px-4" @click="closeMode">取消</button>
<button class="btn btn-primary px-4" @click="save" :disabled="submitting">
<span v-if="submitting" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi me-1" :class="mode === 'add' ? 'bi-check-lg' : 'bi-floppy2-fill'"></i>
{{ mode === 'add' ? '确认添加' : '保存更改' }}
</button>
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
let API_BASE = "";
const BrandMap = {
'HikVision': 1, 'Hikvision': 1, 'Dahua': 2, 'Usb': 3,
'RtspGeneral': 4, 'Rtsp': 4, 'OnvifGeneral': 7, 'Onvif': 7, 'Unknown': 0
};
createApp({
setup() {
const mode = ref('add'); // add | edit
const deviceId = ref(0);
const loadingData = ref(false);
const submitting = ref(false);
const enableBoard = ref(false);
const form = reactive({
id: null, name: '', brand: 1, location: '',
ipAddress: '', port: 8000, username: '', password: '',
channelIndex: 1, rtspPath: '', streamType: 0,
renderHandle: 0, transport: 'Tcp', connectionTimeoutMs: 5000,
mainboardIp: '', mainboardPort: 80
});
const pageTitle = computed(() => mode.value === 'add' ? '添加新设备' : '编辑设备配置');
const isSdkBrand = computed(() => form.brand === 1 || form.brand === 2);
const logToParent = (method, url, status, msg) => window.parent.postMessage({ type: 'API_LOG', log: { method, url, status, msg } }, '*');
const generateRtspUrl = () => {
if (!isSdkBrand.value) return "";
const ip = form.ipAddress || "0.0.0.0";
const user = form.username || "admin";
const pass = form.password || "123456";
const ch = form.channelIndex || 1;
if (form.brand === 1) {
const stream = form.streamType === 0 ? "01" : "02";
return `rtsp://${user}:${pass}@${ip}:554/Streaming/Channels/${ch}${stream}`;
} else if (form.brand === 2) {
const subtype = form.streamType;
return `rtsp://${user}:${pass}@${ip}:554/cam/realmonitor?channel=${ch}&subtype=${subtype}`;
}
return "";
};
const tryAutoGenerateRtsp = () => {
if (isSdkBrand.value && !form.rtspPath) form.rtspPath = generateRtspUrl();
};
const forceGenerateRtsp = () => { form.rtspPath = generateRtspUrl(); };
const onBrandChange = () => {
if (!isSdkBrand.value) enableBoard.value = false;
if (form.brand === 1) form.port = 8000;
else if (form.brand === 2) form.port = 37777;
else if (form.brand === 7) form.port = 80;
};
const initAdd = () => {
mode.value = 'add';
loadingData.value = false;
Object.assign(form, {
id: Math.floor(Math.random() * 9000) + 1000,
name: "NewCamera", brand: 1, location: '',
ipAddress: '192.168.1.64', port: 8000,
username: 'admin', password: '',
channelIndex: 1, rtspPath: '', streamType: 0,
renderHandle: 0, transport: 'Tcp', connectionTimeoutMs: 5000,
mainboardIp: '', mainboardPort: 80
});
enableBoard.value = false;
};
const loadData = async (id) => {
mode.value = 'edit';
deviceId.value = id;
loadingData.value = true;
const url = `${API_BASE}/api/Cameras/${id}`;
try {
logToParent('GET', url, 'PENDING', 'Reading config...');
const res = await axios.get(url);
if (res.data) {
const d = res.data;
Object.assign(form, {
id: d.id, name: d.name || '',
brand: (typeof d.brand === 'string') ? (BrandMap[d.brand] || 0) : d.brand,
location: d.location || '', ipAddress: d.ipAddress, port: d.port,
username: d.username || d.account || '', password: d.password || '',
channelIndex: d.channelIndex || 1, rtspPath: d.rtspPath || d.rtspUrl || '',
streamType: d.streamType, renderHandle: d.renderHandle || 0,
transport: d.transport || 'Tcp', connectionTimeoutMs: d.connectionTimeoutMs || 5000,
mainboardIp: d.mainboardIp || '', mainboardPort: d.mainboardPort || 80
});
enableBoard.value = !!form.mainboardIp;
logToParent('GET', url, 200, 'OK');
}
} catch (e) { alert("加载失败: " + e.message); }
finally { loadingData.value = false; }
};
const save = async () => {
submitting.value = true;
if (form.id <= 0) { alert("ID 必须为正整数"); submitting.value = false; return; }
if (!enableBoard.value) { form.mainboardIp = ""; form.mainboardPort = 80; }
if (form.brand === 4 && !form.ipAddress) { form.ipAddress = "0.0.0.0"; }
if (isSdkBrand.value) {
const autoGen = generateRtspUrl();
if (form.rtspPath === autoGen) form.rtspPath = "";
}
try {
if (mode.value === 'add') {
const url = `${API_BASE}/api/Cameras`;
logToParent('POST', url, 'PENDING', 'Adding camera...');
await axios.post(url, form);
logToParent('POST', url, 200, 'Created');
alert("添加成功");
window.parent.postMessage({ type: 'CLOSE_ADD_MODE', needRefresh: true }, '*');
} else {
const url = `${API_BASE}/api/Cameras/${deviceId.value}`;
logToParent('PUT', url, 'PENDING', 'Updating camera...');
await axios.put(url, form);
logToParent('PUT', url, 200, 'Updated');
alert("保存成功");
window.parent.postMessage({ type: 'CLOSE_EDIT_MODE', needRefresh: true }, '*');
}
} catch (e) {
const msg = e.response?.data?.message || e.message;
let detail = msg;
if (e.response?.data?.errors) detail += "\n" + JSON.stringify(e.response.data.errors);
alert("操作失败: " + detail);
logToParent(mode.value === 'add' ? 'POST' : 'PUT', 'API', 'ERROR', msg);
} finally { submitting.value = false; }
};
// 【新增】删除设备
const removeDevice = async () => {
if(!confirm(`确定要删除设备 [${form.name}] (ID:${deviceId.value}) 吗?\n此操作不可恢复!`)) return;
submitting.value = true;
const url = `${API_BASE}/api/Cameras/${deviceId.value}`;
try {
logToParent('DELETE', url, 'PENDING', 'Deleting camera...');
await axios.delete(url);
logToParent('DELETE', url, 200, 'Deleted');
alert("删除成功");
// 刷新列表并退出编辑模式
window.parent.postMessage({ type: 'CLOSE_EDIT_MODE', needRefresh: true }, '*');
} catch(e) {
alert("删除失败: " + e.message);
logToParent('DELETE', url, 'ERROR', e.message);
submitting.value = false;
}
};
const closeMode = () => {
const msgType = mode.value === 'add' ? 'CLOSE_ADD_MODE' : 'CLOSE_EDIT_MODE';
window.parent.postMessage({ type: msgType, needRefresh: false }, '*');
};
onMounted(() => {
window.addEventListener('message', (e) => {
if (e.data.type === 'LOAD_EDIT_DATA') {
if (e.data.apiBase) API_BASE = e.data.apiBase;
loadData(e.data.deviceId);
} else if (e.data.type === 'INIT_ADD_PAGE') {
if (e.data.apiBase) API_BASE = e.data.apiBase;
initAdd();
}
});
});
return {
mode, deviceId, pageTitle, form, loadingData, submitting, enableBoard, isSdkBrand,
save, removeDevice, closeMode, onBrandChange, tryAutoGenerateRtsp, forceGenerateRtsp
};
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body { background: #1e1e1e; color: #d4d4d4; margin: 0; font-family: 'Consolas', monospace; font-size: 11px; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* 头部样式 */
.header { background: #2d2d2d; padding: 5px 15px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-bottom: 2px solid #444; height: 40px; flex-shrink: 0; }
.search-box { background: #3c3c3c; border: 1px solid #555; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; width: 220px; }
/* 日志区域 */
.body { flex: 1; overflow-y: auto; padding: 5px 10px; background: #1e1e1e; }
.log-row { display: flex; gap: 12px; border-bottom: 1px solid #2a2a2a; padding: 4px 0; align-items: center; white-space: nowrap; }
.log-row:hover { background: #2a2a2a; }
/* 文字颜色定义 */
.t-time { color: #888; min-width: 75px; }
.t-method { font-weight: bold; min-width: 50px; color: #569cd6; }
.t-url { color: #9cdcfe; flex: 1; overflow: hidden; text-overflow: ellipsis; cursor: help; }
.status-ok { color: #4ec9b0; }
.status-err { color: #f44747; }
/* 复制按钮样式 */
.btn-copy {
color: #6a9955; cursor: pointer; padding: 0 5px;
border: 1px solid transparent; border-radius: 3px;
transition: 0.2s; visibility: hidden; /* 默认隐藏,悬停显示 */
}
.log-row:hover .btn-copy { visibility: visible; }
.btn-copy:hover { border-color: #6a9955; background: rgba(106, 153, 85, 0.1); }
/* 滚动条 */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
</style>
</head>
<body>
<div id="app" style="display: contents;">
<div class="header" @click="toggle">
<div>
<i class="bi bi-terminal-split me-2 text-warning"></i>
全路径诊断中控 ({{ filteredLogs.length }} 条)
</div>
<div class="d-flex align-items-center">
<input type="text" class="form-control form-control-sm search-box me-3"
v-model="searchText" @click.stop placeholder="检索 URL / 关键字...">
<span style="color:#ffca28; font-weight:bold; cursor:pointer" class="me-3">
{{ isExpanded ? '▼ 收起' : '▲ 展开' }}
</span>
<button class="btn btn-outline-danger btn-sm py-0 px-2" @click.stop="logs=[]">清空</button>
</div>
</div>
<div class="body" id="log-container">
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
<span class="t-time">[{{ log.time }}]</span>
<span class="t-method">{{ log.method }}</span>
<span class="t-url" :title="log.url">{{ log.url }}</span>
<span :class="log.ok ? 'status-ok' : 'status-err'">{{ log.status }}</span>
<span class="btn-copy" @click.stop="copyLog(log.url)" title="复制完整URL">
<i class="bi bi-clipboard-plus"></i> 复制
</span>
</div>
<div v-if="filteredLogs.length === 0" class="text-center text-muted mt-5">
<i class="bi bi-activity d-block fs-3 mb-2"></i>
等待数据请求...
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script>
const { createApp, ref, computed, nextTick } = Vue;
createApp({
setup() {
const logs = ref([]);
const isExpanded = ref(false);
const searchText = ref("");
// 监听母座转发的日志
window.addEventListener('message', (e) => {
if (e.data.type === 'PUSH_LOG') {
logs.value.push({
id: Date.now() + Math.random(),
time: new Date().toLocaleTimeString(),
...e.data.log,
ok: e.data.log.status === 200 || e.data.log.status === 'OK' || e.data.log.status === 'SEND'
});
// 自动清理,防止内存溢出
if (logs.value.length > 200) logs.value.shift();
// 如果没在搜索,自动滚动到底部
if(!searchText.value) {
nextTick(() => {
const c = document.getElementById('log-container');
c.scrollTop = c.scrollHeight;
});
}
}
});
// 复制到剪贴板功能
const copyLog = (text) => {
navigator.clipboard.writeText(text).then(() => {
// 可以在这里加个简单的 Toast 提示,或者改变图标颜色
console.log('Copied:', text);
});
};
const toggle = () => {
isExpanded.value = !isExpanded.value;
window.parent.postMessage({ type: 'UI_RESIZE_DIAG', expanded: isExpanded.value }, '*');
};
const filteredLogs = computed(() => {
const kw = searchText.value.toLowerCase();
return logs.value.filter(l =>
l.url.toLowerCase().includes(kw) ||
(l.msg && l.msg.toLowerCase().includes(kw))
);
});
return { logs, isExpanded, searchText, filteredLogs, toggle, copyLog };
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
body { background: #f4f6f9; padding: 20px; font-size: 0.85rem; }
.card { border: none; border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); background: #fff; }
</style>
</head>
<body>
<div id="app" v-cloak>
<div v-if="!deviceId" class="text-center mt-5 text-muted">
<i class="bi bi-cpu display-1 opacity-25"></i>
<p class="mt-3">请选择设备以载入配置</p>
</div>
<div v-else class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="m-0 fw-bold">AI 配置 (ID: {{ deviceId }})</h5>
<button class="btn btn-primary btn-sm" @click="save">下发配置</button>
</div>
<div class="row">
<div class="col-md-6 border-end">
<label class="fw-bold mb-2">缩放控制</label>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" v-model="proc.enableShrink">
<label>启用缩小</label>
</div>
<div class="input-group input-group-sm mb-3">
<input type="number" class="form-control" v-model.number="proc.targetWidth">
<span class="input-group-text">x</span>
<input type="number" class="form-control" v-model.number="proc.targetHeight">
</div>
</div>
<div class="col-md-6 ps-4">
<label class="fw-bold mb-2">图像增强</label>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" v-model="proc.enableEnhance">
<label>开启亮度补偿</label>
</div>
<input type="range" class="form-range" v-model.number="proc.brightnessLevel" :disabled="!proc.enableEnhance">
</div>
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
const API_BASE = "http://localhost:5000";
createApp({
setup() {
const deviceId = ref(null);
const proc = reactive({ enableShrink: true, targetWidth: 640, targetHeight: 360, enableEnhance: false, brightnessLevel: 30 });
// 【修复点】路径对齐到 GET /api/Monitor/{id}
const loadDetail = async (id) => {
const fullUrl = `${API_BASE}/api/Monitor/${id}`;
try {
const res = await axios.get(fullUrl);
// 假设返回的 JSON 包含 processingOptions 字段
if (res.data && res.data.processingOptions) {
Object.assign(proc, res.data.processingOptions);
}
window.parent.postMessage({ type: 'API_LOG', log: { method: 'GET', url: fullUrl, status: 200, msg: '配置载入成功' }}, '*');
} catch (e) {
window.parent.postMessage({ type: 'API_LOG', log: { method: 'GET', url: fullUrl, status: 'FAIL', msg: e.message }}, '*');
}
};
const save = async () => {
// 对齐到 POST /api/Monitor/update-processing
const fullUrl = `${API_BASE}/api/Monitor/update-processing`;
try {
await axios.post(fullUrl, { deviceId: deviceId.value, ...proc });
alert("配置已更新");
} catch (e) { alert("更新失败"); }
};
onMounted(() => {
window.addEventListener('message', (event) => {
if (event.data.type === 'LOAD_DEVICE') {
deviceId.value = event.data.deviceId;
loadDetail(event.data.deviceId);
}
});
});
return { deviceId, proc, save };
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>顶部控制栏</title>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
body {
background: #fff;
margin: 0; padding: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
/* 容器布局 */
.top-bar {
height: 95px;
display: flex; align-items: center; padding: 0 20px;
background: #fff; border-bottom: 1px solid #e9ecef;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
box-sizing: border-box;
}
.divider { width: 1px; height: 50px; background: #f0f0f0; margin: 0 25px; }
/* 左侧状态 */
.status-group { display: flex; align-items: center; min-width: 160px; }
.status-dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 12px; position: relative; }
.st-run { background: #198754; box-shadow: 0 0 0 4px rgba(25, 135, 84, 0.15); }
.st-stop { background: #dc3545; box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.15); }
.st-wait { background: #ffc107; box-shadow: 0 0 0 4px rgba(255, 193, 7, 0.15); animation: pulse 1.5s infinite; }
.status-main { font-size: 1.1rem; font-weight: 700; color: #343a40; line-height: 1.2; }
.status-sub { font-size: 0.75rem; color: #adb5bd; margin-top: 2px; }
/* 信息与数据 */
.info-group { display: flex; flex-direction: column; justify-content: center; gap: 4px; font-size: 0.85rem; }
.info-row { display: flex; align-items: center; }
.info-label { color: #8898aa; width: 45px; font-weight: 500; }
.info-val { color: #495057; font-family: 'Segoe UI Semibold', sans-serif; }
.stat-group { display: flex; gap: 20px; }
.stat-item { text-align: center; min-width: 70px; }
.stat-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
.stat-value { font-size: 1.1rem; color: #212529; font-weight: 600; font-family: Consolas, monospace; }
/* 按钮风格 */
.tool-btn {
background: linear-gradient(to bottom, #ffffff, #f1f3f5);
border: 1px solid #dee2e6; color: #495057; padding: 0;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center;
width: 68px; height: 68px; margin-left: 10px; border-radius: 6px; transition: all 0.1s;
}
.tool-btn:hover { background: linear-gradient(to bottom, #fff, #e2e6ea); border-color: #ced4da; color: #212529; }
.tool-btn:active { background: #e9ecef; box-shadow: inset 0 2px 5px rgba(0,0,0,0.05); transform: translateY(1px); }
.tool-btn i { font-size: 1.4rem; margin-bottom: 5px; color: #6c757d; }
.btn-stop-mode i { color: #dc3545; }
.btn-play-mode i { color: #198754; }
@keyframes pulse { 0% { opacity: 0.6; } 50% { opacity: 1; } 100% { opacity: 0.6; } }
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="top-bar" v-if="cam">
<div class="status-group">
<div class="status-dot" :class="statusStyle.cls"></div>
<div>
<div class="status-main">{{ statusStyle.text }}</div>
<div class="status-sub">ID: {{ cam.id }}</div>
</div>
</div>
<div class="divider"></div>
<div class="info-group">
<div class="info-row">
<span class="info-label">名称</span>
<span class="info-val" :title="cam.name">{{ truncate(cam.name) }}</span>
</div>
<div class="info-row">
<span class="info-label">地址</span>
<span class="info-val">{{ cam.ipAddress }}</span>
</div>
</div>
<div class="divider"></div>
<div class="stat-group me-auto">
<div class="stat-item">
<div class="stat-label">分辨率</div>
<div class="stat-value">{{ cam.width }}<small class="text-muted" style="font-size:0.7em">x{{cam.height}}</small></div>
</div>
<div class="stat-item">
<div class="stat-label">实时帧率</div>
<div class="stat-value">{{ cam.realFps }} <small style="font-size:0.6em">FPS</small></div>
</div>
</div>
<div class="d-flex">
<button class="tool-btn" :class="cam.status === 'Playing' ? 'btn-stop-mode' : 'btn-play-mode'" @click="togglePower" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm mb-1 text-secondary"></span>
<i v-else :class="cam.status === 'Playing' ? 'bi-power' : 'bi-play-circle-fill'"></i>
<span>{{ btnText }}</span>
</button>
<button class="tool-btn" @click="openEdit" title="修改IP、端口等基础信息">
<i class="bi-pencil-square"></i>
<span>编辑</span>
</button>
<button class="tool-btn" @click="openControl" title="云台、校时、重启">
<i class="bi-dpad-fill"></i>
<span>控制</span>
</button>
<button class="tool-btn" @click="openPre" title="图像预处理">
<i class="bi-sliders2"></i>
<span>预处理</span>
</button>
<button class="tool-btn" @click="openSub" title="流订阅分发">
<i class="bi-diagram-3"></i>
<span>订阅</span>
</button>
</div>
</div>
<div v-else class="top-bar justify-content-center text-muted">
<i class="bi bi-hand-index-thumb me-2"></i> 请从左侧列表选择设备进行操作
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script>
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
const API_BASE = "http://localhost:5000";
createApp({
setup() {
const cam = ref(null);
const loading = ref(false);
let pollTimer = null;
const statusStyle = computed(() => {
if(!cam.value) return {};
const s = cam.value.status;
if(s === 'Playing') return { text: '运行中', cls: 'st-run' };
if(s === 'Connecting') return { text: '启动中...', cls: 'st-wait' };
return { text: '已停止', cls: 'st-stop' };
});
const btnText = computed(() => {
if(loading.value) return '请稍候';
if(cam.value?.status === 'Playing') return '停止';
return '启动';
});
const truncate = (str) => (!str ? '-' : (str.length > 10 ? str.substring(0,10)+'..' : str));
// 状态轮询
const refreshStatus = async () => {
if (!cam.value) return;
try {
const res = await axios.get(`${API_BASE}/api/Monitor/${cam.value.id}`);
if (res.data) Object.assign(cam.value, res.data);
} catch (e) { console.warn("状态轮询失败", e); }
};
const togglePower = () => {
if (!cam.value || loading.value) return;
const isStart = cam.value.status !== 'Playing';
cam.value.status = isStart ? 'Connecting' : 'Stopped';
loading.value = true;
window.parent.postMessage({ type: 'DEVICE_CONTROL', action: isStart ? 'start' : 'stop', deviceId: cam.value.id }, '*');
setTimeout(() => { loading.value = false; }, 2000);
};
// 消息发送
const openSub = () => window.parent.postMessage({ type: 'OPEN_SUBSCRIPTION', id: cam.value.id }, '*');
const openPre = () => window.parent.postMessage({ type: 'OPEN_PREPROCESS', id: cam.value.id }, '*');
// 两个不同的弹窗入口
const openEdit = () => window.parent.postMessage({ type: 'OPEN_CAMERA_EDIT', id: cam.value.id }, '*');
const openControl = () => window.parent.postMessage({ type: 'OPEN_CAMERA_CONTROL', id: cam.value.id }, '*');
onMounted(() => {
window.addEventListener('message', (e) => {
if (e.data.type === 'UPDATE_TOP_INFO') cam.value = e.data.data;
});
pollTimer = setInterval(() => { if(cam.value) refreshStatus(); }, 2000);
});
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); });
return { cam, statusStyle, btnText, loading, truncate, togglePower, openSub, openPre, openEdit, openControl };
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none !important; }
body { background-color: #fff; font-size: 0.85rem; height: 100vh; overflow: hidden; display: flex; flex-direction: column; font-family: "Segoe UI", sans-serif; }
.sidebar-header { padding: 12px; border-bottom: 1px solid #eee; background: #f8f9fa; flex-shrink: 0; }
.sidebar-list { flex: 1; overflow-y: auto; }
/* 经典卡片样式 */
.camera-card { padding: 10px 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: 0.2s; border-left: 4px solid transparent; }
.camera-card:hover { background-color: #f8f9fa; }
.camera-card.active { background-color: #e3f2fd; border-left-color: #0d6efd !important; }
/* 状态边框颜色 */
.border-run { border-left-color: #198754; }
.border-wait { border-left-color: #ffc107; }
.border-err { border-left-color: #dc3545; }
/* 细节样式 */
.ping-dot { font-size: 10px; margin-right: 6px; }
.ping-online { color: #198754; text-shadow: 0 0 5px rgba(25, 135, 84, 0.5); }
.ping-offline { color: #adb5bd; }
.device-id { font-weight: bold; color: #333; }
.info-row { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
.res-tag {
font-size: 0.7rem; color: #666; background: #f0f0f0;
padding: 1px 5px; border-radius: 3px; font-family: monospace;
}
</style>
</head>
<body>
<div id="app" v-cloak class="d-flex flex-column h-100">
<div class="sidebar-header">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0" v-model="searchText" placeholder="搜索 ID / IP / 名称...">
<button class="btn btn-primary" @click="openAddPage" title="添加新设备">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="d-flex justify-content-between align-items-center px-1">
<small class="text-muted">运行中: <span class="text-success fw-bold">{{ playingCount }}</span> / {{ list.length }}</small>
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" v-model="autoRefresh" id="ar" style="transform: scale(0.8);">
<label class="form-check-label small" for="ar">自动刷新</label>
</div>
</div>
</div>
<div class="sidebar-list">
<div v-for="cam in filteredList" :key="cam.id"
class="camera-card"
:class="[{'active': sid === cam.id}, getStatusBorder(cam.status)]"
@click="onSelect(cam)">
<div class="info-row mb-1">
<span class="device-id text-truncate" style="max-width: 160px;" :title="cam.name">
<i class="bi bi-circle-fill ping-dot" :class="cam.isPhysicalOnline ? 'ping-online' : 'ping-offline'"></i>
#{{ cam.id }} {{ cam.name || '未命名相机' }}
</span>
<span v-if="isRun(cam.status)" class="badge bg-success" style="font-size: 0.6rem; padding: 2px 5px;">LIVE</span>
<span v-else-if="isWait(cam.status)" class="badge bg-warning text-dark" style="font-size: 0.6rem;">BUSY</span>
<span v-else-if="cam.status === 'Faulted'" class="badge bg-danger" style="font-size: 0.6rem;">ERR</span>
<span v-else class="badge bg-light text-muted border" style="font-size: 0.6rem;">STOP</span>
</div>
<div class="info-row text-muted small">
<span><i class="bi bi-link-45deg"></i> {{ cam.ipAddress }}</span>
<div class="d-flex align-items-center gap-2">
<span v-if="cam.resolution" class="res-tag" title="当前分辨率">
<i class="bi bi-aspect-ratio me-1"></i>{{ cam.resolution }}
</span>
<span v-if="isRun(cam.status)" class="text-primary fw-bold" style="min-width: 45px; text-align: right;">
{{ cam.realFps?.toFixed(1) }} FPS
</span>
</div>
</div>
</div>
<div v-if="filteredList.length === 0" class="text-center mt-5 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-25"></i>
未找到设备
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, computed, onMounted } = Vue;
createApp({
setup() {
const API_BASE = "http://localhost:5000";
const list = ref([]);
const sid = ref(null);
const searchText = ref("");
const autoRefresh = ref(true);
// 状态判断
const isRun = (s) => s === 'Playing' || s === 'Streaming';
const isWait = (s) => ['Connecting', 'Authorizing', 'Reconnecting'].includes(s);
const getStatusBorder = (s) => {
if (isRun(s)) return 'border-run';
if (isWait(s)) return 'border-wait';
if (s === 'Faulted') return 'border-err';
return '';
};
const fetchList = async () => {
try {
const res = await axios.get(API_BASE + "/api/Monitor/all");
const data = res.data || [];
list.value = data.map(item => ({
...item,
// 兼容大小写
resolution: item.resolution || item.Resolution || (item.width ? `${item.width}x${item.height}` : null)
}));
} catch (e) { console.error(e); }
};
const onSelect = (cam) => {
sid.value = cam.id;
if(window.parent) window.parent.postMessage({ type: 'DEVICE_SELECTED', data: JSON.parse(JSON.stringify(cam)) }, '*');
};
// 【触发】打开右侧添加页
const openAddPage = () => {
if(window.parent) window.parent.postMessage({ type: 'OPEN_CAMERA_ADD' }, '*');
};
window.addEventListener('message', (e) => {
if(e.data.type === 'REFRESH_LIST') fetchList();
});
const filteredList = computed(() => {
const kw = searchText.value.toLowerCase().trim();
if (!kw) return list.value;
return list.value.filter(i =>
String(i.id).includes(kw) ||
(i.name && i.name.toLowerCase().includes(kw)) ||
(i.ipAddress && i.ipAddress.includes(kw))
);
});
const playingCount = computed(() => list.value.filter(i => isRun(i.status)).length);
onMounted(() => {
fetchList();
setInterval(() => { if (autoRefresh.value) fetchList(); }, 3000);
});
return {
list, sid, searchText, autoRefresh,
filteredList, playingCount,
onSelect, isRun, isWait, getStatusBorder,
openAddPage
};
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SHH 视频网关 - 控制底座</title>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
body, html { margin: 0; padding: 0; height: 100vh; overflow: hidden; background: #f4f6f9; }
.app-shell { display: flex; height: 100vh; }
.sidebar-container { width: 320px; border-right: 1px solid #ddd; background: #fff; flex-shrink: 0; }
.main-workarea { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.top-container { height: 110px; padding: 15px 15px 0 15px; flex-shrink: 0; }
.editor-container { flex: 1; padding: 15px; overflow: hidden; position: relative; }
.log-panel { height: 45px; border-top: 1px solid #333; transition: height 0.3s; background: #1e1e1e; }
.modal-body iframe { border: none; width: 100%; }
#frame-editor { width: 100%; height: 100%; border: none; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
</style>
</head>
<body>
<div class="app-shell">
<div class="sidebar-container">
<iframe id="frame-list" src="List.html" style="width:100%; height:100%; border:none;" name="list"></iframe>
</div>
<div class="main-workarea">
<div class="top-container">
<iframe id="frame-top" src="EditorTop.html" style="width:100%; height:100%; border:none;" name="top"></iframe>
</div>
<div class="editor-container">
<iframe id="frame-editor" src="Editor.html" name="editor"></iframe>
</div>
<div id="diag-wrapper" class="log-panel">
<iframe id="frame-diag" src="Diagnostic.html" style="width:100%; height:100%; border:none;" name="diag"></iframe>
</div>
</div>
</div>
<div class="modal fade" id="subModal" tabindex="-1"><div class="modal-dialog modal-lg modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-sub" src="Subscription.html" style="height: 650px;" name="sub"></iframe></div></div></div></div>
<div class="modal fade" id="preModal" tabindex="-1"><div class="modal-dialog modal-md modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-pre" src="Preprocessing.html" style="height: 580px;" name="pre"></iframe></div></div></div></div>
<div class="modal fade" id="ctrlModal" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-ctrl" src="CameraControl.html" style="height: 480px;" name="ctrl"></iframe></div></div></div></div>
<script src="https://cdn.staticfile.org/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<script>
const API_BASE = "http://localhost:5000";
let currentDeviceId = 0;
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || !msg.type) return;
const frames = {
list: document.getElementById('frame-list').contentWindow,
top: document.getElementById('frame-top').contentWindow,
editor: document.getElementById('frame-editor').contentWindow,
diag: document.getElementById('frame-diag').contentWindow,
sub: document.getElementById('frame-sub').contentWindow,
pre: document.getElementById('frame-pre').contentWindow,
ctrl: document.getElementById('frame-ctrl').contentWindow
};
const editorIframe = document.getElementById('frame-editor');
const switchToDetail = () => {
editorIframe.src = "Editor.html";
editorIframe.onload = () => {
setTimeout(() => {
if(currentDeviceId) editorIframe.contentWindow.postMessage({ type: 'LOAD_DEVICE', deviceId: currentDeviceId }, '*');
}, 100);
};
};
switch(msg.type) {
case 'DEVICE_SELECTED':
currentDeviceId = msg.data.id;
// 如果当前不是Editor页面切回
if (!editorIframe.src.includes('Editor.html')) {
switchToDetail();
} else {
if(frames.top) frames.top.postMessage({ type: 'UPDATE_TOP_INFO', data: msg.data }, '*');
if(frames.editor) frames.editor.postMessage({ type: 'LOAD_DEVICE', deviceId: msg.data.id }, '*');
}
break;
case 'DEVICE_CONTROL':
const controlUrl = `${API_BASE}/api/Cameras/${msg.deviceId}/power?enabled=${msg.action === 'start'}`;
axios.post(controlUrl).then(() => {
frames.list.postMessage({ type: 'REFRESH_LIST' }, '*');
frames.diag.postMessage({ type: 'PUSH_LOG', log: { method: 'POST', url: controlUrl, status: 200, msg: `Device ${msg.action}` } }, '*');
}).catch(e => console.error(e));
break;
case 'API_LOG':
if(frames.diag) frames.diag.postMessage({ type: 'PUSH_LOG', log: msg.log }, '*');
break;
case 'UI_RESIZE_DIAG':
const diagEl = document.getElementById('diag-wrapper');
if(diagEl) diagEl.style.height = msg.expanded ? '350px' : '45px';
break;
// 弹窗逻辑
case 'OPEN_SUBSCRIPTION':
new bootstrap.Modal(document.getElementById('subModal')).show();
setTimeout(() => frames.sub.postMessage({ type: 'LOAD_SUBS_DATA', deviceId: msg.id }, '*'), 400);
break;
case 'OPEN_PREPROCESS':
editorIframe.src = "Preprocessing.html";
editorIframe.onload = () => {
editorIframe.contentWindow.postMessage({ type: 'LOAD_PREPROCESS_DATA', deviceId: msg.id, apiBase: API_BASE }, '*');
};
break;
case 'OPEN_CAMERA_CONTROL':
new bootstrap.Modal(document.getElementById('ctrlModal')).show();
setTimeout(() => frames.ctrl.postMessage({ type: 'LOAD_CTRL_DATA', deviceId: msg.id, apiBase: API_BASE }, '*'), 400);
break;
// --- 统一使用 CameraEdit.html ---
case 'OPEN_CAMERA_EDIT':
editorIframe.src = "CameraEdit.html";
editorIframe.onload = () => {
editorIframe.contentWindow.postMessage({ type: 'LOAD_EDIT_DATA', deviceId: msg.id, apiBase: API_BASE }, '*');
};
break;
case 'OPEN_CAMERA_ADD': // 新增也指向同一个文件
editorIframe.src = "CameraEdit.html";
editorIframe.onload = () => {
editorIframe.contentWindow.postMessage({ type: 'INIT_ADD_PAGE', apiBase: API_BASE }, '*');
};
break;
// 统一关闭逻辑
case 'CLOSE_EDIT_MODE':
case 'CLOSE_PREPROCESS_MODE':
case 'CLOSE_ADD_MODE':
switchToDetail();
if(msg.needRefresh) frames.list.postMessage({ type: 'REFRESH_LIST' }, '*');
break;
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,375 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>图像预处理配置</title>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
/* 1. 基础布局全屏、Flex */
html, body {
height: 100%; margin: 0; padding: 0;
background: #fff; font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
#app {
height: 100%; display: flex; flex-direction: column;
}
/* 2. 头部样式 */
.page-header {
padding: 15px 30px;
border-bottom: 1px solid #e9ecef;
background: #fff;
display: flex; justify-content: space-between; align-items: center;
flex-shrink: 0;
}
.page-title { font-size: 1.2rem; font-weight: 700; color: #343a40; display: flex; align-items: center; }
/* 3. 中间内容区 */
.content-body {
flex: 1; overflow-y: auto; padding: 30px 40px;
max-width: 1200px; margin: 0 auto; width: 100%;
background: #fff;
}
.content-body::-webkit-scrollbar { width: 8px; }
.content-body::-webkit-scrollbar-track { background: #f8f9fa; }
.content-body::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 4px; }
/* 4. 分区标题:亮蓝色风格 */
.section-header {
display: flex; align-items: center; margin-top: 10px; margin-bottom: 25px;
padding-bottom: 10px; border-bottom: 2px solid #e7f1ff;
}
.section-header h6 {
margin: 0; font-weight: 800; color: #0d6efd;
font-size: 1rem; text-transform: uppercase; letter-spacing: 0.5px;
}
/* 5. 组件样式 */
.form-label { font-weight: 600; color: #495057; margin-bottom: 8px; font-size: 0.9rem; }
.form-control { padding: 10px 15px; border-radius: 6px; border-color: #dee2e6; }
.form-control:focus { border-color: #86b7fe; box-shadow: 0 0 0 3px rgba(13,110,253,0.1); }
.preset-badge {
cursor: pointer; user-select: none; border: 1px solid #dee2e6;
color: #555; background: #fff; transition: all 0.2s;
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; margin-right: 8px; margin-bottom: 8px;
display: inline-block;
}
.preset-badge:hover { background-color: #f8f9fa; border-color: #adb5bd; }
.preset-badge.active { background-color: #e7f1ff; color: #0d6efd; border-color: #0d6efd; font-weight: 600; }
.source-info-bar {
background-color: #f8f9fa; border: 1px dashed #ced4da; border-radius: 8px;
padding: 12px 20px; margin-bottom: 25px; display: flex; justify-content: space-between; align-items: center;
}
/* 6. 底部按钮栏 */
.footer-bar {
padding: 15px 40px; border-top: 1px solid #e9ecef; background: #fff;
display: flex; justify-content: space-between; align-items: center;
flex-shrink: 0; z-index: 10;
}
.success-msg { color: #198754; font-weight: 500; display: flex; align-items: center; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.is-invalid { border-color: #dc3545 !important; }
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="page-header">
<div class="page-title">
<i class="bi bi-sliders2 me-2 text-primary"></i>
图像预处理配置 <span class="text-muted ms-2 fw-normal fs-6">(ID: {{ deviceId }})</span>
</div>
</div>
<div class="content-body">
<div class="section-header">
<h6><i class="bi bi-aspect-ratio me-2"></i>分辨率控制 (Resolution)</h6>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div>
<span class="text-muted me-2"><i class="bi bi-camera-video"></i> 源画面尺寸:</span>
<strong class="font-monospace fs-5 text-dark">{{ baseRes.w }} x {{ baseRes.h }}</strong>
<span class="badge bg-light text-secondary border me-2" v-if="currentScale !== '1.00'">缩放比: {{ currentScale }}x</span>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch p-2 ps-5 border rounded bg-white">
<input class="form-check-input" type="checkbox" v-model="config.allowShrink" style="margin-left: -2.5em;">
<label class="form-check-label fw-bold">允许缩小 (Shrink)</label>
<div class="text-muted small mt-1">当源分辨率大于目标时生效,通常建议开启以节省带宽。</div>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch p-2 ps-5 border rounded bg-white">
<input class="form-check-input" type="checkbox" v-model="config.allowEnlarge" @change="validateInputLimit" style="margin-left: -2.5em;">
<label class="form-check-label fw-bold">允许放大 (Expand)</label>
<div class="text-muted small mt-1">允许输出比源画面更大的分辨率(可能会增加系统负载)。</div>
</div>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="d-flex justify-content-between mb-2">
<label class="form-label">目标分辨率 (Target Size)</label>
<div class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" id="lockRatio" v-model="lockRatio">
<label class="form-check-label small text-muted" for="lockRatio"><i class="bi bi-link-45deg"></i> 锁定比例</label>
</div>
</div>
<div class="row g-2 align-items-center">
<div class="col-5">
<div class="input-group">
<span class="input-group-text bg-light text-muted">W</span>
<input type="number" class="form-control font-monospace" v-model.number="config.width"
@input="onWidthChange" @blur="validateInputLimit" :class="{'is-invalid': inputError.w}">
</div>
</div>
<div class="col-auto text-muted"><i class="bi bi-x-lg"></i></div>
<div class="col-5">
<div class="input-group">
<span class="input-group-text bg-light text-muted">H</span>
<input type="number" class="form-control font-monospace" v-model.number="config.height"
@input="onHeightChange" @blur="validateInputLimit" :class="{'is-invalid': inputError.h}">
</div>
</div>
</div>
<div v-if="inputError.msg" class="text-danger small mt-2"><i class="bi bi-exclamation-circle me-1"></i>{{ inputError.msg }}</div>
</div>
<div class="col-md-5">
<label class="form-label d-block mb-3">常用预设 (Presets)</label>
<span v-for="p in presets" :key="p.label" class="preset-badge"
:class="{ 'active': config.width === p.w && config.height === p.h }" @click="applyPreset(p)">
{{ p.label }} <span class="opacity-50 ms-1" v-if="p.w > 0">{{ p.w }}x{{ p.h }}</span>
</span>
</div>
</div>
<div class="mb-5">
<label class="form-label d-flex justify-content-between">
<span>快速缩放</span>
<span class="text-primary font-monospace">{{ sliderScale }}x</span>
</label>
<div class="d-flex align-items-center gap-2 mt-2">
<span class="small text-muted">0.1x</span>
<input type="range" class="form-range" min="0.1" max="2.0" step="0.1"
v-model.number="sliderScale" @input="onSliderChange">
<span class="small text-muted">2.0x</span>
</div>
</div>
<div class="section-header">
<h6><i class="bi bi-magic me-2"></i>图像增强 (Enhancement)</h6>
</div>
<div class="row">
<div class="col-12">
<div class="form-check form-switch mb-3 ps-5">
<input class="form-check-input" type="checkbox" v-model="config.enableGain" style="margin-left: -2.5em; transform: scale(1.2);">
<label class="form-check-label fw-bold">启用亮度/增益调节 (Brightness)</label>
</div>
</div>
<div class="col-md-8 transition-box" v-show="config.enableGain">
<div class="p-3 bg-light border rounded">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">强度 (Intensity)</span>
<span class="fw-bold text-primary">{{ config.brightness }}</span>
</div>
<div class="d-flex align-items-center gap-3">
<i class="bi bi-sun text-muted"></i>
<input type="range" class="form-range" min="0" max="255" step="1" v-model.number="config.brightness">
<i class="bi bi-sun-fill text-warning"></i>
</div>
</div>
</div>
</div>
<div style="height: 40px;"></div>
</div>
<div class="footer-bar">
<div class="me-auto">
<transition name="fade">
<div v-if="saveSuccess" class="success-msg">
<i class="bi bi-check-circle-fill me-2 fs-5"></i>
<span>配置已生效 (Applied)</span>
</div>
</transition>
<span v-if="errorMsg" class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>{{ errorMsg }}</span>
</div>
<button class="btn btn-light border px-4" @click="closeMode">取消</button>
<button class="btn btn-primary px-4 shadow-sm" @click="saveConfig" :disabled="loading || !!inputError.msg">
<span v-if="loading" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-floppy2-fill me-1"></i>
保存应用
</button>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
let API_BASE = "";
createApp({
setup() {
const deviceId = ref(0);
const loading = ref(false);
const saveSuccess = ref(false);
const errorMsg = ref("");
const lockRatio = ref(true);
const sliderScale = ref(1.0);
const baseRes = reactive({ w: 0, h: 0 });
const config = reactive({ width: 1280, height: 720, allowShrink: true, allowEnlarge: false, enableGain: false, brightness: 128 });
const inputError = reactive({ w: false, h: false, msg: '' });
const presets = [
{ label: 'Original', w: 0, h: 0 },
{ label: '2K', w: 2560, h: 1440 },
{ label: '1080P', w: 1920, h: 1080 },
{ label: '720P', w: 1280, h: 720 },
{ label: 'D1', w: 704, h: 576 },
{ label: 'Yolo', w: 640, h: 640 }
];
const currentScale = computed(() => (!baseRes.w ? "1.00" : (config.width / baseRes.w).toFixed(2)));
const logToParent = (status, msg, url, method = 'POST') => {
if (window.parent) window.parent.postMessage({ type: 'API_LOG', log: { method, url, status, msg } }, '*');
};
const loadDeviceInfo = async (id) => {
deviceId.value = id;
loading.value = true;
const urlStatus = `${API_BASE}/api/Monitor/${id}`;
const urlConfig = `${API_BASE}/api/cameras/${id}/processing`;
try {
const [resStatus, resConfig] = await Promise.allSettled([ axios.get(urlStatus), axios.get(urlConfig) ]);
if (resStatus.status === 'fulfilled' && resStatus.value.data) {
baseRes.w = resStatus.value.data.width || 2560;
baseRes.h = resStatus.value.data.height || 1440;
} else { baseRes.w = 2560; baseRes.h = 1440; }
if (resConfig.status === 'fulfilled' && resConfig.value.data) {
const d = resConfig.value.data;
if (d.targetWidth) config.width = d.targetWidth;
if (d.targetHeight) config.height = d.targetHeight;
config.allowShrink = d.enableShrink ?? true;
config.allowEnlarge = d.enableExpand ?? false;
config.enableGain = d.enableBrightness ?? false;
config.brightness = d.brightness ?? 128;
logToParent(200, '配置回显成功', urlConfig, 'GET');
}
if(baseRes.w > 0) sliderScale.value = (config.width / baseRes.w).toFixed(1);
} catch (e) { console.error(e); }
finally { loading.value = false; validateInputLimit(); }
};
const validateInputLimit = () => {
inputError.w = false; inputError.h = false; inputError.msg = "";
if (!config.allowEnlarge && baseRes.w > 0) {
let corrected = false;
if (config.width > baseRes.w) { config.width = baseRes.w; inputError.w = true; corrected = true; }
if (config.height > baseRes.h) { config.height = baseRes.h; inputError.h = true; corrected = true; }
if (corrected) {
inputError.msg = "尺寸限制:最大为源分辨率 (需开启放大)";
setTimeout(() => inputError.msg = "", 3000);
}
}
if(baseRes.w > 0) sliderScale.value = (config.width / baseRes.w).toFixed(1);
};
const onSliderChange = () => {
config.width = Math.round(baseRes.w * sliderScale.value);
config.height = Math.round(baseRes.h * sliderScale.value);
validateInputLimit();
};
const onWidthChange = () => {
if (lockRatio.value && baseRes.w > 0) config.height = Math.round(config.width * (baseRes.h / baseRes.w));
};
const onHeightChange = () => {
if (lockRatio.value && baseRes.h > 0) config.width = Math.round(config.height * (baseRes.w / baseRes.h));
};
const applyPreset = (p) => {
if (p.label === 'Original') { config.width = baseRes.w; config.height = baseRes.h; }
else { config.width = p.w; config.height = p.h; }
validateInputLimit();
};
const saveConfig = async () => {
validateInputLimit();
if(inputError.msg) return;
loading.value = true;
saveSuccess.value = false;
errorMsg.value = "";
const url = `${API_BASE}/api/cameras/${deviceId.value}/processing`;
const payload = {
DeviceId: deviceId.value, EnableShrink: config.allowShrink, EnableExpand: config.allowEnlarge,
TargetWidth: parseInt(config.width), TargetHeight: parseInt(config.height),
EnableBrightness: config.enableGain, Brightness: parseInt(config.brightness)
};
try {
await axios.post(url, payload);
logToParent(200, '配置已应用', url);
saveSuccess.value = true;
setTimeout(() => { saveSuccess.value = false; }, 2500);
} catch (e) {
const msg = e.response?.data?.message || e.message;
errorMsg.value = "提交失败: " + msg;
logToParent('ERROR', msg, url);
} finally {
loading.value = false;
}
};
// 【修改】关闭模式,而非关闭弹窗
const closeMode = () => window.parent.postMessage({ type: 'CLOSE_PREPROCESS_MODE' }, '*');
onMounted(() => {
window.addEventListener('message', (e) => {
if (e.data.type === 'LOAD_PREPROCESS_DATA') {
if (e.data.apiBase) API_BASE = e.data.apiBase;
loadDeviceInfo(e.data.deviceId);
}
});
});
return {
deviceId, config, baseRes, presets, loading, errorMsg, saveSuccess,
lockRatio, sliderScale, currentScale, inputError,
saveConfig, closeMode, applyPreset, onWidthChange, onHeightChange, validateInputLimit, onSliderChange
};
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>流控订阅配置中心</title>
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
<style>
[v-cloak] { display: none; }
body { background: #fff; font-size: 0.85rem; padding: 15px; font-family: "Segoe UI", system-ui, sans-serif; }
/* 配置面板样式 */
.config-section { border: 1px solid #dee2e6; border-radius: 8px; background: #f8f9fa; padding: 15px; margin-bottom: 20px; }
/* 列表项样式:点击回显 */
.sub-item {
border: 1px solid #e9ecef; border-radius: 8px; padding: 10px 15px; margin-bottom: 10px;
border-left: 4px solid #0d6efd; background: #fff; cursor: pointer; transition: all 0.2s;
}
.sub-item:hover { border-color: #0d6efd; box-shadow: 0 4px 10px rgba(0,0,0,0.08); background: #fcfcfc; }
.sub-item:active { transform: scale(0.99); }
/* 第一行:标题与备注 */
.sub-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.sub-title-group { display: flex; align-items: center; gap: 10px; flex-grow: 1; }
.app-id { font-family: "Cascadia Code", monospace; font-weight: 700; color: #333; }
.sub-memo { color: #6c757d; font-size: 0.8rem; border-left: 1px solid #ddd; padding-left: 10px; max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 第二行:详情展示 */
.sub-body { display: flex; align-items: center; gap: 15px; font-size: 0.75rem; color: #555; }
.type-badge { font-size: 0.7rem; padding: 1px 6px; border-radius: 4px; background: #e9ecef; color: #495057; font-weight: 600; }
.fps-real { color: #198754; font-weight: 700; background: #e8f5e9; padding: 0 4px; border-radius: 3px; }
.dynamic-detail { color: #0d6efd; display: flex; align-items: center; gap: 4px; }
/* 表单布局 */
.horizontal-group { display: flex; align-items: center; margin-bottom: 10px; }
.horizontal-group label { width: 80px; font-weight: 600; color: #495057; flex-shrink: 0; }
.horizontal-group .input-container { flex-grow: 1; display: flex; align-items: center; gap: 8px; }
.form-label-top { font-weight: 600; font-size: 0.75rem; color: #495057; margin-bottom: 3px; display: block; }
</style>
</head>
<body>
<div id="app" v-cloak>
<div v-if="deviceId">
<div class="config-section shadow-sm">
<h6 class="fw-bold mb-3 text-primary small d-flex align-items-center">
<i class="bi bi-gear-wide-connected me-2"></i>订阅策略分发
</h6>
<div class="row g-2 mb-3">
<div class="col-4">
<label class="form-label-top">订阅标识 (AppId)</label>
<input type="text" class="form-control form-control-sm" v-model.trim="form.appId" placeholder="自动生成 ID">
</div>
<div class="col-3">
<label class="form-label-top">目标帧率</label>
<div class="input-group input-group-sm">
<input type="number" class="form-control" v-model.number="form.displayFps">
<span class="input-group-text">FPS</span>
</div>
</div>
<div class="col-5">
<label class="form-label-top">业务类型</label>
<select class="form-select form-select-sm" v-model.number="form.typeIndex">
<option :value="0">本地窗口渲染 (OpenCV)</option>
<option :value="1">本地录像存储 (MP4)</option>
<option :value="2">窗口句柄穿透 (PlayM4)</option>
<option :value="3">网络数据转发 (TCP/UDP)</option>
</select>
</div>
</div>
<div class="dynamic-params-box">
<div class="horizontal-group" v-if="form.typeIndex === 2">
<label>窗口句柄</label>
<div class="input-container">
<input type="text" class="form-control form-control-sm" v-model="form.handle" placeholder="输入 HWND 句柄值 (如 0x00120F)">
</div>
</div>
<div class="horizontal-group" v-if="form.typeIndex === 1">
<label>录像配置</label>
<div class="input-container">
<input type="text" class="form-control form-control-sm" v-model="form.savePath" placeholder="存储路径 (如 D:\Recordings)">
<span class="small text-muted">时长:</span>
<input type="number" class="form-control form-control-sm" style="width: 70px;" v-model.number="form.recordDuration">
<span class="small text-muted">分钟</span>
</div>
</div>
<div class="horizontal-group" v-if="form.typeIndex === 3">
<label>转发目标</label>
<div class="input-container">
<input type="text" class="form-control form-control-sm" v-model="form.targetIp" placeholder="目标 IP">
<span class="fw-bold">:</span>
<input type="number" class="form-control form-control-sm" style="width: 90px;" v-model.number="form.targetPort">
</div>
</div>
<div class="horizontal-group">
<label>业务备注</label>
<div class="input-container">
<input type="text" class="form-control form-control-sm" v-model="form.memo" placeholder="例如AI识别、大屏显示、审计备份">
<button class="btn btn-primary btn-sm px-4 fw-bold" @click="submitSub" :disabled="loading">
<i class="bi bi-cloud-arrow-up-fill me-1"></i> {{ loading ? '下发中' : '执行订阅' }}
</button>
</div>
</div>
</div>
</div>
<div class="sub-list">
<h6 class="fw-bold mb-3 px-1 small d-flex justify-content-between align-items-center">
<span>活跃订阅列表 <span class="badge bg-success ms-1">{{ activeSubs.length }}</span></span>
<span class="text-muted small" style="font-weight:normal">定时刷新: {{ lastUpdateTime }}</span>
</h6>
<div v-for="sub in activeSubs" :key="sub.appId"
class="sub-item"
@click="echoToForm(sub)"
title="点击回显到表单进行编辑">
<div class="sub-header">
<div class="sub-title-group">
<span class="app-id">{{ sub.appId }}</span>
<span v-if="sub.memo" class="sub-memo">{{ sub.memo }}</span>
</div>
<button class="btn btn-link btn-sm p-0 text-danger" @click.stop="removeSub(sub.appId)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
<div class="sub-body">
<span class="type-badge">{{ translateType(sub.type) }}</span>
<span>目标: {{ sub.targetFps }} FPS</span>
<span class="fps-real">实际: {{ (sub.realFps || 0).toFixed(1) }} FPS</span>
<div class="dynamic-detail">
<span v-if="sub.type === 1 || sub.type === 'LocalRecord'">
<i class="bi bi-folder-symlink"></i> {{ sub.savePath }}
</span>
<span v-else-if="sub.type === 2 || sub.type === 'HandleDisplay'">
<i class="bi bi-window-stack"></i> HWND: {{ sub.handle }}
</span>
<span v-else-if="sub.type === 3 || sub.type === 'NetworkStream'">
<i class="bi bi-globe"></i> {{ sub.targetIp }}:{{ sub.targetPort }}
</span>
<span v-else>
<i class="bi bi-display"></i> 本地渲染
</span>
</div>
</div>
</div>
<div v-if="activeSubs.length === 0" class="text-center py-5 text-muted small border rounded bg-light border-dashed">
暂无活跃订阅需求,请在上方录入策略
</div>
</div>
</div>
</div>
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
<script>
const { createApp, ref, reactive, onMounted, onUnmounted } = Vue;
const API_BASE = "http://localhost:5000";
createApp({
setup() {
const deviceId = ref(null);
const activeSubs = ref([]);
const loading = ref(false);
const lastUpdateTime = ref('--:--:--');
const form = reactive({
appId: '', typeIndex: 0, displayFps: 15, memo: '',
handle: '', recordDuration: 5, savePath: 'C:\\Recordings',
targetIp: '127.0.0.1', targetPort: 8080
});
let pollTimer = null;
// 加载订阅数据
const loadSubs = async () => {
if (!deviceId.value) return;
try {
const res = await axios.get(`${API_BASE}/api/Monitor/${deviceId.value}`);
// 适配后端强类型 DTO 属性名
const raw = res.data.requirements || res.data.Requirements || [];
activeSubs.value = raw.map(r => ({
appId: r.appId || r.AppId,
type: r.type !== undefined ? r.type : r.Type,
targetFps: r.targetFps || r.TargetFps,
realFps: r.realFps || r.RealFps || 0,
memo: r.memo || r.Memo || '',
handle: r.handle || r.Handle || '',
savePath: r.savePath || r.SavePath || '',
targetIp: r.targetIp || r.TargetIp || '',
targetPort: r.targetPort || r.TargetPort || 0
}));
lastUpdateTime.value = new Date().toLocaleTimeString();
} catch (e) { console.error("刷新失败", e); }
};
// 点击卡片:回显数据到表单
const echoToForm = (sub) => {
form.appId = sub.appId;
form.displayFps = sub.targetFps;
form.memo = sub.memo;
form.handle = sub.handle;
form.savePath = sub.savePath;
form.targetIp = sub.targetIp;
form.targetPort = sub.targetPort;
// 处理类型映射
const typeMap = { "LocalWindow": 0, "LocalRecord": 1, "HandleDisplay": 2, "NetworkStream": 3 };
if (typeof sub.type === 'string') {
form.typeIndex = typeMap[sub.type] ?? 0;
} else {
form.typeIndex = sub.type;
}
// 视觉反馈:平滑滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// 提交订阅
const submitSub = async () => {
if (!form.appId) form.appId = 'SUB_' + Math.random().toString(36).substring(2, 9).toUpperCase();
loading.value = true;
try {
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/subscriptions`, {
appId: form.appId,
type: form.typeIndex,
displayFps: form.displayFps,
memo: form.memo,
handle: form.handle,
savePath: form.savePath,
recordDuration: form.recordDuration,
targetIp: form.targetIp,
targetPort: form.targetPort
});
await loadSubs();
} catch (e) { alert("下发失败,请检查网络或后端接口"); }
finally { loading.value = false; }
};
// 注销订阅
const removeSub = async (appId) => {
if(!confirm(`确定注销订阅: ${appId} ?`)) return;
try {
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/subscriptions`, { appId: appId, displayFps: 0 });
await loadSubs();
} catch (e) { alert("注销失败"); }
};
const translateType = (t) => {
const map = ["本地窗口", "录像存储", "句柄显示", "网络转发"];
return typeof t === 'number' ? map[t] : (map[t] || t);
};
onMounted(() => {
window.addEventListener('message', (e) => {
if (e.data.type === 'LOAD_SUBS_DATA') {
deviceId.value = e.data.deviceId;
loadSubs();
if(pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(loadSubs, 2000); // 2秒轮询一次实际帧率
}
});
});
onUnmounted(() => { if(pollTimer) clearInterval(pollTimer); });
return { deviceId, activeSubs, form, loading, lastUpdateTime, submitSub, removeSub, translateType, echoToForm };
}
}).mount('#app');
</script>
</body>
</html>