Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d404749a | |||
| 8f8b4a2e97 | |||
| a8bc6d8888 | |||
| 5f9ef436c5 | |||
| e868fb8530 | |||
| 0eb2053c6f | |||
| 937eb07b37 | |||
| f32a9a3e8d | |||
| f6bc60f0c2 | |||
| 4c264ee800 | |||
| 4adebd19d2 | |||
| 28ceeca4f1 | |||
| 7f895a7b0f | |||
| 52511dfbdd | |||
| de542aa052 | |||
| a383ff3133 | |||
| 7db9a3f95d | |||
| 38b6a20e45 | |||
| a879487551 | |||
| 0750db2f44 | |||
| cd6fea7a50 | |||
| 8ddb56008f | |||
| 5cfac62421 | |||
| 8f17ba9005 | |||
| d1c8eae5b8 | |||
| 9e3a406c68 | |||
| 7b10b43e34 | |||
| 22ee2b92ee | |||
| 08c67b1101 | |||
| fbfe2f9032 | |||
| 827daebcf4 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -36,3 +36,9 @@ generate_*.py
|
||||
|
||||
# Documentation (keep local, do not commit)
|
||||
docs/
|
||||
|
||||
# 敏感配置文件(含密码/密钥,不入库)
|
||||
src/main/resources/application-dev.yml
|
||||
docker/.env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
225
CLAUDE.md
Normal file
225
CLAUDE.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
WVP-PRO 是一个基于 GB28181-2016 标准的开箱即用网络视频平台。负责实现核心信令与设备管理后台,支持 NAT 穿透,支持海康、大华、宇视等品牌的 IPC、NVR 接入。集成 ZLMediaKit 流媒体服务器。
|
||||
|
||||
**生产部署信息:**
|
||||
- **容器名称:** `vsp-wvp`(后端)、`vsp-frontend`(前端)、`vsp-zlmedia`(流媒体)
|
||||
- **端口映射:**
|
||||
- 后端:18080:18080
|
||||
- SIP: 8116:8116 (TCP/UDP)
|
||||
- 前端:8088:80
|
||||
- **部署位置:** 腾讯云服务器 `/opt/vsp-platform`
|
||||
- **存储:** 腾讯云 MySQL + 腾讯云 Redis
|
||||
- **访问地址:**
|
||||
- WVP 后端:http://服务器IP:18080
|
||||
- WVP 前端:http://服务器IP:8088
|
||||
- API 文档:http://服务器IP:18080/doc.html
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 编译后端(需要 Maven 3.3+ 和 Java 17)
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 或使用 Windows 批处理脚本
|
||||
编译.bat
|
||||
|
||||
# 运行后端
|
||||
mvn spring-boot:run
|
||||
启动.bat
|
||||
|
||||
# 编译前端(Vue 2)
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # 开发服务器
|
||||
npm run build:prod # 生产构建
|
||||
```
|
||||
|
||||
### Docker 部署(生产环境)
|
||||
|
||||
```bash
|
||||
# 构建后端镜像
|
||||
docker build -t vsp-wvp:latest .
|
||||
|
||||
# 运行后端容器
|
||||
docker run -d \
|
||||
--name vsp-wvp \
|
||||
-p 18080:18080 \
|
||||
-p 8116:8116 \
|
||||
-p 8116:8116/udp \
|
||||
-e SPRING_PROFILES_ACTIVE=prod \
|
||||
-e MYSQL_HOST=腾讯云MySQL地址 \
|
||||
-e REDIS_HOST=腾讯云Redis地址 \
|
||||
vsp-wvp:latest
|
||||
|
||||
# 构建前端镜像
|
||||
cd web
|
||||
docker build -t vsp-frontend:latest .
|
||||
|
||||
# 运行前端容器
|
||||
docker run -d \
|
||||
--name vsp-frontend \
|
||||
-p 8088:80 \
|
||||
vsp-frontend:latest
|
||||
|
||||
# 查看日志
|
||||
docker logs -f vsp-wvp
|
||||
docker logs -f vsp-frontend
|
||||
|
||||
# 重启容器
|
||||
docker restart vsp-wvp
|
||||
docker restart vsp-frontend
|
||||
|
||||
# 进入容器调试
|
||||
docker exec -it vsp-wvp /bin/bash
|
||||
```
|
||||
|
||||
### 基础服务(本地开发)
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up -d polaris-redis polaris-mysql polaris-media
|
||||
docker compose down
|
||||
docker compose logs -f polaris-media
|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
### 核心模块(src/main/java/com/genersoft/iot/vmp/)
|
||||
|
||||
- **gb28181/** — GB28181 协议实现
|
||||
- `transmit/` — SIP 信令传输层
|
||||
- `service/` — 设备管理、播放服务、PTZ 控制
|
||||
- `bean/` — GB28181 实体类
|
||||
|
||||
- **jt1078/** — JT1078 协议支持(部标808/1078)
|
||||
|
||||
- **media/** — ZLMediaKit 集成
|
||||
- 流媒体代理、录像管理
|
||||
- RTSP/RTMP/HLS/FLV 输出
|
||||
|
||||
- **aiot/** — AIoT 功能模块
|
||||
- `controller/` — AI 配置、ROI 管理、告警接口
|
||||
- `service/` — 配置推送、截图服务
|
||||
- `dao/` — ai_camera、ai_roi、ai_roi_algo_bind 数据访问
|
||||
|
||||
- **vmanager/** — REST API 控制器
|
||||
- 设备管理、通道查询、云台控制等
|
||||
|
||||
- **web/** — Vue 2 前端(独立目录)
|
||||
|
||||
### 配置文件
|
||||
|
||||
- `src/main/resources/application.yml` — 主配置
|
||||
- `src/main/resources/application-dev.yml` — 开发环境配置
|
||||
- server.port: 18080
|
||||
- MySQL: localhost:3306
|
||||
- Redis: localhost:6379
|
||||
- ZLM: localhost:6080
|
||||
|
||||
### AIoT 关键接口
|
||||
|
||||
**配置管理:**
|
||||
- `POST /api/ai/config/push` — 推送配置到边缘端(Redis Stream)
|
||||
- `GET /api/ai/config/export` — 导出摄像头完整配置
|
||||
- `POST /api/ai/config/push-all` — 一次性推送全部配置(本地调试)
|
||||
|
||||
**ROI 管理:**
|
||||
- `POST /api/ai/roi/create` — 创建 ROI 区域
|
||||
- `PUT /api/ai/roi/update` — 更新 ROI 区域
|
||||
- `DELETE /api/ai/roi/delete` — 删除 ROI 区域
|
||||
- `GET /api/ai/roi/list` — 查询 ROI 列表
|
||||
|
||||
**截图服务:**
|
||||
- `POST /api/ai/roi/snap` — 请求边缘端截图(Redis Stream)
|
||||
- `POST /api/ai/roi/snap/callback` — 边缘端截图回调
|
||||
|
||||
**摄像头管理:**
|
||||
- `GET /api/ai/camera/list` — 获取摄像头列表
|
||||
- `POST /api/ai/camera/sync` — 同步摄像头配置
|
||||
|
||||
**视频播放:**
|
||||
- `GET /api/play/{deviceId}/{channelId}` — 播放视频流
|
||||
- `GET /api/play/{deviceId}/{channelId}/stop` — 停止播放
|
||||
|
||||
### 数据库表(MySQL)
|
||||
|
||||
**AIoT 核心表:**
|
||||
- `ai_camera` — 摄像头配置(camera_id, camera_name, rtsp_url)
|
||||
- `ai_roi` — ROI 区域配置(roi_id, camera_id, polygon, roi_name)
|
||||
- `ai_roi_algo_bind` — 算法绑定(bind_id, roi_id, algorithm_code, threshold)
|
||||
- `ai_edge_device` — 边缘设备管理
|
||||
- `ai_config_snapshot` — 配置版本快照
|
||||
|
||||
**GB28181 核心表:**
|
||||
- `device` — 国标设备表
|
||||
- `device_channel` — 设备通道表
|
||||
- `stream_proxy` — 流代理配置
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 修改后端代码
|
||||
1. 修改 Java 代码
|
||||
2. `mvn clean package -DskipTests`
|
||||
3. `启动.bat` 或 `mvn spring-boot:run`
|
||||
4. 访问 http://localhost:18080/doc.html 测试
|
||||
|
||||
### 修改前端代码
|
||||
1. 修改 Vue 代码(web/src/)
|
||||
2. `cd web && npm run build:prod`
|
||||
3. 重新构建 Docker 镜像或直接运行 `npm run dev`
|
||||
|
||||
### 修改 AIoT 配置推送逻辑
|
||||
- 配置推送服务:`aiot/service/impl/AiConfigServiceImpl.java`
|
||||
- Redis Stream 写入:使用 RedisTemplate 的 `opsForStream().add()`
|
||||
- 配置格式:JSON 包含 cameras、rois、bindings 数组
|
||||
|
||||
### GB28181 设备处理
|
||||
- 设备注册处理:`gb28181/transmit/event/request/impl/RegisterRequestProcessor.java`
|
||||
- 心跳处理:`gb28181/transmit/event/request/impl/MessageRequestProcessor.java`
|
||||
- SIP 命令发送:`gb28181/transmit/cmd/impl/SIPCommander.java`
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 启动失败:端口被占用
|
||||
检查 18080 端口:`netstat -ano | findstr 18080`
|
||||
|
||||
### 数据库连接失败
|
||||
检查 MySQL 状态(Docker 模式):
|
||||
```bash
|
||||
docker ps | grep mysql
|
||||
docker logs docker-polaris-mysql-1
|
||||
```
|
||||
|
||||
### Redis 连接失败
|
||||
检查 Redis(Docker 模式):
|
||||
```bash
|
||||
docker exec -it docker-polaris-redis-1 redis-cli ping
|
||||
```
|
||||
|
||||
### ZLMediaKit 流媒体服务异常
|
||||
检查 ZLM 容器:
|
||||
```bash
|
||||
docker logs vsp-zlmedia
|
||||
```
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
在修改代码后,使用中文提交信息:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "功能:添加XXX功能
|
||||
|
||||
详细说明...
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
**不要立即 push**,等待用户指示再推送到远程。
|
||||
19
docker/.env
19
docker/.env
@@ -1,19 +0,0 @@
|
||||
MediaRtmp=10001
|
||||
MediaRtsp=10002
|
||||
MediaRtp=10003
|
||||
|
||||
WebHttp=8080
|
||||
WebHttps=8081
|
||||
|
||||
Stream_IP=127.0.0.1
|
||||
SDP_IP=127.0.0.1
|
||||
|
||||
SIP_ShowIP=127.0.0.1
|
||||
SIP_Port=8160
|
||||
SIP_Domain=3502000000
|
||||
SIP_Id=35020000002000000001
|
||||
SIP_Password=wvp_sip_password
|
||||
|
||||
|
||||
RecordSip=true
|
||||
RecordPushLive=
|
||||
28
docker/.env.example
Normal file
28
docker/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
MediaRtmp=10001
|
||||
MediaRtsp=10002
|
||||
MediaRtp=10003
|
||||
|
||||
WebHttp=8080
|
||||
WebHttps=8081
|
||||
|
||||
Stream_IP=127.0.0.1
|
||||
SDP_IP=127.0.0.1
|
||||
|
||||
SIP_ShowIP=127.0.0.1
|
||||
SIP_Port=8160
|
||||
SIP_Domain=3502000000
|
||||
SIP_Id=35020000002000000001
|
||||
SIP_Password=your-sip-password
|
||||
|
||||
|
||||
RecordSip=true
|
||||
RecordPushLive=
|
||||
|
||||
# COS 截图代理(腾讯云 COS)
|
||||
COS_REGION=ap-shanghai
|
||||
COS_BUCKET=your-bucket-name
|
||||
COS_SECRET_ID=your-cos-secret-id
|
||||
COS_SECRET_KEY=your-cos-secret-key
|
||||
|
||||
# AI 截图回调地址
|
||||
AI_SCREENSHOT_CALLBACK_URL=http://your-server:18080
|
||||
@@ -133,6 +133,15 @@ services:
|
||||
RecordSip: ${RecordSip}
|
||||
RecordPushLive: ${RecordPushLive}
|
||||
|
||||
# COS 截图代理
|
||||
COS_SECRET_ID: ${COS_SECRET_ID:}
|
||||
COS_SECRET_KEY: ${COS_SECRET_KEY:}
|
||||
COS_REGION: ${COS_REGION:ap-beijing}
|
||||
COS_BUCKET: ${COS_BUCKET:}
|
||||
|
||||
# AI 截图回调地址
|
||||
AI_SCREENSHOT_CALLBACK_URL: ${AI_SCREENSHOT_CALLBACK_URL:}
|
||||
|
||||
polaris-nginx:
|
||||
# 显式指定构建上下文和Dockerfile路径
|
||||
build:
|
||||
|
||||
@@ -31,6 +31,12 @@ public class AiEdgeDevice {
|
||||
@Schema(description = "流统计信息JSON")
|
||||
private String streamStats;
|
||||
|
||||
@Schema(description = "活跃视频流数量")
|
||||
private Integer streamCount;
|
||||
|
||||
@Schema(description = "当前配置版本")
|
||||
private String configVersion;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private String updatedAt;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ 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;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI摄像头管理接口
|
||||
*/
|
||||
@@ -38,4 +43,17 @@ public class AiCameraController {
|
||||
|
||||
return WVPResult.success(proxy);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取摄像头选项列表(用于下拉选择)")
|
||||
@GetMapping("/options")
|
||||
public WVPResult<List<Map<String, String>>> getCameraOptions() {
|
||||
List<StreamProxy> list = streamProxyMapper.selectAllCameraOptions();
|
||||
List<Map<String, String>> options = list.stream().map(p -> {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
m.put("cameraCode", p.getCameraCode());
|
||||
m.put("cameraName", p.getCameraName() != null ? p.getCameraName() : p.getApp());
|
||||
return m;
|
||||
}).collect(Collectors.toList());
|
||||
return WVPResult.success(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,21 @@ public class AiEdgeDeviceController {
|
||||
public WVPResult<Map<String, Object>> statistics() {
|
||||
return WVPResult.success(edgeDeviceService.getStatistics());
|
||||
}
|
||||
|
||||
@Operation(summary = "边缘设备心跳上报")
|
||||
@PostMapping("/heartbeat")
|
||||
public WVPResult<String> heartbeat(@RequestBody String payload) {
|
||||
try {
|
||||
com.alibaba.fastjson2.JSONObject json = com.alibaba.fastjson2.JSON.parseObject(payload);
|
||||
String deviceId = json.getString("device_id");
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
return WVPResult.fail(400, "device_id 不能为空");
|
||||
}
|
||||
edgeDeviceService.saveOrUpdateHeartbeat(deviceId, payload);
|
||||
return WVPResult.success("ok");
|
||||
} catch (Exception e) {
|
||||
log.error("[AiEdgeDevice] 心跳上报处理失败", e);
|
||||
return WVPResult.fail(500, "心跳处理失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -116,8 +117,22 @@ public class AiRoiController {
|
||||
}
|
||||
|
||||
@Operation(summary = "截图图片代理(服务端从 COS 下载后返回)")
|
||||
@GetMapping("/snap/image")
|
||||
public ResponseEntity<byte[]> getSnapImage(@RequestParam String cameraCode) {
|
||||
@RequestMapping(value = "/snap/image", method = {RequestMethod.GET, RequestMethod.HEAD})
|
||||
public ResponseEntity<?> getSnapImage(@RequestParam String cameraCode, HttpServletRequest request) {
|
||||
// HEAD 请求:只检查是否存在,不返回图片数据
|
||||
if ("HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||
byte[] image = screenshotService.proxyScreenshotImage(cameraCode);
|
||||
if (image == null || image.length == 0) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_JPEG)
|
||||
.contentLength(image.length)
|
||||
.header("Cache-Control", "public, max-age=300")
|
||||
.build();
|
||||
}
|
||||
|
||||
// GET 请求:返回图片数据
|
||||
byte[] image = screenshotService.proxyScreenshotImage(cameraCode);
|
||||
if (image == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
|
||||
@@ -9,15 +9,16 @@ import java.util.List;
|
||||
public interface AiEdgeDeviceMapper {
|
||||
|
||||
@Insert("INSERT INTO wvp_ai_edge_device (device_id, status, last_heartbeat, uptime_seconds, " +
|
||||
"frames_processed, alerts_generated, stream_stats, updated_at) " +
|
||||
"frames_processed, alerts_generated, stream_stats, stream_count, config_version, updated_at) " +
|
||||
"VALUES (#{deviceId}, #{status}, #{lastHeartbeat}, #{uptimeSeconds}, " +
|
||||
"#{framesProcessed}, #{alertsGenerated}, #{streamStats}, #{updatedAt})")
|
||||
"#{framesProcessed}, #{alertsGenerated}, #{streamStats}, #{streamCount}, #{configVersion}, #{updatedAt})")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int add(AiEdgeDevice device);
|
||||
|
||||
@Update("UPDATE wvp_ai_edge_device SET status=#{status}, last_heartbeat=#{lastHeartbeat}, " +
|
||||
"uptime_seconds=#{uptimeSeconds}, frames_processed=#{framesProcessed}, " +
|
||||
"alerts_generated=#{alertsGenerated}, stream_stats=#{streamStats}, " +
|
||||
"stream_count=#{streamCount}, config_version=#{configVersion}, " +
|
||||
"updated_at=#{updatedAt} WHERE device_id=#{deviceId}")
|
||||
int updateByDeviceId(AiEdgeDevice device);
|
||||
|
||||
|
||||
@@ -47,11 +47,14 @@ public class AiAlgorithmServiceImpl implements IAiAlgorithmService {
|
||||
"周界入侵检测", "person", "检测人员进入指定区域。算法抽帧频率:1帧/秒(固定)。持续检测到人5秒触发告警,持续无人180秒自动结束告警。消失确认期间短暂有人(<5秒)不影响倒计时。",
|
||||
"{\"cooldown_seconds\":{\"type\":\"int\",\"default\":300,\"min\":0},\"confirm_seconds\":{\"type\":\"int\",\"default\":5,\"min\":1},\"confirm_intrusion_seconds\":{\"type\":\"int\",\"default\":5,\"min\":1},\"confirm_clear_seconds\":{\"type\":\"int\",\"default\":180,\"min\":1}}"
|
||||
});
|
||||
// 人群聚集检测暂时注释,边缘端未启用
|
||||
// PRESET_ALGORITHMS.put("crowd_detection", new String[]{
|
||||
// "人群聚集检测", "person", "检测区域内人员数量是否超过阈值",
|
||||
// "{\"max_count\":{\"type\":\"int\",\"default\":10,\"min\":1},\"cooldown_seconds\":{\"type\":\"int\",\"default\":300,\"min\":0}}"
|
||||
// });
|
||||
PRESET_ALGORITHMS.put("illegal_parking", new String[]{
|
||||
"车辆违停检测", "car,truck,bus,motorcycle", "检测禁停区域内是否有车辆违规停放。确认车辆停留15秒后开始5分钟倒计时,超时触发告警。车辆离开30秒后自动结束告警。",
|
||||
"{\"confirm_vehicle_sec\":{\"type\":\"int\",\"default\":15,\"min\":5},\"parking_countdown_sec\":{\"type\":\"int\",\"default\":300,\"min\":60},\"confirm_clear_sec\":{\"type\":\"int\",\"default\":30,\"min\":10},\"cooldown_sec\":{\"type\":\"int\",\"default\":600,\"min\":0}}"
|
||||
});
|
||||
PRESET_ALGORITHMS.put("vehicle_congestion", new String[]{
|
||||
"车辆拥堵检测", "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}}"
|
||||
});
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
|
||||
@@ -15,9 +15,9 @@ import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiConfigService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiConfigSnapshotService;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService;
|
||||
import com.genersoft.iot.vmp.aiot.util.CameraCodeUtil;
|
||||
import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
|
||||
import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper;
|
||||
import java.util.UUID;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@@ -78,9 +78,9 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
// 1. 统一 device_id
|
||||
try {
|
||||
String defaultDeviceId = getDefaultDeviceId();
|
||||
int updated = roiMapper.updateAllDeviceId(defaultDeviceId);
|
||||
int updated = roiMapper.backfillDeviceId(defaultDeviceId);
|
||||
if (updated > 0) {
|
||||
log.info("[AiConfig] 启动时统一 {} 条 ROI 的 device_id → {}", updated, defaultDeviceId);
|
||||
log.info("[AiConfig] 启动时回填 {} 条空 device_id 的 ROI → {}", updated, defaultDeviceId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AiConfig] 启动时修复 device_id 失败: {}", e.getMessage());
|
||||
@@ -104,7 +104,7 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
}
|
||||
int backfilled = 0;
|
||||
for (StreamProxy proxy : nullCodeProxies) {
|
||||
String cameraCode = "cam_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
String cameraCode = CameraCodeUtil.generate(streamProxyMapper);
|
||||
streamProxyMapper.updateCameraCode(proxy.getId(), cameraCode);
|
||||
backfilled++;
|
||||
log.info("[AiConfig] 回填 camera_code: id={}, app={}, stream={} → {}",
|
||||
@@ -357,7 +357,15 @@ public class AiConfigServiceImpl implements IAiConfigService {
|
||||
camOut.put("camera_id", cameraId);
|
||||
camOut.put("enabled", true);
|
||||
camOut.put("rtsp_url", proxy.getSrcUrl());
|
||||
camOut.put("camera_name", proxy.getGbName() != null ? proxy.getGbName() : cameraId);
|
||||
String cameraName = proxy.getCameraName();
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
cameraName = proxy.getGbName();
|
||||
}
|
||||
if (cameraName == null || cameraName.isBlank()) {
|
||||
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());
|
||||
|
||||
@@ -46,6 +46,8 @@ public class AiEdgeDeviceServiceImpl implements IAiEdgeDeviceService {
|
||||
device.setAlertsGenerated(status != null ? status.getLong("alerts_generated") : null);
|
||||
device.setStreamStats(status != null && status.containsKey("stream_stats") ?
|
||||
status.getJSONObject("stream_stats").toJSONString() : null);
|
||||
device.setStreamCount(status != null ? status.getInteger("stream_count") : null);
|
||||
device.setConfigVersion(status != null ? status.getString("config_version") : null);
|
||||
device.setUpdatedAt(now);
|
||||
deviceMapper.add(device);
|
||||
log.info("[AiEdgeDevice] 新设备上线: deviceId={}", deviceId);
|
||||
@@ -57,6 +59,8 @@ public class AiEdgeDeviceServiceImpl implements IAiEdgeDeviceService {
|
||||
device.setAlertsGenerated(status != null ? status.getLong("alerts_generated") : null);
|
||||
device.setStreamStats(status != null && status.containsKey("stream_stats") ?
|
||||
status.getJSONObject("stream_stats").toJSONString() : null);
|
||||
device.setStreamCount(status != null ? status.getInteger("stream_count") : null);
|
||||
device.setConfigVersion(status != null ? status.getString("config_version") : null);
|
||||
device.setUpdatedAt(now);
|
||||
deviceMapper.updateByDeviceId(device);
|
||||
log.debug("[AiEdgeDevice] 心跳更新: deviceId={}", deviceId);
|
||||
|
||||
@@ -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路径直接使用该格式
|
||||
@@ -504,23 +501,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 = "";
|
||||
@@ -560,6 +549,9 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
cameraMap.put("location", "");
|
||||
cameraMap.put("rtsp_url", rtspUrl);
|
||||
cameraMap.put("rtsp_url_valid", true);
|
||||
if (proxy.getAreaId() != null) {
|
||||
cameraMap.put("area_id", proxy.getAreaId());
|
||||
}
|
||||
cameras.add(cameraMap);
|
||||
|
||||
// 该摄像头下的 ROI 和绑定
|
||||
|
||||
@@ -261,6 +261,12 @@ public class AiRoiServiceImpl implements IAiRoiService {
|
||||
if (old == null) {
|
||||
throw new IllegalArgumentException("绑定关系不存在");
|
||||
}
|
||||
// 部分更新:前端可能只发送部分字段,未发送的字段保留旧值
|
||||
if (bind.getParams() == null) bind.setParams(old.getParams());
|
||||
if (bind.getPriority() == null) bind.setPriority(old.getPriority());
|
||||
if (bind.getEnabled() == null) bind.setEnabled(old.getEnabled());
|
||||
if (bind.getTemplateId() == null) bind.setTemplateId(old.getTemplateId());
|
||||
if (bind.getParamOverride() == null) bind.setParamOverride(old.getParamOverride());
|
||||
bind.setUpdateTime(now);
|
||||
bindMapper.updateByBindId(bind);
|
||||
configLogService.addLog("BIND", bind.getBindId(), toJson(old), toJson(bind), null);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.genersoft.iot.vmp.aiot.service.impl;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper;
|
||||
import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper;
|
||||
import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService;
|
||||
import com.genersoft.iot.vmp.aiot.util.CosUtil;
|
||||
import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
|
||||
@@ -55,10 +56,13 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
@Autowired
|
||||
private AiCameraSnapshotMapper snapshotMapper;
|
||||
|
||||
@Autowired
|
||||
private AiRoiMapper roiMapper;
|
||||
|
||||
@Autowired
|
||||
private CosUtil cosUtil;
|
||||
|
||||
@Value("${ai.screenshot.callback-url:}")
|
||||
@Value("${ai.screenshot.callback-url:http://124.222.218.198:18080}")
|
||||
private String callbackUrl;
|
||||
|
||||
@Override
|
||||
@@ -114,6 +118,12 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
fields.put("callback_url", callbackUrl);
|
||||
}
|
||||
|
||||
// 查询摄像头关联的 device_id,供 Edge 按设备过滤
|
||||
String deviceId = roiMapper.queryDeviceIdByCameraId(cameraCode);
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
fields.put("device_id", deviceId);
|
||||
}
|
||||
|
||||
// 查询 rtsp_url 放入请求,供 Edge 对无 ROI 摄像头临时连接截图
|
||||
StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraCode);
|
||||
if (proxy != null) {
|
||||
@@ -121,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 {
|
||||
@@ -156,9 +173,12 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
JSONObject res = JSON.parseObject(resultJson);
|
||||
result.put("status", res.getString("status"));
|
||||
if ("ok".equals(res.getString("status"))) {
|
||||
result.put("url", res.getString("url"));
|
||||
String url = res.getString("url");
|
||||
result.put("url", url);
|
||||
// 降级成功,写入缓存
|
||||
writeCache(cameraCode, res.getString("url"));
|
||||
writeCache(cameraCode, url);
|
||||
// 降级路径也要持久化 cos_key 到 DB,否则后续 proxyImage 会找不到记录
|
||||
persistCosKey(cameraCode, url, requestId);
|
||||
} else {
|
||||
result.put("message", res.getString("message"));
|
||||
}
|
||||
@@ -209,17 +229,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
if ("ok".equals(status) && cameraCode != null) {
|
||||
String url = (String) data.get("url");
|
||||
writeCache(cameraCode, url);
|
||||
|
||||
// 持久化 cos_key 到 DB(永不过期,供后续直接读取)
|
||||
String cosKey = pendingCosKeys.remove(requestId);
|
||||
if (cosKey != null) {
|
||||
try {
|
||||
snapshotMapper.upsert(cameraCode, cosKey);
|
||||
log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[AI截图] 持久化 cos_key 失败: cameraCode={}, error={}", cameraCode, e.getMessage());
|
||||
}
|
||||
}
|
||||
persistCosKey(cameraCode, url, requestId);
|
||||
} else {
|
||||
pendingCosKeys.remove(requestId);
|
||||
}
|
||||
@@ -233,6 +243,72 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从COS预签名URL中提取cos_key
|
||||
* URL格式: https://{bucket}.cos.{region}.myqcloud.com/{cos_key}?q-sign-...
|
||||
* 返回: snapshots/{camera_code}/2026-03-10/08-16-39_xxx.jpg
|
||||
*/
|
||||
private String extractCosKeyFromUrl(String url) {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 移除查询参数,得到路径部分
|
||||
String pathPart = url;
|
||||
int queryStart = url.indexOf('?');
|
||||
if (queryStart > 0) {
|
||||
pathPart = url.substring(0, queryStart);
|
||||
}
|
||||
|
||||
// 找到myqcloud.com后的路径
|
||||
int domainEnd = pathPart.indexOf(".myqcloud.com/");
|
||||
if (domainEnd > 0) {
|
||||
String cosKey = pathPart.substring(domainEnd + ".myqcloud.com/".length());
|
||||
// 确保以snapshots/开头
|
||||
if (cosKey.startsWith("snapshots/")) {
|
||||
return cosKey;
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:尝试最后一个/后的路径
|
||||
int lastSlash = pathPart.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
// 往回找snapshots
|
||||
int snapshotsIdx = pathPart.indexOf("snapshots/");
|
||||
if (snapshotsIdx > 0) {
|
||||
return pathPart.substring(snapshotsIdx);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI截图] 从URL解析cos_key失败: {}, error={}", url, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化 cos_key 到 DB(从 URL 解析或从 pendingCosKeys 获取)
|
||||
* 供降级路径和回调路径共用
|
||||
*/
|
||||
private void persistCosKey(String cameraCode, String url, String requestId) {
|
||||
String cosKey = extractCosKeyFromUrl(url);
|
||||
if (cosKey == null) {
|
||||
cosKey = pendingCosKeys.remove(requestId);
|
||||
} else {
|
||||
pendingCosKeys.remove(requestId);
|
||||
}
|
||||
|
||||
if (cosKey != null) {
|
||||
try {
|
||||
snapshotMapper.upsert(cameraCode, cosKey);
|
||||
log.info("[AI截图] cos_key 已持久化(降级路径): cameraCode={}, cosKey={}", cameraCode, cosKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[AI截图] 持久化 cos_key 失败(降级路径): cameraCode={}, error={}", cameraCode, e.getMessage());
|
||||
}
|
||||
} else {
|
||||
log.warn("[AI截图] 降级路径无法获取cos_key: cameraCode={}, url={}", cameraCode, url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入截图缓存
|
||||
*/
|
||||
@@ -258,33 +334,14 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
|
||||
@Override
|
||||
public byte[] proxyScreenshotImage(String cameraCode) {
|
||||
// 1. 先查 Redis 缓存中的 presigned URL(5分钟有效)
|
||||
String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
|
||||
if (cacheJson != null) {
|
||||
try {
|
||||
JSONObject cached = JSON.parseObject(cacheJson);
|
||||
String cosUrl = cached.getString("url");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
byte[] bytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class);
|
||||
if (bytes != null && bytes.length > 0) {
|
||||
log.debug("[AI截图] 代理图片(Redis缓存): cameraCode={}, size={}", cameraCode, bytes.length);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查 DB 持久化的 cos_key(永不过期)
|
||||
// 1. 查 DB 持久化的 cos_key(永不过期)- 优先直接操作 COS
|
||||
String cosKey = snapshotMapper.getCosKey(cameraCode);
|
||||
if (cosKey == null) {
|
||||
log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode);
|
||||
log.warn("[AI截图] 代理图片: 无持久化记录 cameraCode={}", cameraCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 通过 CosUtil 直接生成 presigned URL(无需调 FastAPI)
|
||||
// 2. 通过 CosUtil 直接生成 presigned URL
|
||||
if (!cosUtil.isAvailable()) {
|
||||
log.warn("[AI截图] COS 客户端未初始化,无法生成 presigned URL");
|
||||
return null;
|
||||
@@ -297,15 +354,24 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. 下载图片
|
||||
// 3. 下载图片
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
byte[] imageBytes = restTemplate.getForObject(URI.create(presignedUrl), byte[].class);
|
||||
|
||||
// 5. 更新 Redis 缓存(加速后续请求)
|
||||
if (imageBytes != null && imageBytes.length > 0) {
|
||||
writeCache(cameraCode, presignedUrl);
|
||||
log.debug("[AI截图] 代理图片(DB→COS): cameraCode={}, size={}", cameraCode, imageBytes.length);
|
||||
if (imageBytes == null || imageBytes.length == 0) {
|
||||
log.error("[AI截图] 下载图片为空: cameraCode={}, cosKey={}", cameraCode, cosKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证是否为有效的 JPEG 图片(检查魔数)
|
||||
if (imageBytes.length < 2 || (imageBytes[0] & 0xFF) != 0xFF || (imageBytes[1] & 0xFF) != 0xD8) {
|
||||
log.error("[AI截图] 下载的数据不是有效的JPEG: cameraCode={}, size={}", cameraCode, imageBytes.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 更新 Redis 缓存(加速后续请求)
|
||||
writeCache(cameraCode, presignedUrl);
|
||||
log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes.length);
|
||||
return imageBytes;
|
||||
} catch (Exception e) {
|
||||
log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.genersoft.iot.vmp.aiot.util;
|
||||
|
||||
import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 摄像头编码生成工具类
|
||||
* 格式:CAM{YYYYMMDD}{NNN} 例:CAM20260319001
|
||||
* 日期部分标识创建日期,序号部分为当日递增
|
||||
*/
|
||||
public class CameraCodeUtil {
|
||||
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
private CameraCodeUtil() {}
|
||||
|
||||
/**
|
||||
* 生成新的 camera_code(需要查询数据库获取当日最大序号)
|
||||
*/
|
||||
public static String generate(StreamProxyMapper mapper) {
|
||||
String dateStr = LocalDate.now().format(DATE_FMT);
|
||||
String prefix = "CAM" + dateStr;
|
||||
|
||||
// 查询当日最大编码
|
||||
String maxCode = mapper.selectMaxCameraCodeByPrefix(prefix);
|
||||
int nextSeq = 1;
|
||||
if (maxCode != null && maxCode.length() > prefix.length()) {
|
||||
try {
|
||||
nextSeq = Integer.parseInt(maxCode.substring(prefix.length())) + 1;
|
||||
} catch (NumberFormatException e) {
|
||||
nextSeq = 1;
|
||||
}
|
||||
}
|
||||
return prefix + String.format("%03d", nextSeq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧接口:从 RTSP URL 生成(降级方案)
|
||||
*/
|
||||
public static String generateFromUrl(String srcUrl) {
|
||||
String dateStr = LocalDate.now().format(DATE_FMT);
|
||||
String hash = String.format("%03d", Math.abs(srcUrl.hashCode()) % 999 + 1);
|
||||
return "CAM" + dateStr + hash;
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ public class WebSecurityConfig {
|
||||
defaultExcludes.add("/api/ai/alert/edge/**");
|
||||
defaultExcludes.add("/api/ai/alert/image");
|
||||
defaultExcludes.add("/api/ai/device/edge/**");
|
||||
defaultExcludes.add("/api/ai/device/heartbeat");
|
||||
|
||||
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {
|
||||
defaultExcludes.addAll(userSetting.getInterfaceAuthenticationExcludes());
|
||||
|
||||
@@ -30,9 +30,12 @@ public class StreamProxy extends CommonGBChannel {
|
||||
@Schema(description = "流ID")
|
||||
private String stream;
|
||||
|
||||
@Schema(description = "摄像头全局唯一编码(格式:cam_xxxxxxxxxxxx)")
|
||||
@Schema(description = "摄像头全局唯一编码(格式:CAM{YYYYMMDD}{NNN})")
|
||||
private String cameraCode;
|
||||
|
||||
@Schema(description = "摄像头名称(用户自定义)")
|
||||
private String cameraName;
|
||||
|
||||
@Schema(description = "当前拉流使用的流媒体服务ID")
|
||||
private String mediaServerId;
|
||||
|
||||
@@ -72,6 +75,12 @@ public class StreamProxy extends CommonGBChannel {
|
||||
@Schema(description = "拉流状态")
|
||||
private Boolean pulling;
|
||||
|
||||
@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) 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} )")
|
||||
"#{enableDisableNoneReader}, #{serverId}, #{createTime}, #{cameraCode}, #{cameraName}, #{areaId}, #{edgeDeviceId} )")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int add(StreamProxy streamProxyDto);
|
||||
|
||||
@@ -33,7 +33,10 @@ public interface StreamProxyMapper {
|
||||
"enable=#{enable}, " +
|
||||
"pulling=#{pulling}, " +
|
||||
"enable_disable_none_reader=#{enableDisableNoneReader}, " +
|
||||
"enable_mp4=#{enableMp4} " +
|
||||
"enable_mp4=#{enableMp4}, " +
|
||||
"camera_name=#{cameraName}, " +
|
||||
"area_id=#{areaId}, " +
|
||||
"edge_device_id=#{edgeDeviceId} " +
|
||||
"WHERE id=#{id}")
|
||||
int update(StreamProxy streamProxyDto);
|
||||
|
||||
@@ -112,4 +115,16 @@ public interface StreamProxyMapper {
|
||||
*/
|
||||
@Update("UPDATE wvp_stream_proxy SET camera_code = #{cameraCode} WHERE id = #{id}")
|
||||
int updateCameraCode(@Param("id") int id, @Param("cameraCode") String cameraCode);
|
||||
|
||||
/**
|
||||
* 查询指定前缀的最大 camera_code(用于生成新编码的序号)
|
||||
*/
|
||||
@Select("SELECT MAX(camera_code) FROM wvp_stream_proxy WHERE camera_code LIKE CONCAT(#{prefix}, '%')")
|
||||
String selectMaxCameraCodeByPrefix(@Param("prefix") String prefix);
|
||||
|
||||
/**
|
||||
* 查询所有摄像头简要信息(用于前端下拉选择)
|
||||
*/
|
||||
@Select("SELECT camera_code, camera_name FROM wvp_stream_proxy WHERE enable = 1 ORDER BY camera_name")
|
||||
List<StreamProxy> selectAllCameraOptions();
|
||||
}
|
||||
|
||||
@@ -39,11 +39,12 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import com.genersoft.iot.vmp.aiot.util.CameraCodeUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 视频代理业务
|
||||
@@ -79,15 +80,6 @@ public class StreamProxyServiceImpl implements IStreamProxyService {
|
||||
@Autowired
|
||||
private IAiRedisConfigService redisConfigService;
|
||||
|
||||
/**
|
||||
* 生成唯一的 camera_code
|
||||
* 格式:cam_xxxxxxxxxxxx(12位随机字符)
|
||||
* @return 唯一的 camera_code
|
||||
*/
|
||||
private String generateCameraCode() {
|
||||
return "cam_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流到来的处理
|
||||
*/
|
||||
@@ -154,34 +146,31 @@ 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(), "摄像头名称不能为空");
|
||||
}
|
||||
|
||||
// 自动生成 camera_code(最多重试3次避免冲突)
|
||||
int retryCount = 0;
|
||||
while (retryCount < 3) {
|
||||
String cameraCode = generateCameraCode();
|
||||
streamProxy.setCameraCode(cameraCode);
|
||||
streamProxy.setCreateTime(DateUtil.getNow());
|
||||
streamProxy.setUpdateTime(DateUtil.getNow());
|
||||
|
||||
streamProxy.setCreateTime(DateUtil.getNow());
|
||||
streamProxy.setUpdateTime(DateUtil.getNow());
|
||||
|
||||
try {
|
||||
if (streamProxy.getGbDeviceId() != null) {
|
||||
gbChannelService.add(streamProxy.buildCommonGBChannel());
|
||||
}
|
||||
streamProxyMapper.add(streamProxy);
|
||||
streamProxy.setDataType(ChannelDataType.STREAM_PROXY);
|
||||
streamProxy.setDataDeviceId(streamProxy.getId());
|
||||
return;
|
||||
} catch (DuplicateKeyException e) {
|
||||
retryCount++;
|
||||
if (retryCount >= 3) {
|
||||
throw new RuntimeException("生成 camera_code 失败,请重试");
|
||||
}
|
||||
try {
|
||||
if (streamProxy.getGbDeviceId() != null) {
|
||||
gbChannelService.add(streamProxy.buildCommonGBChannel());
|
||||
}
|
||||
streamProxyMapper.add(streamProxy);
|
||||
streamProxy.setDataType(ChannelDataType.STREAM_PROXY);
|
||||
streamProxy.setDataDeviceId(streamProxy.getId());
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new RuntimeException("生成 camera_code 失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
spring:
|
||||
# 设置接口超时时间
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: 20000
|
||||
thymeleaf:
|
||||
cache: false
|
||||
# [可选]上传文件大小限制
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 100MB
|
||||
cache:
|
||||
type: redis
|
||||
data:
|
||||
# REDIS数据库配置
|
||||
redis:
|
||||
# [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
|
||||
host: 127.0.0.1
|
||||
# [必须修改] 端口号
|
||||
port: 6379
|
||||
# [可选] 数据库 DB
|
||||
database: 0
|
||||
# [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
|
||||
# password:
|
||||
# [可选] 超时时间
|
||||
timeout: 10000
|
||||
# mysql数据源
|
||||
datasource:
|
||||
type: com.zaxxer.hikari.HikariDataSource
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://127.0.0.1:3306/wvp?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&allowPublicKeyRetrieval=true
|
||||
username: wvp_user
|
||||
password: wvp_password
|
||||
# h2数据库
|
||||
# datasource:
|
||||
# driver-class-name: org.h2.Driver
|
||||
# url: jdbc:h2:mem:wvp
|
||||
# username: sa
|
||||
# password: 12345678
|
||||
# sql:
|
||||
# init:
|
||||
# # 启动时仅初始化内置的数据库,例如h2:mem
|
||||
# mode: embedded
|
||||
# schema-locations: file:数据库/2.7.4-h2/h2-schema.sql
|
||||
# data-locations: file:数据库/2.7.4-h2/h2-data.sql
|
||||
# # h2数据库控制台,请注意仅在测试环境下使用!
|
||||
# h2:
|
||||
# console:
|
||||
# enabled: true
|
||||
#[可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
|
||||
server:
|
||||
port: 18080
|
||||
# [可选] HTTPS配置, 默认不开启
|
||||
ssl:
|
||||
# [可选] 是否开启HTTPS访问
|
||||
enabled: false
|
||||
# [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
|
||||
key-store: classpath:test.monitor.89iot.cn.jks
|
||||
# [可选] 证书密码
|
||||
key-store-password: gpf64qmw
|
||||
# [可选] 证书类型, 默认为jks,根据实际修改
|
||||
key-store-type: JKS
|
||||
|
||||
# 作为28181服务器的配置
|
||||
sip:
|
||||
# [可选] 28181服务监听的端口
|
||||
port: 8116
|
||||
# 根据国标6.1.2中规定,domain宜采用ID统一编码的前十位编码。国标附录D中定义前8位为中心编码(由省级、市级、区级、基层编号组成,参照GB/T 2260-2007)
|
||||
# 后两位为行业编码,定义参照附录D.3
|
||||
# 3701020049标识山东济南历下区 信息行业接入
|
||||
# [可选]
|
||||
domain: 4101050000
|
||||
# [可选]
|
||||
id: 41010500002000000001
|
||||
# [可选] 公共认证密码 移除密码将必须提前添加设备才能通过认证
|
||||
password: 12345678
|
||||
# 是否存储alarm信息
|
||||
alarm: false
|
||||
|
||||
#zlm 默认服务器配置
|
||||
media:
|
||||
id: zlmediakit-local
|
||||
# [必须修改] zlm服务器的内网IP
|
||||
ip: 127.0.0.1
|
||||
# [必须修改] zlm服务器的http.port
|
||||
http-port: 6080
|
||||
# [必选选] zlm服务器的hook.admin_params=secret
|
||||
secret: su6TiedN2rVAmBbIDX0aa0QTiBJLBdcf
|
||||
# [重要] ZLM在Docker内运行时,hook回调需用host.docker.internal才能访问宿主机
|
||||
hook-ip: host.docker.internal
|
||||
# 启用多端口模式, 多端口模式使用端口区分每路流,兼容性更好。 单端口使用流的ssrc区分, 点播超时建议使用多端口测试
|
||||
rtp:
|
||||
# [可选] 是否启用多端口模式, 开启后会在portRange范围内选择端口用于媒体流传输
|
||||
enable: true
|
||||
# [可选] 在此范围内选择端口用于媒体流传输, 必须提前在zlm上配置该属性,不然自动配置此属性可能不成功
|
||||
port-range: 40000,45000 # 端口范围
|
||||
# [可选] 国标级联在此范围内选择端口发送媒体流,
|
||||
send-port-range: 50000,55000 # 端口范围
|
||||
# [根据业务需求配置]
|
||||
user-settings:
|
||||
# 点播/录像回放 等待超时时间,单位:毫秒
|
||||
play-timeout: 180000
|
||||
# [可选] 自动点播, 使用固定流地址进行播放时,如果未点播则自动进行点播, 需要rtp.enable=true
|
||||
auto-apply-play: true
|
||||
# 推流直播是否录制
|
||||
record-push-live: true
|
||||
# 国标是否录制
|
||||
record-sip: true
|
||||
# 国标点播 按需拉流, true:有人观看拉流,无人观看释放, false:拉起后不自动释放
|
||||
stream-on-demand: true
|
||||
# 是否返回Date属性,true:不返回,避免摄像头通过该参数自动校时,false:返回,摄像头可能会根据该时间校时
|
||||
disable-date-header: false
|
||||
|
||||
# AI边缘端服务配置
|
||||
ai:
|
||||
service:
|
||||
# FastAPI边缘端地址
|
||||
url: http://127.0.0.1:9001
|
||||
# 推送超时ms
|
||||
push-timeout: 10000
|
||||
# 暂未对接时设为false
|
||||
enabled: true
|
||||
screenshot:
|
||||
# Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果)
|
||||
callback-url: http://124.221.55.225:18080
|
||||
cos:
|
||||
secret-id:
|
||||
secret-key:
|
||||
region: ap-beijing
|
||||
bucket:
|
||||
mqtt:
|
||||
# MQTT推送开关
|
||||
enabled: false
|
||||
# EMQX Broker地址
|
||||
|
||||
broker: tcp://127.0.0.1:1883
|
||||
# 客户端ID
|
||||
client-id: wvp-server
|
||||
# 认证用户名
|
||||
username: wvp
|
||||
# 认证密码
|
||||
password: wvp123
|
||||
# topic前缀
|
||||
topic-prefix: ai/config
|
||||
# QoS级别
|
||||
qos: 1
|
||||
# 连接超时(秒)
|
||||
connect-timeout: 10
|
||||
# 心跳间隔(秒)
|
||||
keep-alive: 60
|
||||
|
||||
95
src/main/resources/application-dev.yml.example
Normal file
95
src/main/resources/application-dev.yml.example
Normal file
@@ -0,0 +1,95 @@
|
||||
spring:
|
||||
# 设置接口超时时间
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: 20000
|
||||
thymeleaf:
|
||||
cache: false
|
||||
# [可选]上传文件大小限制
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 100MB
|
||||
cache:
|
||||
type: redis
|
||||
data:
|
||||
# REDIS数据库配置
|
||||
redis:
|
||||
# [必须修改] Redis服务器地址
|
||||
host: 127.0.0.1
|
||||
# [必须修改] 端口号
|
||||
port: 6379
|
||||
# [可选] 数据库 DB
|
||||
database: 0
|
||||
# [可选] 访问密码
|
||||
# password: your-redis-password
|
||||
# [可选] 超时时间
|
||||
timeout: 10000
|
||||
# mysql数据源
|
||||
datasource:
|
||||
type: com.zaxxer.hikari.HikariDataSource
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://127.0.0.1:3306/wvp?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&allowPublicKeyRetrieval=true
|
||||
username: your-db-username
|
||||
password: your-db-password
|
||||
|
||||
#[可选] WVP监听的HTTP端口
|
||||
server:
|
||||
port: 18080
|
||||
ssl:
|
||||
enabled: false
|
||||
key-store: classpath:test.monitor.89iot.cn.jks
|
||||
key-store-password: your-keystore-password
|
||||
key-store-type: JKS
|
||||
|
||||
# 作为28181服务器的配置
|
||||
sip:
|
||||
port: 8116
|
||||
domain: 4101050000
|
||||
id: 41010500002000000001
|
||||
password: 12345678
|
||||
alarm: false
|
||||
|
||||
#zlm 默认服务器配置
|
||||
media:
|
||||
id: zlmediakit-local
|
||||
ip: 127.0.0.1
|
||||
http-port: 6080
|
||||
secret: your-zlm-secret
|
||||
hook-ip: host.docker.internal
|
||||
rtp:
|
||||
enable: true
|
||||
port-range: 40000,45000
|
||||
send-port-range: 50000,55000
|
||||
|
||||
user-settings:
|
||||
play-timeout: 180000
|
||||
auto-apply-play: true
|
||||
record-push-live: true
|
||||
record-sip: true
|
||||
stream-on-demand: true
|
||||
disable-date-header: false
|
||||
|
||||
# AI边缘端服务配置
|
||||
ai:
|
||||
service:
|
||||
url: http://127.0.0.1:9001
|
||||
push-timeout: 10000
|
||||
enabled: true
|
||||
screenshot:
|
||||
callback-url: http://127.0.0.1:18080
|
||||
cos:
|
||||
secret-id: your-cos-secret-id
|
||||
secret-key: your-cos-secret-key
|
||||
region: ap-shanghai
|
||||
bucket: your-bucket-name
|
||||
mqtt:
|
||||
enabled: false
|
||||
broker: tcp://127.0.0.1:1883
|
||||
client-id: wvp-server
|
||||
username: wvp
|
||||
password: your-mqtt-password
|
||||
topic-prefix: ai/config
|
||||
qos: 1
|
||||
connect-timeout: 10
|
||||
keep-alive: 60
|
||||
@@ -14,7 +14,7 @@ spring:
|
||||
# [必须修改] 端口号
|
||||
port: ${REDIS_PORT:6379}
|
||||
# [可选] 数据库 DB
|
||||
database: ${REDIS_DB:6}
|
||||
database: ${REDIS_DB:1}
|
||||
# [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
|
||||
password: ${REDIS_PWD:root}
|
||||
# [可选] 超时时间
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,10 +53,16 @@ CREATE TABLE IF NOT EXISTS wvp_ai_edge_device (
|
||||
frames_processed BIGINT NULL COMMENT '已处理帧数',
|
||||
alerts_generated BIGINT NULL COMMENT '已生成告警数',
|
||||
stream_stats TEXT NULL COMMENT 'JSON流统计',
|
||||
stream_count INT NULL COMMENT '活跃视频流数量',
|
||||
config_version VARCHAR(64) NULL COMMENT '当前配置版本',
|
||||
updated_at VARCHAR(50) NULL COMMENT '更新时间',
|
||||
UNIQUE KEY uk_device_id (device_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='边缘设备状态';
|
||||
|
||||
-- 升级:wvp_ai_edge_device 新增字段(已有表执行)
|
||||
ALTER TABLE wvp_ai_edge_device ADD COLUMN IF NOT EXISTS stream_count INT NULL COMMENT '活跃视频流数量' AFTER stream_stats;
|
||||
ALTER TABLE wvp_ai_edge_device ADD COLUMN IF NOT EXISTS config_version VARCHAR(64) NULL COMMENT '当前配置版本' AFTER stream_count;
|
||||
|
||||
-- 4. 算法参数模板表
|
||||
CREATE TABLE IF NOT EXISTS wvp_ai_algo_template (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
|
||||
16
数据库/aiot/迁移-添加camera_name字段.sql
Normal file
16
数据库/aiot/迁移-添加camera_name字段.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 摄像头命名改造迁移脚本
|
||||
-- 1. 新增 camera_name 字段
|
||||
-- 2. 存量数据:camera_name = CONCAT(app, stream)
|
||||
-- 3. 设置 NOT NULL
|
||||
|
||||
-- Step 1: 添加 camera_name 列
|
||||
ALTER TABLE wvp_stream_proxy ADD COLUMN camera_name VARCHAR(100) NULL AFTER camera_code;
|
||||
|
||||
-- Step 2: 存量数据迁移 — app + stream 合并为 camera_name
|
||||
UPDATE wvp_stream_proxy SET camera_name = CONCAT(IFNULL(app, ''), IFNULL(stream, '')) WHERE camera_name IS NULL;
|
||||
|
||||
-- Step 3: 设置 NOT NULL(确认数据填充后执行)
|
||||
ALTER TABLE wvp_stream_proxy MODIFY COLUMN camera_name VARCHAR(100) NOT NULL DEFAULT '';
|
||||
|
||||
-- 验证
|
||||
SELECT camera_code, camera_name, app, stream FROM wvp_stream_proxy LIMIT 20;
|
||||
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;
|
||||
Reference in New Issue
Block a user