Compare commits
21 Commits
f32a9a3e8d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 994820e4b9 | |||
| fca2630998 | |||
| 3dbe38becd | |||
| 0a803f103c | |||
| 5af2ae1bcd | |||
| 6a02c96340 | |||
| c28ef38311 | |||
| c295d9264a | |||
| 41112e1c83 | |||
| 584a9cd621 | |||
| 3ecb60f61e | |||
| 616ed4a7c2 | |||
| e2d404749a | |||
| 8f8b4a2e97 | |||
| a8bc6d8888 | |||
| 5f9ef436c5 | |||
| e868fb8530 | |||
| 0eb2053c6f | |||
| 937eb07b37 | |||
| 13c76d8ac2 | |||
| 34f8a14b25 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,6 +27,8 @@ binlog_*.txt
|
||||
# Development tools
|
||||
.claude/
|
||||
.trae/
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
# Batch scripts (user-specific)
|
||||
*.bat
|
||||
|
||||
@@ -22,6 +22,9 @@ public class AiAlgorithm {
|
||||
@Schema(description = "参数模板JSON")
|
||||
private String paramSchema;
|
||||
|
||||
@Schema(description = "用户自定义的全局默认参数JSON")
|
||||
private String globalParams;
|
||||
|
||||
@Schema(description = "描述")
|
||||
private String description;
|
||||
|
||||
|
||||
@@ -39,4 +39,12 @@ public class AiRoiAlgoBind {
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private String updateTime;
|
||||
|
||||
// ---- 以下为关联查询字段,非表字段 ----
|
||||
|
||||
@Schema(description = "设备ID(关联查询)")
|
||||
private String deviceId;
|
||||
|
||||
@Schema(description = "ROI名称(关联查询)")
|
||||
private String roiName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.genersoft.iot.vmp.aiot.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiAlgorithm;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiAlgorithmService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -8,7 +10,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -19,10 +23,31 @@ public class AiAlgorithmController {
|
||||
@Autowired
|
||||
private IAiAlgorithmService algorithmService;
|
||||
|
||||
@Operation(summary = "查询算法列表")
|
||||
@Operation(summary = "查询算法列表(可选按设备覆盖参数)")
|
||||
@GetMapping("/list")
|
||||
public List<AiAlgorithm> queryList() {
|
||||
return algorithmService.queryAll();
|
||||
public List<AiAlgorithm> queryList(@RequestParam(required = false) String deviceId) {
|
||||
List<AiAlgorithm> algorithms = algorithmService.queryAll();
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
List<AiRoiAlgoBind> binds = algorithmService.queryBindsByDevice(deviceId);
|
||||
// 按 algo_code 分组,取第一个有参数的绑定作为该设备的绑定参数
|
||||
Map<String, String> deviceParams = new HashMap<>();
|
||||
for (AiRoiAlgoBind bind : binds) {
|
||||
if (!deviceParams.containsKey(bind.getAlgoCode()) && bind.getParams() != null) {
|
||||
deviceParams.put(bind.getAlgoCode(), bind.getParams());
|
||||
}
|
||||
}
|
||||
for (AiAlgorithm algo : algorithms) {
|
||||
String bindParams = deviceParams.get(algo.getAlgoCode());
|
||||
if (bindParams != null) {
|
||||
// 三级合并:paramSchema.default < globalParams < bindParams
|
||||
String merged = algorithmService.mergeEffectiveParams(
|
||||
algo.getParamSchema(), algo.getGlobalParams(), bindParams);
|
||||
algo.setGlobalParams(merged);
|
||||
}
|
||||
// 如果没有绑定参数,保留原 globalParams(全局默认)
|
||||
}
|
||||
}
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
@Operation(summary = "启用/禁用算法")
|
||||
@@ -36,4 +61,42 @@ public class AiAlgorithmController {
|
||||
public void syncFromEdge() {
|
||||
algorithmService.syncFromEdge();
|
||||
}
|
||||
|
||||
@Operation(summary = "保存算法全局参数")
|
||||
@PostMapping("/global-params/{algoCode}")
|
||||
public void saveGlobalParams(@PathVariable String algoCode, @RequestBody Map<String, String> body) {
|
||||
String globalParams = body.get("globalParams");
|
||||
if (globalParams == null || globalParams.isBlank()) {
|
||||
throw new IllegalArgumentException("参数不能为空");
|
||||
}
|
||||
try {
|
||||
JSON.parseObject(globalParams);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("参数不是合法的 JSON: " + e.getMessage());
|
||||
}
|
||||
algorithmService.saveGlobalParams(algoCode, globalParams);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询设备下所有ROI算法绑定")
|
||||
@GetMapping("/device-binds/{deviceId}")
|
||||
public List<AiRoiAlgoBind> queryDeviceBinds(@PathVariable String deviceId) {
|
||||
return algorithmService.queryBindsByDevice(deviceId);
|
||||
}
|
||||
|
||||
@Operation(summary = "批量更新设备下某算法的参数")
|
||||
@PostMapping("/device-binds/{deviceId}/{algoCode}")
|
||||
public void updateDeviceAlgoParams(@PathVariable String deviceId,
|
||||
@PathVariable String algoCode,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String params = body.get("params");
|
||||
if (params == null || params.isBlank()) {
|
||||
throw new IllegalArgumentException("参数不能为空");
|
||||
}
|
||||
try {
|
||||
JSON.parseObject(params);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("参数不是合法的 JSON: " + e.getMessage());
|
||||
}
|
||||
algorithmService.updateDeviceAlgoParams(deviceId, algoCode, params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ public interface AiAlgorithmMapper {
|
||||
@Update("UPDATE wvp_ai_algorithm SET is_active=#{isActive}, update_time=#{updateTime} WHERE id=#{id}")
|
||||
int updateActive(@Param("id") Integer id, @Param("isActive") Integer isActive, @Param("updateTime") String updateTime);
|
||||
|
||||
@Update("UPDATE wvp_ai_algorithm SET global_params = #{globalParams}, update_time = #{updateTime} WHERE algo_code = #{algoCode}")
|
||||
int updateGlobalParams(@Param("algoCode") String algoCode, @Param("globalParams") String globalParams, @Param("updateTime") String updateTime);
|
||||
|
||||
@Delete("DELETE FROM wvp_ai_algorithm WHERE id=#{id}")
|
||||
int delete(@Param("id") Integer id);
|
||||
}
|
||||
|
||||
@@ -45,4 +45,18 @@ public interface AiRoiAlgoBindMapper {
|
||||
|
||||
@Select("SELECT * FROM wvp_ai_roi_algo_bind ORDER BY priority DESC, id")
|
||||
List<AiRoiAlgoBind> queryAll();
|
||||
|
||||
@Select("SELECT b.*, r.device_id, r.name AS roi_name FROM wvp_ai_roi_algo_bind b " +
|
||||
"INNER JOIN wvp_ai_roi r ON b.roi_id = r.roi_id " +
|
||||
"WHERE r.device_id = #{deviceId} ORDER BY b.priority DESC, b.id")
|
||||
List<AiRoiAlgoBind> queryByDeviceId(@Param("deviceId") String deviceId);
|
||||
|
||||
@Update("UPDATE wvp_ai_roi_algo_bind b " +
|
||||
"INNER JOIN wvp_ai_roi r ON b.roi_id = r.roi_id " +
|
||||
"SET b.params = #{params}, b.update_time = #{updateTime} " +
|
||||
"WHERE r.device_id = #{deviceId} AND b.algo_code = #{algoCode}")
|
||||
int updateParamsByDeviceAndAlgo(@Param("deviceId") String deviceId,
|
||||
@Param("algoCode") String algoCode,
|
||||
@Param("params") String params,
|
||||
@Param("updateTime") String updateTime);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.genersoft.iot.vmp.aiot.service;
|
||||
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiAlgorithm;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -13,4 +14,16 @@ public interface IAiAlgorithmService {
|
||||
void toggleActive(Integer id, Integer isActive);
|
||||
|
||||
void syncFromEdge();
|
||||
|
||||
void saveGlobalParams(String algoCode, String globalParams);
|
||||
|
||||
List<AiRoiAlgoBind> queryBindsByDevice(String deviceId);
|
||||
|
||||
void updateDeviceAlgoParams(String deviceId, String algoCode, String params);
|
||||
|
||||
/**
|
||||
* 三级参数合并:paramSchema.default < globalParams < bindParams
|
||||
* 用于 /list 接口按设备覆盖参数时,保留完整的参数层级
|
||||
*/
|
||||
String mergeEffectiveParams(String paramSchema, String globalParams, String bindParams);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.genersoft.iot.vmp.aiot.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiAlgorithm;
|
||||
import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind;
|
||||
import com.genersoft.iot.vmp.aiot.config.AiServiceConfig;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiAlgorithmMapper;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiAlgorithmService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiConfigLogService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiConfigService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -14,6 +19,7 @@ import org.springframework.web.client.RestTemplate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -24,12 +30,21 @@ public class AiAlgorithmServiceImpl implements IAiAlgorithmService {
|
||||
@Autowired
|
||||
private AiAlgorithmMapper algorithmMapper;
|
||||
|
||||
@Autowired
|
||||
private AiRoiAlgoBindMapper roiAlgoBindMapper;
|
||||
|
||||
@Autowired
|
||||
private AiServiceConfig aiServiceConfig;
|
||||
|
||||
@Autowired
|
||||
private IAiConfigLogService configLogService;
|
||||
|
||||
@Autowired
|
||||
private IAiConfigService configService;
|
||||
|
||||
@Autowired
|
||||
private IAiRedisConfigService redisConfigService;
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/**
|
||||
@@ -55,6 +70,10 @@ public class AiAlgorithmServiceImpl implements IAiAlgorithmService {
|
||||
"车辆拥堵检测", "car,truck,bus,motorcycle", "检测区域内车辆是否拥堵。当平均车辆数达到阈值并持续60秒触发告警,车辆减少并持续120秒后自动结束告警。",
|
||||
"{\"count_threshold\":{\"type\":\"int\",\"default\":3,\"min\":1},\"confirm_congestion_sec\":{\"type\":\"int\",\"default\":60,\"min\":10},\"confirm_clear_sec\":{\"type\":\"int\",\"default\":120,\"min\":10},\"cooldown_sec\":{\"type\":\"int\",\"default\":600,\"min\":0}}"
|
||||
});
|
||||
PRESET_ALGORITHMS.put("non_motor_vehicle_parking", new String[]{
|
||||
"非机动车违停检测", "bicycle,motorcycle", "检测禁停区域内是否有非机动车(自行车、电动车)违规停放。确认非机动车停留10秒后开始3分钟倒计时,超时触发告警。非机动车离开60秒后自动结束告警。",
|
||||
"{\"confirm_vehicle_sec\":{\"type\":\"int\",\"default\":10,\"min\":5},\"parking_countdown_sec\":{\"type\":\"int\",\"default\":180,\"min\":60},\"confirm_clear_sec\":{\"type\":\"int\",\"default\":60,\"min\":10},\"cooldown_sec\":{\"type\":\"int\",\"default\":900,\"min\":0}}"
|
||||
});
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -153,4 +172,86 @@ public class AiAlgorithmServiceImpl implements IAiAlgorithmService {
|
||||
throw new RuntimeException("同步失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveGlobalParams(String algoCode, String globalParams) {
|
||||
String now = LocalDateTime.now().format(FORMATTER);
|
||||
algorithmMapper.updateGlobalParams(algoCode, globalParams, now);
|
||||
log.info("[AI算法] 保存全局参数: algoCode={}, globalParams={}", algoCode, globalParams);
|
||||
|
||||
// 全局参数变更会影响所有使用该算法的设备,因此需要全量推送配置到边缘端
|
||||
// 这是合理的全量推送场景:无法预知哪些设备绑定了该算法(需要跨表查询 bind→roi→device),
|
||||
// 且全局参数变更频率低,全量推送的开销可接受
|
||||
try {
|
||||
configService.pushAllConfig();
|
||||
log.info("[AI算法] 全局参数变更已推送到边缘端");
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI算法] 全局参数推送失败(参数已保存): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AiRoiAlgoBind> queryBindsByDevice(String deviceId) {
|
||||
return roiAlgoBindMapper.queryByDeviceId(deviceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDeviceAlgoParams(String deviceId, String algoCode, String params) {
|
||||
String now = LocalDateTime.now().format(FORMATTER);
|
||||
int updated = roiAlgoBindMapper.updateParamsByDeviceAndAlgo(deviceId, algoCode, params, now);
|
||||
log.info("[AI算法] 批量更新设备算法参数: deviceId={}, algoCode={}, 影响行数={}", deviceId, algoCode, updated);
|
||||
|
||||
// 更新后推送该设备配置到边缘端
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AI算法] 设备参数变更已推送到边缘端: deviceId={}", deviceId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI算法] 设备参数推送失败(参数已保存): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public String mergeEffectiveParams(String paramSchema, String globalParams, String bindParams) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
|
||||
// 1. 从 paramSchema 提取 default 值(最低优先级)
|
||||
if (paramSchema != null && !paramSchema.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> schema = JSON.parseObject(paramSchema, LinkedHashMap.class);
|
||||
for (Map.Entry<String, Object> entry : schema.entrySet()) {
|
||||
if (entry.getValue() instanceof Map) {
|
||||
Map<String, Object> fieldDef = (Map<String, Object>) entry.getValue();
|
||||
if (fieldDef.containsKey("default")) {
|
||||
merged.put(entry.getKey(), fieldDef.get("default"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[AI算法] 解析 paramSchema 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 用 globalParams 覆盖
|
||||
if (globalParams != null && !globalParams.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> global = JSON.parseObject(globalParams, LinkedHashMap.class);
|
||||
merged.putAll(global);
|
||||
} catch (Exception e) {
|
||||
log.debug("[AI算法] 解析 globalParams 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用 bindParams 覆盖(最高优先级)
|
||||
if (bindParams != null && !bindParams.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> bind = JSON.parseObject(bindParams, LinkedHashMap.class);
|
||||
merged.putAll(bind);
|
||||
} catch (Exception e) {
|
||||
log.debug("[AI算法] 解析 bindParams 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.toJSONString(merged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,10 +225,27 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
ids.add(cameraId);
|
||||
redisConfigService.publishConfigUpdate("full", ids);
|
||||
|
||||
// 5. 写入设备聚合配置 + Stream 通知(新格式,对接 Edge config_sync)
|
||||
String deviceId = roiMapper.queryDeviceIdByCameraId(cameraId);
|
||||
// 5. 从摄像头配置获取正确的 edge_device_id,修正 ROI 表中的 device_id
|
||||
String correctDeviceId = streamProxyMapper.selectEdgeDeviceIdByCameraCode(cameraId);
|
||||
if (correctDeviceId != null && !correctDeviceId.isEmpty()) {
|
||||
List<AiRoi> rois = roiMapper.queryByCameraId(cameraId);
|
||||
for (AiRoi roi : rois) {
|
||||
if (!correctDeviceId.equals(roi.getDeviceId())) {
|
||||
String oldDeviceId = roi.getDeviceId();
|
||||
roi.setDeviceId(correctDeviceId);
|
||||
roiMapper.update(roi);
|
||||
log.info("[AiConfig] 修正 ROI device_id: roi={}, {} → {}",
|
||||
roi.getRoiId(), oldDeviceId, correctDeviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 写入设备聚合配置 + Stream 通知(新格式,对接 Edge config_sync)
|
||||
String deviceId = correctDeviceId;
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
deviceId = roiMapper.queryDeviceIdByCameraId(cameraId);
|
||||
}
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
// 回退:从 edge 设备表获取默认设备
|
||||
deviceId = getDefaultDeviceId();
|
||||
log.info("[AiConfig] 摄像头 {} 未关联设备,使用默认设备: {}", cameraId, deviceId);
|
||||
}
|
||||
@@ -240,10 +257,10 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
log.warn("[AiConfig] 无法确定设备ID,跳过 Redis 聚合配置推送。请先注册边缘设备或为摄像头关联 device_id");
|
||||
}
|
||||
|
||||
// 6. 本地调试:同步到 Edge HTTP 接口(保留原 Redis 流程)
|
||||
// 7. 本地调试:同步到 Edge HTTP 接口(保留原 Redis 流程)
|
||||
boolean httpSyncOk = pushConfigToLocalEdge(cameraId, config);
|
||||
|
||||
// 7. 返回推送结果
|
||||
// 8. 返回推送结果
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("camera_id", cameraId);
|
||||
result.put("version", snapshot.getVersion());
|
||||
@@ -365,6 +382,7 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
cameraName = cameraId;
|
||||
}
|
||||
camOut.put("camera_name", cameraName);
|
||||
camOut.put("area_id", proxy.getAreaId());
|
||||
cameraList.add(camOut);
|
||||
validCameraIds.add(cameraId);
|
||||
log.debug("[AiConfig] 添加摄像头: cameraCode={}, srcUrl={}", cameraId, proxy.getSrcUrl());
|
||||
|
||||
@@ -279,20 +279,17 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
String cameraName = "";
|
||||
String rtspUrl = "";
|
||||
|
||||
// 从ROI关联的通道获取名称
|
||||
if (!rois.isEmpty()) {
|
||||
AiRoi firstRoi = rois.get(0);
|
||||
if (firstRoi.getChannelDbId() != null) {
|
||||
try {
|
||||
CommonGBChannel channel = channelMapper.queryById(firstRoi.getChannelDbId());
|
||||
if (channel != null) {
|
||||
cameraName = channel.getGbName() != null ? channel.getGbName() : "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AiRedis] 查询通道信息失败: channelDbId={}", firstRoi.getChannelDbId());
|
||||
}
|
||||
// 优先从 StreamProxy 获取 cameraName(用户自定义名称)
|
||||
StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraId);
|
||||
if (proxy != null) {
|
||||
cameraName = proxy.getCameraName();
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
cameraName = proxy.getGbName();
|
||||
}
|
||||
}
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
cameraName = cameraId;
|
||||
}
|
||||
|
||||
// 构建RTSP代理地址(通过ZLM媒体服务器)
|
||||
// cameraId格式为 {app}/{stream},ZLM的RTSP路径直接使用该格式
|
||||
@@ -479,7 +476,7 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
|
||||
/**
|
||||
* 构建扁平格式配置 JSON(Edge 期望的格式)
|
||||
* 输出: {cameras: [...], rois: [...], binds: [...]}
|
||||
* 输出: {cameras: [...], rois: [...], binds: [...], global_params: {...}}
|
||||
*/
|
||||
private Map<String, Object> buildFlatConfig(String deviceId, List<String> cameraIds) {
|
||||
List<Map<String, Object>> cameras = new ArrayList<>();
|
||||
@@ -488,6 +485,13 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
|
||||
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
// 查询所有算法,构建 algo_code -> AiAlgorithm 索引
|
||||
List<AiAlgorithm> allAlgorithms = algorithmMapper.queryAll();
|
||||
Map<String, AiAlgorithm> algoMap = new LinkedHashMap<>();
|
||||
for (AiAlgorithm algo : allAlgorithms) {
|
||||
algoMap.put(algo.getAlgoCode(), algo);
|
||||
}
|
||||
|
||||
for (String cameraId : cameraIds) {
|
||||
// 摄像头信息
|
||||
Map<String, Object> cameraMap = new LinkedHashMap<>();
|
||||
@@ -504,23 +508,15 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
cameraMap.put("camera_code", cameraId);
|
||||
cameraMap.put("camera_id", cameraId);
|
||||
|
||||
// 获取摄像头名称
|
||||
String cameraName = "";
|
||||
List<AiRoi> cameraRois = roiMapper.queryAllByCameraId(cameraId);
|
||||
|
||||
if (!cameraRois.isEmpty()) {
|
||||
AiRoi firstRoi = cameraRois.get(0);
|
||||
if (firstRoi.getChannelDbId() != null) {
|
||||
try {
|
||||
CommonGBChannel channel = channelMapper.queryById(firstRoi.getChannelDbId());
|
||||
if (channel != null) {
|
||||
cameraName = channel.getGbName() != null ? channel.getGbName() : "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AiRedis] 查询通道信息失败: channelDbId={}", firstRoi.getChannelDbId());
|
||||
}
|
||||
}
|
||||
// 获取摄像头名称:优先 cameraName(用户自定义),其次 gbName,最后 cameraId
|
||||
String cameraName = proxy.getCameraName();
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
cameraName = proxy.getGbName();
|
||||
}
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
cameraName = cameraId;
|
||||
}
|
||||
List<AiRoi> cameraRois = roiMapper.queryAllByCameraId(cameraId);
|
||||
|
||||
// 获取 RTSP URL:优先使用 StreamProxy 的源 URL
|
||||
String rtspUrl = "";
|
||||
@@ -611,10 +607,14 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
bindMap.put("enabled", bind.getEnabled() != null && bind.getEnabled() == 1);
|
||||
bindMap.put("priority", bind.getPriority() != null ? bind.getPriority() : 0);
|
||||
|
||||
// params: 解析为标准 JSON 对象(非 Python eval 字符串)
|
||||
// params: 三级合并 param_schema.default < global_params < bind.params
|
||||
AiAlgorithm algo = algoMap.get(bind.getAlgoCode());
|
||||
String algoParamSchema = algo != null ? algo.getParamSchema() : null;
|
||||
String algoGlobalParams = algo != null ? algo.getGlobalParams() : null;
|
||||
String effectiveParams = resolveEffectiveParams(bind);
|
||||
String mergedParams = mergeParams(algoParamSchema, algoGlobalParams, effectiveParams);
|
||||
try {
|
||||
bindMap.put("params", objectMapper.readValue(effectiveParams, Object.class));
|
||||
bindMap.put("params", objectMapper.readValue(mergedParams, Object.class));
|
||||
} catch (Exception e) {
|
||||
bindMap.put("params", new LinkedHashMap<>());
|
||||
}
|
||||
@@ -627,6 +627,73 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
flatConfig.put("cameras", cameras);
|
||||
flatConfig.put("rois", rois);
|
||||
flatConfig.put("binds", binds);
|
||||
|
||||
// 顶层新增 global_params: {algo_code: {param_key: value, ...}, ...}
|
||||
Map<String, Object> globalParamsMap = new LinkedHashMap<>();
|
||||
for (AiAlgorithm algo : allAlgorithms) {
|
||||
if (algo.getGlobalParams() != null && !algo.getGlobalParams().isEmpty()) {
|
||||
try {
|
||||
globalParamsMap.put(algo.getAlgoCode(), objectMapper.readValue(algo.getGlobalParams(), Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[AiRedis] 解析算法 {} 的 globalParams 失败: {}", algo.getAlgoCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!globalParamsMap.isEmpty()) {
|
||||
flatConfig.put("global_params", globalParamsMap);
|
||||
}
|
||||
|
||||
return flatConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三级参数合并:param_schema 的 default 值 < global_params < bind 的 params
|
||||
* @param paramSchema 算法参数模板JSON(含 default 字段)
|
||||
* @param globalParams 用户自定义的全局默认参数JSON
|
||||
* @param bindParams 绑定级别的参数JSON(最高优先级)
|
||||
* @return 合并后的参数JSON字符串
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private String mergeParams(String paramSchema, String globalParams, String bindParams) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
|
||||
// 1. 从 paramSchema 提取 default 值(最低优先级)
|
||||
if (paramSchema != null && !paramSchema.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> schema = JSON.parseObject(paramSchema, LinkedHashMap.class);
|
||||
for (Map.Entry<String, Object> entry : schema.entrySet()) {
|
||||
if (entry.getValue() instanceof Map) {
|
||||
Map<String, Object> fieldDef = (Map<String, Object>) entry.getValue();
|
||||
if (fieldDef.containsKey("default")) {
|
||||
merged.put(entry.getKey(), fieldDef.get("default"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[AiRedis] 解析 paramSchema 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 用 globalParams 覆盖
|
||||
if (globalParams != null && !globalParams.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> global = JSON.parseObject(globalParams, LinkedHashMap.class);
|
||||
merged.putAll(global);
|
||||
} catch (Exception e) {
|
||||
log.debug("[AiRedis] 解析 globalParams 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 用 bindParams 覆盖(最高优先级)
|
||||
if (bindParams != null && !bindParams.isEmpty()) {
|
||||
try {
|
||||
Map<String, Object> bind = JSON.parseObject(bindParams, LinkedHashMap.class);
|
||||
merged.putAll(bind);
|
||||
} catch (Exception e) {
|
||||
log.debug("[AiRedis] 解析 bindParams 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.toJSONString(merged);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ package com.genersoft.iot.vmp.aiot.service.impl;
|
||||
|
||||
import com.genersoft.iot.vmp.aiot.bean.*;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiAlgorithmMapper;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiEdgeDeviceMapper;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiConfigLogService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiRoiService;
|
||||
import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -36,14 +35,11 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
private AiAlgorithmMapper algorithmMapper;
|
||||
|
||||
@Autowired
|
||||
private AiEdgeDeviceMapper edgeDeviceMapper;
|
||||
private StreamProxyMapper streamProxyMapper;
|
||||
|
||||
@Autowired
|
||||
private IAiConfigLogService configLogService;
|
||||
|
||||
@Autowired
|
||||
private IAiRedisConfigService redisConfigService;
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
@@ -82,42 +78,27 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
roiMapper.add(roi);
|
||||
configLogService.addLog("ROI", roi.getRoiId(), null, toJson(roi), null);
|
||||
}
|
||||
|
||||
// 推送配置到 Edge(新增/更新操作)
|
||||
String deviceId = roi.getDeviceId();
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AiRoi] {}ROI后推送配置到Edge,camera_id={}, device_id={}",
|
||||
isUpdate ? "更新" : "新增", cameraId, deviceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[AiRoi] {}ROI后推送配置失败,camera_id={}, device_id={}",
|
||||
isUpdate ? "更新" : "新增", cameraId, deviceId, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("[AiRoi] {}ROI但device_id为空,跳过推送配置,camera_id={}",
|
||||
isUpdate ? "更新" : "新增", cameraId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 deviceId:
|
||||
* 1. 优先从同摄像头的已有 ROI 继承
|
||||
* 2. 否则取系统中唯一的已注册边缘设备(单边缘场景)
|
||||
* 1. 优先从摄像头配置继承 edge_device_id(stream_proxy 表)
|
||||
* 2. 兼容:从同摄像头已有 ROI 继承
|
||||
* 3. 不再硬编码默认值,返回 null
|
||||
*/
|
||||
private String resolveDeviceId(String cameraId) {
|
||||
// 从同摄像头已有 ROI 继承
|
||||
String deviceId = roiMapper.queryDeviceIdByCameraId(cameraId);
|
||||
// 1. 优先从摄像头配置继承 edge_device_id
|
||||
String deviceId = streamProxyMapper.selectEdgeDeviceIdByCameraCode(cameraId);
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
return deviceId;
|
||||
}
|
||||
// 取系统中第一个已注册的边缘设备
|
||||
List<AiEdgeDevice> devices = edgeDeviceMapper.queryAll();
|
||||
if (devices != null && !devices.isEmpty()) {
|
||||
log.info("[AiRoi] 使用默认边缘设备: {}", devices.get(0).getDeviceId());
|
||||
return devices.get(0).getDeviceId();
|
||||
// 2. 兼容:从同摄像头已有 ROI 继承
|
||||
deviceId = roiMapper.queryDeviceIdByCameraId(cameraId);
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
return deviceId;
|
||||
}
|
||||
log.warn("[AiRoi] 无已注册边缘设备,deviceId 为空");
|
||||
// 3. 不再硬编码默认值,返回 null
|
||||
log.warn("[AiRoi] 无法解析 deviceId,cameraId={}", cameraId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,17 +112,6 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
bindMapper.deleteByRoiId(roiId);
|
||||
roiMapper.deleteByRoiId(roiId);
|
||||
configLogService.addLog("ROI", roiId, toJson(old), null, null);
|
||||
// 推送配置到 Edge(删除操作)
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AiRoi] 删除ROI后推送配置到Edge,camera_id={}, device_id={}", cameraId, deviceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[AiRoi] 删除ROI后推送配置失败,camera_id={}, device_id={}", cameraId, deviceId, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("[AiRoi] 删除ROI但device_id为空,跳过推送配置,camera_id={}", cameraId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,27 +170,6 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
bind.setUpdateTime(now);
|
||||
bindMapper.add(bind);
|
||||
configLogService.addLog("BIND", bind.getBindId(), null, toJson(bind), null);
|
||||
|
||||
// 推送配置到 Edge(绑定算法)
|
||||
String roiId = bind.getRoiId();
|
||||
if (roiId != null) {
|
||||
AiRoi roi = roiMapper.queryByRoiId(roiId);
|
||||
if (roi != null) {
|
||||
String deviceId = roi.getDeviceId();
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AiRoi] 绑定算法后推送配置到Edge,camera_id={}, device_id={}, algo={}",
|
||||
roi.getCameraId(), deviceId, bind.getAlgoCode());
|
||||
} catch (Exception e) {
|
||||
log.error("[AiRoi] 绑定算法后推送配置失败,camera_id={}, device_id={}",
|
||||
roi.getCameraId(), deviceId, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("[AiRoi] 绑定算法但device_id为空,跳过推送配置,camera_id={}", roi.getCameraId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -231,25 +180,6 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
String roiId = old.getRoiId();
|
||||
bindMapper.deleteByBindId(bindId);
|
||||
configLogService.addLog("BIND", bindId, toJson(old), null, null);
|
||||
// 推送配置到 Edge(解绑算法)
|
||||
if (roiId != null) {
|
||||
AiRoi roi = roiMapper.queryByRoiId(roiId);
|
||||
if (roi != null) {
|
||||
String deviceId = roi.getDeviceId();
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AiRoi] 解绑算法后推送配置到Edge,camera_id={}, device_id={}",
|
||||
roi.getCameraId(), deviceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[AiRoi] 解绑算法后推送配置失败,camera_id={}, device_id={}",
|
||||
roi.getCameraId(), deviceId, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("[AiRoi] 解绑算法但device_id为空,跳过推送配置,camera_id={}", roi.getCameraId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,27 +200,6 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
bind.setUpdateTime(now);
|
||||
bindMapper.updateByBindId(bind);
|
||||
configLogService.addLog("BIND", bind.getBindId(), toJson(old), toJson(bind), null);
|
||||
|
||||
// 推送配置到 Edge(更新算法参数)
|
||||
String roiId = old.getRoiId();
|
||||
if (roiId != null) {
|
||||
AiRoi roi = roiMapper.queryByRoiId(roiId);
|
||||
if (roi != null) {
|
||||
String deviceId = roi.getDeviceId();
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
try {
|
||||
redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE");
|
||||
log.info("[AiRoi] 更新算法参数后推送配置到Edge,camera_id={}, device_id={}, bind_id={}",
|
||||
roi.getCameraId(), deviceId, bind.getBindId());
|
||||
} catch (Exception e) {
|
||||
log.error("[AiRoi] 更新算法参数后推送配置失败,camera_id={}, device_id={}",
|
||||
roi.getCameraId(), deviceId, e);
|
||||
}
|
||||
} else {
|
||||
log.warn("[AiRoi] 更新算法参数但device_id为空,跳过推送配置,camera_id={}", roi.getCameraId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(Object obj) {
|
||||
|
||||
@@ -131,6 +131,13 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
if (rtspUrl != null && !rtspUrl.isEmpty()) {
|
||||
fields.put("rtsp_url", rtspUrl);
|
||||
}
|
||||
// 多 Edge 设备隔离:优先用 ROI 表的 device_id,没有则用 stream_proxy 的 edge_device_id
|
||||
if (!fields.containsKey("device_id")) {
|
||||
String edgeDeviceId = proxy.getEdgeDeviceId();
|
||||
if (edgeDeviceId != null && !edgeDeviceId.isEmpty()) {
|
||||
fields.put("device_id", edgeDeviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -109,6 +109,7 @@ public class WebSecurityConfig {
|
||||
defaultExcludes.add("/api/ai/alert/image");
|
||||
defaultExcludes.add("/api/ai/device/edge/**");
|
||||
defaultExcludes.add("/api/ai/device/heartbeat");
|
||||
defaultExcludes.add("/api/ai/algorithm/**");
|
||||
|
||||
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {
|
||||
defaultExcludes.addAll(userSetting.getInterfaceAuthenticationExcludes());
|
||||
|
||||
@@ -78,6 +78,9 @@ public class StreamProxy extends CommonGBChannel {
|
||||
@Schema(description = "所属区域ID")
|
||||
private Long areaId;
|
||||
|
||||
@Schema(description = "绑定的边缘设备ID(如 edge、edge_002)")
|
||||
private String edgeDeviceId;
|
||||
|
||||
public CommonGBChannel buildCommonGBChannel() {
|
||||
if (ObjectUtils.isEmpty(this.getGbDeviceId())) {
|
||||
return null;
|
||||
|
||||
@@ -13,10 +13,10 @@ public interface StreamProxyMapper {
|
||||
|
||||
@Insert("INSERT INTO wvp_stream_proxy (type, app, stream,relates_media_server_id, src_url, " +
|
||||
"timeout, ffmpeg_cmd_key, rtsp_type, enable_audio, enable_mp4, enable, pulling, " +
|
||||
"enable_disable_none_reader, server_id, create_time, camera_code, camera_name, area_id) VALUES" +
|
||||
"enable_disable_none_reader, server_id, create_time, camera_code, camera_name, area_id, edge_device_id) VALUES" +
|
||||
"(#{type}, #{app}, #{stream}, #{relatesMediaServerId}, #{srcUrl}, " +
|
||||
"#{timeout}, #{ffmpegCmdKey}, #{rtspType}, #{enableAudio}, #{enableMp4}, #{enable}, #{pulling}, " +
|
||||
"#{enableDisableNoneReader}, #{serverId}, #{createTime}, #{cameraCode}, #{cameraName}, #{areaId} )")
|
||||
"#{enableDisableNoneReader}, #{serverId}, #{createTime}, #{cameraCode}, #{cameraName}, #{areaId}, #{edgeDeviceId} )")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int add(StreamProxy streamProxyDto);
|
||||
|
||||
@@ -35,7 +35,8 @@ public interface StreamProxyMapper {
|
||||
"enable_disable_none_reader=#{enableDisableNoneReader}, " +
|
||||
"enable_mp4=#{enableMp4}, " +
|
||||
"camera_name=#{cameraName}, " +
|
||||
"area_id=#{areaId} " +
|
||||
"area_id=#{areaId}, " +
|
||||
"edge_device_id=#{edgeDeviceId} " +
|
||||
"WHERE id=#{id}")
|
||||
int update(StreamProxy streamProxyDto);
|
||||
|
||||
@@ -126,4 +127,10 @@ public interface StreamProxyMapper {
|
||||
*/
|
||||
@Select("SELECT camera_code, camera_name FROM wvp_stream_proxy WHERE enable = 1 ORDER BY camera_name")
|
||||
List<StreamProxy> selectAllCameraOptions();
|
||||
|
||||
/**
|
||||
* 根据 camera_code 查询关联的边缘设备 ID
|
||||
*/
|
||||
@Select("SELECT edge_device_id FROM wvp_stream_proxy WHERE camera_code = #{cameraCode}")
|
||||
String selectEdgeDeviceIdByCameraCode(@Param("cameraCode") String cameraCode);
|
||||
}
|
||||
|
||||
@@ -146,15 +146,14 @@ public class StreamProxyServiceImpl implements IStreamProxyService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(StreamProxy streamProxy) {
|
||||
StreamProxy streamProxyInDb = streamProxyMapper.selectOneByAppAndStream(streamProxy.getApp(), streamProxy.getStream());
|
||||
if (streamProxyInDb != null) {
|
||||
throw new ControllerException(ErrorCode.ERROR100.getCode(), "APP+STREAM已经存在");
|
||||
}
|
||||
|
||||
// 自动生成 camera_code(日期+序号格式)
|
||||
String cameraCode = CameraCodeUtil.generate(streamProxyMapper);
|
||||
streamProxy.setCameraCode(cameraCode);
|
||||
|
||||
// app/stream 自动生成唯一值(不再依赖前端传入)
|
||||
streamProxy.setApp("camera");
|
||||
streamProxy.setStream(cameraCode);
|
||||
|
||||
// camera_name 必填校验
|
||||
if (streamProxy.getCameraName() == null || streamProxy.getCameraName().trim().isEmpty()) {
|
||||
throw new ControllerException(ErrorCode.ERROR100.getCode(), "摄像头名称不能为空");
|
||||
|
||||
@@ -3,18 +3,27 @@
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button size="small" icon="el-icon-back" @click="goBack">返回</el-button>
|
||||
<h3>{{ cameraId }} - ROI配置</h3>
|
||||
<h3>{{ cameraName || cameraId }} - ROI配置</h3>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('rectangle')">画矩形</el-button>
|
||||
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('polygon')">画多边形</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" :loading="snapLoading" @click="refreshSnap">刷新截图</el-button>
|
||||
<el-button size="small" type="info" @click="handlePush">推送到边缘端</el-button>
|
||||
<!-- 默认工具栏 -->
|
||||
<template v-if="!isDrawing">
|
||||
<el-button size="small" type="primary" icon="el-icon-full-screen" @click="addFullscreen">全图</el-button>
|
||||
<el-button size="small" type="primary" icon="el-icon-edit-outline" @click="startDraw('polygon')">自定义选区</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" :loading="snapLoading" @click="refreshSnap">刷新截图</el-button>
|
||||
<el-button size="small" type="info" @click="handlePush">推送到边缘端</el-button>
|
||||
</template>
|
||||
<!-- 绘制中工具栏 -->
|
||||
<template v-else>
|
||||
<el-button size="small" type="success" icon="el-icon-check" :disabled="polygonPointCount < 3" @click="finishDraw">完成选区</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh-left" :disabled="polygonPointCount === 0" @click="undoPoint">撤销上一点</el-button>
|
||||
<el-button size="small" type="danger" icon="el-icon-close" @click="cancelDraw">取消绘制</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="canvas-panel">
|
||||
<div :class="['canvas-panel', { 'panel-open': panelVisible }]">
|
||||
<RoiCanvas
|
||||
ref="roiCanvas"
|
||||
:rois="roiList"
|
||||
@@ -24,62 +33,69 @@
|
||||
@roi-drawn="onRoiDrawn"
|
||||
@roi-selected="onRoiSelected"
|
||||
@roi-deleted="onRoiDeleted"
|
||||
@draw-cancelled="onDrawCancelled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="side-panel">
|
||||
<div class="roi-list-section">
|
||||
<div class="section-header">
|
||||
<span>ROI列表 ({{ roiList.length }})</span>
|
||||
<transition name="slide-panel">
|
||||
<div v-if="panelVisible" class="side-panel">
|
||||
<div class="panel-close">
|
||||
<el-button size="mini" icon="el-icon-close" circle @click="closePanel"></el-button>
|
||||
</div>
|
||||
<div v-if="roiList.length === 0" class="empty-tip">暂无ROI,请在左侧画面上绘制</div>
|
||||
<div
|
||||
v-for="roi in roiList"
|
||||
:key="roi.roiId"
|
||||
:class="['roi-item', { active: selectedRoiId === roi.roiId }]"
|
||||
@click="selectRoi(roi)"
|
||||
>
|
||||
<div class="roi-item-header">
|
||||
<span class="roi-color" :style="{ background: roi.color || '#FF0000' }"></span>
|
||||
<span class="roi-name">{{ roi.name || '未命名' }}</span>
|
||||
<el-tag size="mini" :type="roi.roiType === 'rectangle' ? '' : 'success'">
|
||||
{{ roi.roiType === 'rectangle' ? '矩形' : '多边形' }}
|
||||
</el-tag>
|
||||
<el-switch v-model="roi.enabled" :active-value="1" :inactive-value="0" size="mini" style="margin-left: auto" @change="updateRoi(roi)"></el-switch>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" circle style="margin-left: 5px" @click.stop="deleteRoi(roi)"></el-button>
|
||||
|
||||
<div class="roi-list-section">
|
||||
<div class="section-header">
|
||||
<span>ROI列表 ({{ roiList.length }})</span>
|
||||
</div>
|
||||
<div v-if="roiList.length === 0" class="empty-tip">暂无ROI,请使用上方按钮添加</div>
|
||||
<div
|
||||
v-for="roi in roiList"
|
||||
:key="roi.roiId"
|
||||
:class="['roi-item', { active: selectedRoiId === roi.roiId }]"
|
||||
@click="selectRoi(roi)"
|
||||
>
|
||||
<div class="roi-item-header">
|
||||
<span class="roi-color" :style="{ background: roi.color || '#FF0000' }"></span>
|
||||
<span class="roi-name">{{ roi.name || '未命名' }}</span>
|
||||
<el-tag size="mini" :type="getRoiTagType(roi)">
|
||||
{{ getRoiTagLabel(roi) }}
|
||||
</el-tag>
|
||||
<el-switch v-model="roi.enabled" :active-value="1" :inactive-value="0" size="mini" style="margin-left: auto" @change="updateRoi(roi)"></el-switch>
|
||||
<el-button size="mini" type="danger" icon="el-icon-delete" circle style="margin-left: 5px" @click.stop="deleteRoi(roi)"></el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<div v-if="selectedRoi" class="roi-detail-section">
|
||||
<h4>ROI属性</h4>
|
||||
<el-form :model="selectedRoi" label-width="70px" size="mini">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="selectedRoi.name" @blur="updateRoi(selectedRoi)"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<el-color-picker v-model="selectedRoi.color" size="mini" @change="updateRoi(selectedRoi)"></el-color-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="selectedRoi.priority" :min="0" size="mini" @change="updateRoi(selectedRoi)"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="selectedRoi.description" type="textarea" :rows="2" @blur="updateRoi(selectedRoi)"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<RoiAlgorithmBind
|
||||
:roi-id="selectedRoi.roiId"
|
||||
:bindings="selectedRoiBindings"
|
||||
@changed="loadRoiDetail"
|
||||
/>
|
||||
<div v-if="selectedRoi" class="roi-detail-section">
|
||||
<h4>ROI属性</h4>
|
||||
<el-form :model="selectedRoi" label-width="70px" size="mini">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="selectedRoi.name" @blur="updateRoi(selectedRoi)"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<el-color-picker v-model="selectedRoi.color" size="mini" @change="updateRoi(selectedRoi)"></el-color-picker>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="selectedRoi.priority" :min="0" size="mini" @change="updateRoi(selectedRoi)"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="selectedRoi.description" type="textarea" :rows="2" @blur="updateRoi(selectedRoi)"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<RoiAlgorithmBind
|
||||
:roi-id="selectedRoi.roiId"
|
||||
:bindings="selectedRoiBindings"
|
||||
@changed="loadRoiDetail"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="empty-tip" style="margin-top: 20px">点击左侧ROI区域或列表项查看详情</div>
|
||||
</div>
|
||||
<div v-else class="empty-tip" style="margin-top: 20px">点击左侧ROI区域或列表项查看详情</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,28 +112,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
cameraId: '',
|
||||
cameraName: '',
|
||||
srcUrl: '',
|
||||
app: '',
|
||||
stream: '',
|
||||
drawMode: null,
|
||||
roiList: [],
|
||||
selectedRoiId: null,
|
||||
selectedRoiBindings: [],
|
||||
snapUrl: '',
|
||||
snapLoading: false
|
||||
snapLoading: false,
|
||||
panelVisible: false,
|
||||
polygonPointCount: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedRoi() {
|
||||
if (!this.selectedRoiId) return null
|
||||
return this.roiList.find(r => r.roiId === this.selectedRoiId) || null
|
||||
},
|
||||
isDrawing() {
|
||||
return this.drawMode === 'polygon'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.cameraId = decodeURIComponent(this.$route.params.cameraId)
|
||||
this.cameraName = this.$route.query.cameraName || ''
|
||||
this.srcUrl = this.$route.query.srcUrl || ''
|
||||
this.app = this.$route.query.app || ''
|
||||
this.stream = this.$route.query.stream || ''
|
||||
this.fetchSnap()
|
||||
this.loadRois()
|
||||
},
|
||||
@@ -125,6 +144,21 @@ export default {
|
||||
goBack() {
|
||||
this.$router.push('/cameraConfig')
|
||||
},
|
||||
|
||||
// ---- ROI 类型标签 ----
|
||||
getRoiTagType(roi) {
|
||||
const type = roi.roiType || roi.roi_type
|
||||
if (type === 'fullscreen') return 'warning'
|
||||
return 'success'
|
||||
},
|
||||
getRoiTagLabel(roi) {
|
||||
const type = roi.roiType || roi.roi_type
|
||||
if (type === 'fullscreen') return '全图'
|
||||
if (type === 'rectangle') return '矩形'
|
||||
return '自定义'
|
||||
},
|
||||
|
||||
// ---- 数据加载 ----
|
||||
loadRois() {
|
||||
queryRoiByCameraId(this.cameraId).then(res => {
|
||||
this.roiList = res.data || []
|
||||
@@ -144,9 +178,8 @@ export default {
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
startDraw(mode) {
|
||||
this.drawMode = mode
|
||||
},
|
||||
|
||||
// ---- 截图 ----
|
||||
fetchSnap(force = false) {
|
||||
if (!this.cameraId) return
|
||||
this.snapLoading = true
|
||||
@@ -161,39 +194,121 @@ export default {
|
||||
refreshSnap() {
|
||||
this.fetchSnap(true)
|
||||
},
|
||||
|
||||
// ---- 全图 ----
|
||||
addFullscreen() {
|
||||
// 检查是否已有全图 ROI
|
||||
const hasFullscreen = this.roiList.some(r => (r.roiType || r.roi_type) === 'fullscreen')
|
||||
if (hasFullscreen) {
|
||||
this.$message.warning('已存在全图选区')
|
||||
return
|
||||
}
|
||||
this.$prompt('请输入选区名称', '新建全图选区', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: '全图-' + (this.roiList.length + 1)
|
||||
}).then(({ value }) => {
|
||||
const newRoi = {
|
||||
cameraId: this.cameraId,
|
||||
name: value || '全图',
|
||||
roiType: 'fullscreen',
|
||||
coordinates: JSON.stringify({ x: 0, y: 0, w: 1, h: 1 }),
|
||||
color: '#FF0000',
|
||||
priority: 0,
|
||||
enabled: 1,
|
||||
description: ''
|
||||
}
|
||||
saveRoi(newRoi).then(() => {
|
||||
this.$message.success('全图选区已创建')
|
||||
this.loadRois()
|
||||
}).catch(() => {
|
||||
this.$message.error('保存失败')
|
||||
})
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
// ---- 自定义选区(多边形绘制) ----
|
||||
startDraw(mode) {
|
||||
this.drawMode = mode
|
||||
this.polygonPointCount = 0
|
||||
},
|
||||
finishDraw() {
|
||||
// 通过 ref 调用 Canvas 的完成方法
|
||||
if (this.$refs.roiCanvas) {
|
||||
this.$refs.roiCanvas.finishPolygon()
|
||||
}
|
||||
},
|
||||
undoPoint() {
|
||||
if (this.$refs.roiCanvas && this.$refs.roiCanvas.polygonPoints.length > 0) {
|
||||
this.$refs.roiCanvas.polygonPoints.pop()
|
||||
this.polygonPointCount = this.$refs.roiCanvas.polygonPoints.length
|
||||
this.$refs.roiCanvas.redraw()
|
||||
if (this.$refs.roiCanvas.polygonPoints.length > 0) {
|
||||
this.$refs.roiCanvas.drawPolygonInProgress()
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelDraw() {
|
||||
this.drawMode = null
|
||||
this.polygonPointCount = 0
|
||||
},
|
||||
onDrawCancelled() {
|
||||
this.drawMode = null
|
||||
this.polygonPointCount = 0
|
||||
},
|
||||
|
||||
onRoiDrawn(data) {
|
||||
this.drawMode = null
|
||||
const newRoi = {
|
||||
cameraId: this.cameraId,
|
||||
name: 'ROI-' + (this.roiList.length + 1),
|
||||
roiType: data.roi_type,
|
||||
coordinates: data.coordinates,
|
||||
color: '#FF0000',
|
||||
priority: 0,
|
||||
enabled: 1,
|
||||
description: ''
|
||||
}
|
||||
saveRoi(newRoi).then(() => {
|
||||
this.$message.success('ROI已保存')
|
||||
this.loadRois()
|
||||
this.polygonPointCount = 0
|
||||
this.$prompt('请输入选区名称', '新建自定义选区', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: 'ROI-' + (this.roiList.length + 1)
|
||||
}).then(({ value }) => {
|
||||
const newRoi = {
|
||||
cameraId: this.cameraId,
|
||||
name: value || 'ROI-' + (this.roiList.length + 1),
|
||||
roiType: data.roi_type,
|
||||
coordinates: data.coordinates,
|
||||
color: '#FF0000',
|
||||
priority: 0,
|
||||
enabled: 1,
|
||||
description: ''
|
||||
}
|
||||
saveRoi(newRoi).then(() => {
|
||||
this.$message.success('选区已保存')
|
||||
this.loadRois()
|
||||
}).catch(() => {
|
||||
this.$message.error('保存失败')
|
||||
})
|
||||
}).catch(() => {
|
||||
this.$message.error('保存失败')
|
||||
// 用户取消命名,不保存
|
||||
})
|
||||
},
|
||||
|
||||
// ---- ROI 选中与面板 ----
|
||||
onRoiSelected(roiId) {
|
||||
this.selectedRoiId = roiId
|
||||
if (roiId) {
|
||||
this.panelVisible = true
|
||||
this.loadRoiDetail()
|
||||
} else {
|
||||
this.selectedRoiBindings = []
|
||||
}
|
||||
},
|
||||
selectRoi(roi) {
|
||||
this.selectedRoiId = roi.roiId
|
||||
this.loadRoiDetail()
|
||||
},
|
||||
closePanel() {
|
||||
this.panelVisible = false
|
||||
this.selectedRoiId = null
|
||||
this.selectedRoiBindings = []
|
||||
},
|
||||
|
||||
// ---- ROI 删除 ----
|
||||
onRoiDeleted(roiId) {
|
||||
this.doDeleteRoi(roiId)
|
||||
this.$confirm('确定删除该ROI?关联的算法绑定也将删除。', '提示', { type: 'warning' }).then(() => {
|
||||
this.doDeleteRoi(roiId)
|
||||
}).catch(() => {})
|
||||
},
|
||||
deleteRoi(roi) {
|
||||
this.$confirm('确定删除该ROI?关联的算法绑定也将删除。', '提示', { type: 'warning' }).then(() => {
|
||||
@@ -208,10 +323,18 @@ export default {
|
||||
this.selectedRoiBindings = []
|
||||
}
|
||||
this.loadRois()
|
||||
// 删除后无 ROI 时自动收起面板
|
||||
this.$nextTick(() => {
|
||||
if (this.roiList.length === 0) {
|
||||
this.panelVisible = false
|
||||
}
|
||||
})
|
||||
}).catch(() => {
|
||||
this.$message.error('删除失败')
|
||||
})
|
||||
},
|
||||
|
||||
// ---- ROI 更新 ----
|
||||
updateRoi(roi) {
|
||||
saveRoi(roi).then(() => {
|
||||
this.loadRois()
|
||||
@@ -219,6 +342,8 @@ export default {
|
||||
this.$message.error('更新失败')
|
||||
})
|
||||
},
|
||||
|
||||
// ---- 推送配置 ----
|
||||
handlePush() {
|
||||
this.$confirm('确定将此摄像头的所有ROI配置推送到边缘端?', '推送配置', { type: 'info' }).then(() => {
|
||||
pushConfig(this.cameraId).then(() => {
|
||||
@@ -228,6 +353,12 @@ export default {
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
},
|
||||
// 监听 Canvas 中的顶点数量变化(用于工具栏按钮禁用状态)
|
||||
updated() {
|
||||
if (this.isDrawing && this.$refs.roiCanvas) {
|
||||
this.polygonPointCount = this.$refs.roiCanvas.polygonPoints.length
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -236,11 +367,47 @@ export default {
|
||||
.roi-config-page { padding: 15px; height: calc(100vh - 90px); display: flex; flex-direction: column; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.header-left h3 { margin: 0; }
|
||||
.header-left h3 { margin: 0; font-size: 15px; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { display: flex; flex: 1; gap: 15px; overflow: hidden; }
|
||||
.canvas-panel { flex: 6; background: #000; border-radius: 4px; overflow: hidden; }
|
||||
.side-panel { flex: 4; overflow-y: auto; background: #fff; border: 1px solid #eee; border-radius: 4px; padding: 12px; }
|
||||
|
||||
.main-content { display: flex; flex: 1; gap: 0; overflow: hidden; position: relative; }
|
||||
|
||||
.canvas-panel {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
transition: flex 0.3s ease;
|
||||
}
|
||||
.canvas-panel.panel-open {
|
||||
flex: 6;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
flex: 4;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 12px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.panel-close {
|
||||
text-align: right;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 面板滑入动画 */
|
||||
.slide-panel-enter-active, .slide-panel-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-panel-enter, .slide-panel-leave-to {
|
||||
flex: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.section-header { font-weight: bold; font-size: 14px; margin-bottom: 10px; }
|
||||
.empty-tip { color: #999; text-align: center; padding: 20px 0; font-size: 13px; }
|
||||
.roi-item { padding: 8px 10px; border: 1px solid #eee; border-radius: 4px; margin-bottom: 6px; cursor: pointer; transition: all 0.2s; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="roi-canvas-wrapper" ref="wrapper">
|
||||
<div class="roi-canvas-wrapper" ref="wrapper" tabindex="0" @keydown="onKeyDown">
|
||||
<img
|
||||
v-if="snapUrl"
|
||||
ref="bgImage"
|
||||
@@ -15,12 +15,16 @@
|
||||
<canvas
|
||||
ref="canvas"
|
||||
class="roi-overlay"
|
||||
:class="{ drawing: drawMode === 'polygon' }"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@contextmenu.prevent="onContextMenu"
|
||||
></canvas>
|
||||
<!-- 绘制中浮动提示条 -->
|
||||
<div v-if="drawMode === 'polygon'" class="draw-hint-bar">
|
||||
单击添加顶点 | 双击或右键完成选区 | Esc取消 | Ctrl+Z撤销
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,10 +42,8 @@ export default {
|
||||
ctx: null,
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
isDrawing: false,
|
||||
startPoint: null,
|
||||
currentPoint: null,
|
||||
polygonPoints: [],
|
||||
mouseMovePoint: null,
|
||||
loading: true,
|
||||
errorMsg: '',
|
||||
resizeObserver: null
|
||||
@@ -50,10 +52,14 @@ export default {
|
||||
watch: {
|
||||
rois: { handler() { this.redraw() }, deep: true },
|
||||
selectedRoiId() { this.redraw() },
|
||||
drawMode() {
|
||||
drawMode(newVal) {
|
||||
this.polygonPoints = []
|
||||
this.isDrawing = false
|
||||
this.mouseMovePoint = null
|
||||
this.redraw()
|
||||
// 进入绘制模式时聚焦 wrapper 以接收键盘事件
|
||||
if (newVal && this.$refs.wrapper) {
|
||||
this.$refs.wrapper.focus()
|
||||
}
|
||||
},
|
||||
snapUrl() {
|
||||
this.loading = true
|
||||
@@ -64,7 +70,6 @@ export default {
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
// ResizeObserver 确保容器尺寸变化时重新初始化 canvas
|
||||
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
|
||||
@@ -86,14 +91,11 @@ export default {
|
||||
methods: {
|
||||
onImageLoad() {
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
})
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
onImageError() {
|
||||
this.loading = false
|
||||
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
|
||||
// 关键:截图失败也初始化 canvas,使 ROI 区域可见可操作
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
initCanvas() {
|
||||
@@ -117,74 +119,99 @@ export default {
|
||||
y: (e.clientY - rect.top) / this.canvasHeight
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 鼠标事件 ----
|
||||
onMouseDown(e) {
|
||||
if (e.button !== 0) return
|
||||
const pt = this.getCanvasPoint(e)
|
||||
if (this.drawMode === 'rectangle') {
|
||||
this.isDrawing = true
|
||||
this.startPoint = pt
|
||||
this.currentPoint = pt
|
||||
} else if (this.drawMode === 'polygon') {
|
||||
|
||||
if (this.drawMode === 'polygon') {
|
||||
// 多边形模式:添加顶点
|
||||
this.polygonPoints.push(pt)
|
||||
this.redraw()
|
||||
this.drawPolygonInProgress()
|
||||
} else {
|
||||
} else if (!this.drawMode) {
|
||||
// 非绘制模式:点击选中 ROI
|
||||
const clickedRoi = this.findRoiAtPoint(pt)
|
||||
this.$emit('roi-selected', clickedRoi ? clickedRoi.roiId || clickedRoi.roi_id : null)
|
||||
}
|
||||
},
|
||||
onMouseMove(e) {
|
||||
if (!this.isDrawing || this.drawMode !== 'rectangle') return
|
||||
this.currentPoint = this.getCanvasPoint(e)
|
||||
this.redraw()
|
||||
this.drawRectInProgress()
|
||||
},
|
||||
onMouseUp(e) {
|
||||
if (!this.isDrawing || this.drawMode !== 'rectangle') return
|
||||
this.isDrawing = false
|
||||
const endPoint = this.getCanvasPoint(e)
|
||||
const x = Math.min(this.startPoint.x, endPoint.x)
|
||||
const y = Math.min(this.startPoint.y, endPoint.y)
|
||||
const w = Math.abs(endPoint.x - this.startPoint.x)
|
||||
const h = Math.abs(endPoint.y - this.startPoint.y)
|
||||
if (w > 0.01 && h > 0.01) {
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'rectangle',
|
||||
coordinates: JSON.stringify({ x, y, w, h })
|
||||
})
|
||||
}
|
||||
this.startPoint = null
|
||||
this.currentPoint = null
|
||||
if (this.drawMode !== 'polygon' || this.polygonPoints.length === 0) return
|
||||
this.mouseMovePoint = this.getCanvasPoint(e)
|
||||
this.redraw()
|
||||
this.drawPolygonInProgress()
|
||||
},
|
||||
onDoubleClick() {
|
||||
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'polygon',
|
||||
coordinates: JSON.stringify(this.polygonPoints.map(p => ({ x: p.x, y: p.y })))
|
||||
})
|
||||
this.polygonPoints = []
|
||||
this.redraw()
|
||||
// 双击完成:移除最后一个重复点(双击会触发两次 mousedown)
|
||||
this.finishPolygon()
|
||||
}
|
||||
},
|
||||
onContextMenu(e) {
|
||||
const pt = this.getCanvasPoint(e)
|
||||
const roi = this.findRoiAtPoint(pt)
|
||||
if (roi) {
|
||||
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
|
||||
if (this.drawMode === 'polygon' && this.polygonPoints.length >= 3) {
|
||||
// 右键完成选区
|
||||
this.finishPolygon()
|
||||
return
|
||||
}
|
||||
// 非绘制模式:右键删除 ROI
|
||||
if (!this.drawMode) {
|
||||
const pt = this.getCanvasPoint(e)
|
||||
const roi = this.findRoiAtPoint(pt)
|
||||
if (roi) {
|
||||
this.$emit('roi-deleted', roi.roiId || roi.roi_id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 键盘事件 ----
|
||||
onKeyDown(e) {
|
||||
if (this.drawMode === 'polygon') {
|
||||
if (e.key === 'Escape') {
|
||||
// Esc 取消绘制
|
||||
this.polygonPoints = []
|
||||
this.mouseMovePoint = null
|
||||
this.$emit('draw-cancelled')
|
||||
this.redraw()
|
||||
} else if ((e.ctrlKey && e.key === 'z') || e.key === 'Backspace') {
|
||||
// Ctrl+Z 或 Backspace 撤销上一个顶点
|
||||
e.preventDefault()
|
||||
if (this.polygonPoints.length > 0) {
|
||||
this.polygonPoints.pop()
|
||||
this.redraw()
|
||||
if (this.polygonPoints.length > 0) {
|
||||
this.drawPolygonInProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- 多边形完成 ----
|
||||
finishPolygon() {
|
||||
const points = this.polygonPoints.map(p => ({ x: p.x, y: p.y }))
|
||||
this.$emit('roi-drawn', {
|
||||
roi_type: 'polygon',
|
||||
coordinates: JSON.stringify(points)
|
||||
})
|
||||
this.polygonPoints = []
|
||||
this.mouseMovePoint = null
|
||||
this.redraw()
|
||||
},
|
||||
|
||||
// ---- ROI 查找 ----
|
||||
findRoiAtPoint(pt) {
|
||||
for (let i = this.rois.length - 1; i >= 0; i--) {
|
||||
const roi = this.rois[i]
|
||||
try {
|
||||
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
|
||||
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
|
||||
const type = roi.roiType || roi.roi_type
|
||||
if (type === 'rectangle' || type === 'fullscreen') {
|
||||
if (pt.x >= coords.x && pt.x <= coords.x + coords.w &&
|
||||
pt.y >= coords.y && pt.y <= coords.y + coords.h) {
|
||||
return roi
|
||||
}
|
||||
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
|
||||
} else if (type === 'polygon') {
|
||||
if (this.isPointInPolygon(pt, coords)) return roi
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
@@ -202,6 +229,8 @@ export default {
|
||||
}
|
||||
return inside
|
||||
},
|
||||
|
||||
// ---- 绘制 ----
|
||||
redraw() {
|
||||
if (!this.ctx) return
|
||||
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
|
||||
@@ -210,10 +239,13 @@ export default {
|
||||
const coords = typeof roi.coordinates === 'string' ? JSON.parse(roi.coordinates) : roi.coordinates
|
||||
const color = roi.color || '#FF0000'
|
||||
const isSelected = (roi.roiId || roi.roi_id) === this.selectedRoiId
|
||||
const type = roi.roiType || roi.roi_type
|
||||
|
||||
this.ctx.strokeStyle = color
|
||||
this.ctx.lineWidth = isSelected ? 3 : 2
|
||||
this.ctx.fillStyle = color + '33'
|
||||
if (roi.roiType === 'rectangle' || roi.roi_type === 'rectangle') {
|
||||
|
||||
if (type === 'rectangle' || type === 'fullscreen') {
|
||||
const rx = coords.x * this.canvasWidth
|
||||
const ry = coords.y * this.canvasHeight
|
||||
const rw = coords.w * this.canvasWidth
|
||||
@@ -225,7 +257,7 @@ export default {
|
||||
this.ctx.font = '12px Arial'
|
||||
this.ctx.fillText(roi.name, rx + 4, ry + 14)
|
||||
}
|
||||
} else if (roi.roiType === 'polygon' || roi.roi_type === 'polygon') {
|
||||
} else if (type === 'polygon') {
|
||||
this.ctx.beginPath()
|
||||
coords.forEach((p, idx) => {
|
||||
const px = p.x * this.canvasWidth
|
||||
@@ -244,18 +276,6 @@ export default {
|
||||
} catch (e) { /* skip */ }
|
||||
})
|
||||
},
|
||||
drawRectInProgress() {
|
||||
if (!this.startPoint || !this.currentPoint) return
|
||||
const x = Math.min(this.startPoint.x, this.currentPoint.x) * this.canvasWidth
|
||||
const y = Math.min(this.startPoint.y, this.currentPoint.y) * this.canvasHeight
|
||||
const w = Math.abs(this.currentPoint.x - this.startPoint.x) * this.canvasWidth
|
||||
const h = Math.abs(this.currentPoint.y - this.startPoint.y) * this.canvasHeight
|
||||
this.ctx.strokeStyle = '#00FF00'
|
||||
this.ctx.lineWidth = 2
|
||||
this.ctx.setLineDash([5, 5])
|
||||
this.ctx.strokeRect(x, y, w, h)
|
||||
this.ctx.setLineDash([])
|
||||
},
|
||||
drawPolygonInProgress() {
|
||||
if (this.polygonPoints.length < 1) return
|
||||
this.ctx.strokeStyle = '#00FF00'
|
||||
@@ -266,9 +286,24 @@ export default {
|
||||
const px = p.x * this.canvasWidth
|
||||
const py = p.y * this.canvasHeight
|
||||
idx === 0 ? this.ctx.moveTo(px, py) : this.ctx.lineTo(px, py)
|
||||
// 顶点圆点
|
||||
this.ctx.fillStyle = '#00FF00'
|
||||
this.ctx.fillRect(px - 3, py - 3, 6, 6)
|
||||
this.ctx.fillRect(px - 4, py - 4, 8, 8)
|
||||
})
|
||||
// 鼠标跟随线(到当前鼠标位置)
|
||||
if (this.mouseMovePoint) {
|
||||
this.ctx.lineTo(
|
||||
this.mouseMovePoint.x * this.canvasWidth,
|
||||
this.mouseMovePoint.y * this.canvasHeight
|
||||
)
|
||||
}
|
||||
// 首尾闭合预览线(淡色)
|
||||
if (this.polygonPoints.length >= 3) {
|
||||
const first = this.polygonPoints[0]
|
||||
const last = this.mouseMovePoint || this.polygonPoints[this.polygonPoints.length - 1]
|
||||
this.ctx.moveTo(last.x * this.canvasWidth, last.y * this.canvasHeight)
|
||||
this.ctx.lineTo(first.x * this.canvasWidth, first.y * this.canvasHeight)
|
||||
}
|
||||
this.ctx.stroke()
|
||||
this.ctx.setLineDash([])
|
||||
}
|
||||
@@ -283,6 +318,7 @@ export default {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
background: #000;
|
||||
outline: none;
|
||||
}
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
@@ -306,6 +342,22 @@ export default {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.roi-overlay.drawing {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.draw-hint-bar {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
|
||||
11
数据库/aiot/迁移-添加edge_device_id字段.sql
Normal file
11
数据库/aiot/迁移-添加edge_device_id字段.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 摄像头绑定边缘设备迁移脚本
|
||||
-- 新增 edge_device_id 字段,标识摄像头属于哪个边缘端
|
||||
|
||||
-- Step 1: 添加 edge_device_id 列
|
||||
ALTER TABLE wvp_stream_proxy ADD COLUMN edge_device_id VARCHAR(64) NULL AFTER area_id;
|
||||
|
||||
-- Step 2: 存量数据默认绑定到 edge(第一台边缘设备)
|
||||
UPDATE wvp_stream_proxy SET edge_device_id = 'edge' WHERE edge_device_id IS NULL;
|
||||
|
||||
-- 验证
|
||||
SELECT camera_code, camera_name, edge_device_id FROM wvp_stream_proxy;
|
||||
3
数据库/aiot/迁移-添加global_params字段.sql
Normal file
3
数据库/aiot/迁移-添加global_params字段.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 为算法注册表添加全局参数字段
|
||||
-- 用于存储用户自定义的全局默认参数JSON,在配置推送时三级合并:param_schema.default < global_params < bind.params
|
||||
ALTER TABLE wvp_ai_algorithm ADD COLUMN global_params TEXT NULL COMMENT '用户自定义的全局默认参数JSON' AFTER param_schema;
|
||||
Reference in New Issue
Block a user