Browse Source

Merge remote-tracking branch 'origin/dev/录制计划'

648540858 10 months ago
parent
commit
bcb54fa148
31 changed files with 1702 additions and 49 deletions
  1. 1 0
      src/main/java/com/genersoft/iot/vmp/gb28181/bean/CatalogData.java
  2. 3 0
      src/main/java/com/genersoft/iot/vmp/gb28181/bean/CommonGBChannel.java
  3. 21 0
      src/main/java/com/genersoft/iot/vmp/gb28181/controller/CommonChannelController.java
  4. 93 0
      src/main/java/com/genersoft/iot/vmp/gb28181/dao/CommonGBChannelMapper.java
  5. 6 0
      src/main/java/com/genersoft/iot/vmp/gb28181/dao/DeviceChannelMapper.java
  6. 32 0
      src/main/java/com/genersoft/iot/vmp/gb28181/dao/provider/ChannelProvider.java
  7. 3 0
      src/main/java/com/genersoft/iot/vmp/gb28181/service/IDeviceChannelService.java
  8. 3 0
      src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelService.java
  9. 10 0
      src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/DeviceChannelServiceImpl.java
  10. 12 0
      src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelServiceImpl.java
  11. 5 0
      src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/PlatformChannelServiceImpl.java
  12. 1 1
      src/main/java/com/genersoft/iot/vmp/gb28181/session/CatalogDataManager.java
  13. 2 0
      src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/message/response/cmd/BroadcastResponseMessageHandler.java
  14. 4 48
      src/main/java/com/genersoft/iot/vmp/media/zlm/dto/hook/HookResultForOnPublish.java
  15. 31 0
      src/main/java/com/genersoft/iot/vmp/service/IRecordPlanService.java
  16. 32 0
      src/main/java/com/genersoft/iot/vmp/service/bean/RecordPlan.java
  17. 25 0
      src/main/java/com/genersoft/iot/vmp/service/bean/RecordPlanItem.java
  18. 7 0
      src/main/java/com/genersoft/iot/vmp/service/impl/MediaServiceImpl.java
  19. 293 0
      src/main/java/com/genersoft/iot/vmp/service/impl/RecordPlanServiceImpl.java
  20. 67 0
      src/main/java/com/genersoft/iot/vmp/storager/dao/RecordPlanMapper.java
  21. 150 0
      src/main/java/com/genersoft/iot/vmp/vmanager/recordPlan/RecordPlanController.java
  22. 23 0
      src/main/java/com/genersoft/iot/vmp/vmanager/recordPlan/bean/RecordPlanParam.java
  23. 10 0
      src/main/resources/index.html
  24. 1 0
      web_src/package.json
  25. 236 0
      web_src/src/components/RecordPLan.vue
  26. 228 0
      web_src/src/components/dialog/editRecordPlan.vue
  27. 355 0
      web_src/src/components/dialog/linkChannelRecord.vue
  28. 1 0
      web_src/src/layout/UiHeader.vue
  29. 5 0
      web_src/src/router/index.js
  30. 21 0
      数据库/2.7.3/初始化-mysql-2.7.3.sql
  31. 21 0
      数据库/2.7.3/初始化-postgresql-kingbase-2.7.3.sql

+ 1 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/bean/CatalogData.java

@@ -20,6 +20,7 @@ public class CatalogData {
     private Device device;
     private String errorMsg;
     private Set<String> redisKeysForChannel = new HashSet<>();
+    private Set<String> errorChannel = new HashSet<>();
     private Set<String> redisKeysForRegion = new HashSet<>();
     private Set<String> redisKeysForGroup = new HashSet<>();
 

+ 3 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/bean/CommonGBChannel.java

@@ -126,6 +126,9 @@ public class CommonGBChannel {
     @Schema(description = "关联的国标设备数据库ID")
     private Integer gbDeviceDbId;
 
+    @Schema(description = "二进制保存的录制计划, 每一位表示每个小时的前半个小时")
+    private Long recordPLan;
+
     @Schema(description = "关联的推流Id(流来源是推流时有效)")
     private Integer streamPushId;
 

+ 21 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/controller/CommonChannelController.java

@@ -101,11 +101,31 @@ public class CommonChannelController {
         return channel;
     }
 
+    @Operation(summary = "获取通道列表", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "page", description = "当前页", required = true)
+    @Parameter(name = "count", description = "每页查询数量", required = true)
+    @Parameter(name = "query", description = "查询内容")
+    @Parameter(name = "online", description = "是否在线")
+    @Parameter(name = "hasRecordPlan", description = "是否已设置录制计划")
+    @Parameter(name = "channelType", description = "通道类型, 0:国标设备,1:推流设备,2:拉流代理")
+    @GetMapping("/list")
+    public PageInfo<CommonGBChannel> queryList(int page, int count,
+                                                          @RequestParam(required = false) String query,
+                                                          @RequestParam(required = false) Boolean online,
+                                                          @RequestParam(required = false) Boolean hasRecordPlan,
+                                                          @RequestParam(required = false) Integer channelType){
+        if (ObjectUtils.isEmpty(query)){
+            query = null;
+        }
+        return channelService.queryList(page, count, query, online, hasRecordPlan, channelType);
+    }
+
     @Operation(summary = "获取关联行政区划通道列表", security = @SecurityRequirement(name = JwtUtils.HEADER))
     @Parameter(name = "page", description = "当前页", required = true)
     @Parameter(name = "count", description = "每页查询数量", required = true)
     @Parameter(name = "query", description = "查询内容")
     @Parameter(name = "online", description = "是否在线")
+    @Parameter(name = "channelType", description = "通道类型, 0:国标设备,1:推流设备,2:拉流代理")
     @Parameter(name = "civilCode", description = "行政区划")
     @GetMapping("/civilcode/list")
     public PageInfo<CommonGBChannel> queryListByCivilCode(int page, int count,
@@ -124,6 +144,7 @@ public class CommonChannelController {
     @Parameter(name = "count", description = "每页查询数量", required = true)
     @Parameter(name = "query", description = "查询内容")
     @Parameter(name = "online", description = "是否在线")
+    @Parameter(name = "channelType", description = "通道类型, 0:国标设备,1:推流设备,2:拉流代理")
     @Parameter(name = "groupDeviceId", description = "业务分组下的父节点ID")
     @GetMapping("/parent/list")
     public PageInfo<CommonGBChannel> queryListByParentId(int page, int count,

+ 93 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/dao/CommonGBChannelMapper.java

@@ -457,4 +457,97 @@ public interface CommonGBChannelMapper {
             " </script>"})
     void updateGpsByDeviceIdForStreamPush(List<CommonGBChannel> channels);
 
+    @SelectProvider(type = ChannelProvider.class, method = "queryList")
+    List<CommonGBChannel> queryList(@Param("query") String query, @Param("online") Boolean online, @Param("hasRecordPlan") Boolean hasRecordPlan, @Param("channelType") Integer channelType);
+
+    @Update(value = {" <script>" +
+            " UPDATE wvp_device_channel " +
+            " SET record_plan_id = null" +
+            " WHERE id in "+
+            " <foreach collection='channelIds'  item='item'  open='(' separator=',' close=')' > #{item}</foreach>" +
+            " </script>"})
+    void removeRecordPlan(List<Integer> channelIds);
+
+    @Update(value = {" <script>" +
+            " UPDATE wvp_device_channel " +
+            " SET record_plan_id = #{planId}" +
+            " WHERE id in "+
+            " <foreach collection='channelIds'  item='item'  open='(' separator=',' close=')' > #{item}</foreach>" +
+            " </script>"})
+    void addRecordPlan(List<Integer> channelIds, @Param("planId") Integer planId);
+
+    @Update(value = {" <script>" +
+            " UPDATE wvp_device_channel " +
+            " SET record_plan_id = #{planId}" +
+            " </script>"})
+    void addRecordPlanForAll(@Param("planId") Integer planId);
+
+    @Update(value = {" <script>" +
+            " UPDATE wvp_device_channel " +
+            " SET record_plan_id = null" +
+            " WHERE record_plan_id = #{planId} "+
+            " </script>"})
+    void removeRecordPlanByPlanId( @Param("planId") Integer planId);
+
+
+    @Select("<script>" +
+            " select " +
+            "    wdc.id as gb_id,\n" +
+            "    wdc.device_db_id as gb_device_db_id,\n" +
+            "    wdc.stream_push_id,\n" +
+            "    wdc.stream_proxy_id,\n" +
+            "    wdc.create_time,\n" +
+            "    wdc.update_time,\n" +
+            "    wdc.record_plan_id,\n" +
+            "    coalesce( wdc.gb_device_id, wdc.device_id) as gb_device_id,\n" +
+            "    coalesce( wdc.gb_name, wdc.name) as gb_name,\n" +
+            "    coalesce( wdc.gb_manufacturer, wdc.manufacturer) as gb_manufacturer,\n" +
+            "    coalesce( wdc.gb_model, wdc.model) as gb_model,\n" +
+            "    coalesce( wdc.gb_owner, wdc.owner) as gb_owner,\n" +
+            "    coalesce( wdc.gb_civil_code, wdc.civil_code) as gb_civil_code,\n" +
+            "    coalesce( wdc.gb_block, wdc.block) as gb_block,\n" +
+            "    coalesce( wdc.gb_address, wdc.address) as gb_address,\n" +
+            "    coalesce( wdc.gb_parental, wdc.parental) as gb_parental,\n" +
+            "    coalesce( wdc.gb_parent_id, wdc.parent_id) as gb_parent_id,\n" +
+            "    coalesce( wdc.gb_safety_way, wdc.safety_way) as gb_safety_way,\n" +
+            "    coalesce( wdc.gb_register_way, wdc.register_way) as gb_register_way,\n" +
+            "    coalesce( wdc.gb_cert_num, wdc.cert_num) as gb_cert_num,\n" +
+            "    coalesce( wdc.gb_certifiable, wdc.certifiable) as gb_certifiable,\n" +
+            "    coalesce( wdc.gb_err_code, wdc.err_code) as gb_err_code,\n" +
+            "    coalesce( wdc.gb_end_time, wdc.end_time) as gb_end_time,\n" +
+            "    coalesce( wdc.gb_secrecy, wdc.secrecy) as gb_secrecy,\n" +
+            "    coalesce( wdc.gb_ip_address, wdc.ip_address) as gb_ip_address,\n" +
+            "    coalesce( wdc.gb_port, wdc.port) as gb_port,\n" +
+            "    coalesce( wdc.gb_password, wdc.password) as gb_password,\n" +
+            "    coalesce( wdc.gb_status, wdc.status) as gb_status,\n" +
+            "    coalesce( wdc.gb_longitude, wdc.longitude) as gb_longitude,\n" +
+            "    coalesce( wdc.gb_latitude, wdc.latitude) as gb_latitude,\n" +
+            "    coalesce( wdc.gb_ptz_type, wdc.ptz_type) as gb_ptz_type,\n" +
+            "    coalesce( wdc.gb_position_type, wdc.position_type) as gb_position_type,\n" +
+            "    coalesce( wdc.gb_room_type, wdc.room_type) as gb_room_type,\n" +
+            "    coalesce( wdc.gb_use_type, wdc.use_type) as gb_use_type,\n" +
+            "    coalesce( wdc.gb_supply_light_type, wdc.supply_light_type) as gb_supply_light_type,\n" +
+            "    coalesce( wdc.gb_direction_type, wdc.direction_type) as gb_direction_type,\n" +
+            "    coalesce( wdc.gb_resolution, wdc.resolution) as gb_resolution,\n" +
+            "    coalesce( wdc.gb_business_group_id, wdc.business_group_id) as gb_business_group_id,\n" +
+            "    coalesce( wdc.gb_download_speed, wdc.download_speed) as gb_download_speed,\n" +
+            "    coalesce( wdc.gb_svc_space_support_mod, wdc.svc_space_support_mod) as gb_svc_space_support_mod,\n" +
+            "    coalesce( wdc.gb_svc_time_support_mode, wdc.svc_time_support_mode) as gb_svc_time_support_mode \n" +
+            " from wvp_device_channel wdc" +
+            " where wdc.channel_type = 0 " +
+            " <if test='query != null'> " +
+            " AND (coalesce(wdc.gb_device_id, wdc.device_id) LIKE concat('%',#{query},'%') escape '/' " +
+            "      OR coalesce(wdc.gb_name, wdc.name)  LIKE concat('%',#{query},'%') escape '/')</if> " +
+            " <if test='online == true'> AND coalesce(wdc.gb_status, wdc.status) = 'ON'</if> " +
+            " <if test='online == false'> AND coalesce(wdc.gb_status, wdc.status) = 'OFF'</if> " +
+            " <if test='hasLink == true'> AND wdc.record_plan_id = #{planId}</if> " +
+            " <if test='hasLink == false'> AND wdc.record_plan_id is null</if> " +
+            " <if test='channelType == 0'> AND wdc.device_db_id is not null</if> " +
+            " <if test='channelType == 1'> AND wdc.stream_push_id is not null</if> " +
+            " <if test='channelType == 2'> AND wdc.stream_proxy_id is not null</if> " +
+            "</script>")
+    List<CommonGBChannel> queryForRecordPlanForWebList(@Param("planId") Integer planId, @Param("query") String query,
+                                                       @Param("channelType") Integer channelType, @Param("online") Boolean online,
+                                                       @Param("hasLink") Boolean hasLink);
+
 }

+ 6 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/dao/DeviceChannelMapper.java

@@ -93,6 +93,12 @@ public interface DeviceChannelMapper {
     @SelectProvider(type = DeviceChannelProvider.class, method = "queryChannelsByDeviceDbId")
     List<DeviceChannel> queryChannelsByDeviceDbId(@Param("deviceDbId") int deviceDbId);
 
+    @Select(value = {" <script> " +
+            "select id from wvp_device_channel where device_db_id in  " +
+            " <foreach item='item' index='index' collection='deviceDbIds' open='(' separator=',' close=')'> #{item} </foreach>" +
+            " </script>"})
+    List<Integer> queryChaneIdListByDeviceDbIds(List<Integer> deviceDbIds);
+
     @Delete("DELETE FROM wvp_device_channel WHERE device_db_id=#{deviceId}")
     int cleanChannelsByDeviceId(@Param("deviceId") int deviceId);
 

+ 32 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/dao/provider/ChannelProvider.java

@@ -17,6 +17,7 @@ public class ChannelProvider {
             "    stream_proxy_id,\n" +
             "    create_time,\n" +
             "    update_time,\n" +
+            "    record_plan_id,\n" +
             "    coalesce(gb_device_id, device_id) as gb_device_id,\n" +
             "    coalesce(gb_name, name) as gb_name,\n" +
             "    coalesce(gb_manufacturer, manufacturer) as gb_manufacturer,\n" +
@@ -182,6 +183,37 @@ public class ChannelProvider {
         return sqlBuild.toString();
     }
 
+    public String queryList(Map<String, Object> params ){
+        StringBuilder sqlBuild = new StringBuilder();
+        sqlBuild.append(BASE_SQL);
+        sqlBuild.append(" where channel_type = 0 ");
+        if (params.get("query") != null) {
+            sqlBuild.append(" AND (coalesce(gb_device_id, device_id) LIKE concat('%',#{query},'%') escape '/'" +
+                    " OR coalesce(gb_name, name) LIKE concat('%',#{query},'%') escape '/' )")
+            ;
+        }
+        if (params.get("online") != null && (Boolean)params.get("online")) {
+            sqlBuild.append(" AND coalesce(gb_status, status) = 'ON'");
+        }
+        if (params.get("online") != null && !(Boolean)params.get("online")) {
+            sqlBuild.append(" AND coalesce(gb_status, status) = 'OFF'");
+        }
+        if (params.get("hasRecordPlan") != null && (Boolean)params.get("hasRecordPlan")) {
+            sqlBuild.append(" AND record_plan_id > 0");
+        }
+
+        if (params.get("channelType") != null) {
+            if ((Integer)params.get("channelType") == 0) {
+                sqlBuild.append(" AND device_db_id is not null");
+            }else if ((Integer)params.get("channelType") == 1) {
+                sqlBuild.append(" AND stream_push_id is not null");
+            }else if ((Integer)params.get("channelType") == 2) {
+                sqlBuild.append(" AND stream_proxy_id is not null");
+            }
+        }
+        return sqlBuild.toString();
+    }
+
     public String queryInListByStatus(Map<String, Object> params ){
         StringBuilder sqlBuild = new StringBuilder();
         sqlBuild.append(BASE_SQL);

+ 3 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/service/IDeviceChannelService.java

@@ -122,4 +122,7 @@ public interface IDeviceChannelService {
 
     DeviceChannel getOneBySourceId(int deviceDbId, String channelId);
 
+    List<DeviceChannel> queryChaneListByDeviceDbId(Integer deviceDbId);
+
+    List<Integer> queryChaneIdListByDeviceDbIds(List<Integer> deviceDbId);
 }

+ 3 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelService.java

@@ -84,4 +84,7 @@ public interface IGbChannelService {
     List<CommonGBChannel> queryListByStreamPushList(List<StreamPush> streamPushList);
 
     void updateGpsByDeviceIdForStreamPush(List<CommonGBChannel> channels);
+
+    PageInfo<CommonGBChannel> queryList(int page, int count, String query, Boolean online, Boolean hasRecordPlan, Integer channelType);
+
 }

+ 10 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/DeviceChannelServiceImpl.java

@@ -350,6 +350,16 @@ public class DeviceChannelServiceImpl implements IDeviceChannelService {
         return channelMapper.queryChannelsByDeviceDbId(device.getId());
     }
 
+    @Override
+    public List<DeviceChannel> queryChaneListByDeviceDbId(Integer deviceDbId) {
+        return channelMapper.queryChannelsByDeviceDbId(deviceDbId);
+    }
+
+    @Override
+    public List<Integer> queryChaneIdListByDeviceDbIds(List<Integer> deviceDbIds) {
+        return channelMapper.queryChaneIdListByDeviceDbIds(deviceDbIds);
+    }
+
     @Override
     public void updateChannelGPS(Device device, DeviceChannel deviceChannel, MobilePosition mobilePosition) {
         if (userSetting.getSavePositionHistory()) {

+ 12 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelServiceImpl.java

@@ -714,4 +714,16 @@ public class GbChannelServiceImpl implements IGbChannelService {
     public void updateGpsByDeviceIdForStreamPush(List<CommonGBChannel> channels) {
         commonGBChannelMapper.updateGpsByDeviceIdForStreamPush(channels);
     }
+
+    @Override
+    public PageInfo<CommonGBChannel> queryList(int page, int count, String query, Boolean online, Boolean hasRecordPlan, Integer channelType) {
+        PageHelper.startPage(page, count);
+        if (query != null) {
+            query = query.replaceAll("/", "//")
+                    .replaceAll("%", "/%")
+                    .replaceAll("_", "/_");
+        }
+        List<CommonGBChannel> all = commonGBChannelMapper.queryList(query, online,  hasRecordPlan, channelType);
+        return new PageInfo<>(all);
+    }
 }

+ 5 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/PlatformChannelServiceImpl.java

@@ -55,6 +55,11 @@ public class PlatformChannelServiceImpl implements IPlatformChannelService {
     @Override
     public PageInfo<PlatformChannel> queryChannelList(int page, int count, String query, Integer channelType, Boolean online, Integer platformId, Boolean hasShare) {
         PageHelper.startPage(page, count);
+        if (query != null) {
+            query = query.replaceAll("/", "//")
+                    .replaceAll("%", "/%")
+                    .replaceAll("_", "/_");
+        }
         List<PlatformChannel> all = platformChannelMapper.queryForPlatformForWebList(platformId, query, channelType, online, hasShare);
         return new PageInfo<>(all);
     }

+ 1 - 1
src/main/java/com/genersoft/iot/vmp/gb28181/session/CatalogDataManager.java

@@ -283,7 +283,7 @@ public class CatalogDataManager implements CommandLineRunner {
         if (catalogData == null) {
             return 0;
         }
-        return catalogData.getRedisKeysForChannel().size();
+        return catalogData.getRedisKeysForChannel().size() + catalogData.getErrorChannel().size();
     }
 
     public int sumNum(String deviceId, int sn) {

+ 2 - 0
src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/message/response/cmd/BroadcastResponseMessageHandler.java

@@ -94,4 +94,6 @@ public class BroadcastResponseMessageHandler extends SIPRequestProcessorParent i
     public void handForPlatform(RequestEvent evt, Platform parentPlatform, Element element) {
 
     }
+
+
 }

+ 4 - 48
src/main/java/com/genersoft/iot/vmp/media/zlm/dto/hook/HookResultForOnPublish.java

@@ -1,7 +1,11 @@
 package com.genersoft.iot.vmp.media.zlm.dto.hook;
 
 import com.genersoft.iot.vmp.media.bean.ResultForOnPublish;
+import lombok.Getter;
+import lombok.Setter;
 
+@Setter
+@Getter
 public class HookResultForOnPublish extends HookResult{
 
     private boolean enable_audio;
@@ -34,54 +38,6 @@ public class HookResultForOnPublish extends HookResult{
         setMsg(msg);
     }
 
-    public boolean isEnable_audio() {
-        return enable_audio;
-    }
-
-    public void setEnable_audio(boolean enable_audio) {
-        this.enable_audio = enable_audio;
-    }
-
-    public boolean isEnable_mp4() {
-        return enable_mp4;
-    }
-
-    public void setEnable_mp4(boolean enable_mp4) {
-        this.enable_mp4 = enable_mp4;
-    }
-
-    public int getMp4_max_second() {
-        return mp4_max_second;
-    }
-
-    public void setMp4_max_second(int mp4_max_second) {
-        this.mp4_max_second = mp4_max_second;
-    }
-
-    public String getMp4_save_path() {
-        return mp4_save_path;
-    }
-
-    public void setMp4_save_path(String mp4_save_path) {
-        this.mp4_save_path = mp4_save_path;
-    }
-
-    public String getStream_replace() {
-        return stream_replace;
-    }
-
-    public void setStream_replace(String stream_replace) {
-        this.stream_replace = stream_replace;
-    }
-
-    public Integer getModify_stamp() {
-        return modify_stamp;
-    }
-
-    public void setModify_stamp(Integer modify_stamp) {
-        this.modify_stamp = modify_stamp;
-    }
-
     @Override
     public String toString() {
         return "HookResultForOnPublish{" +

+ 31 - 0
src/main/java/com/genersoft/iot/vmp/service/IRecordPlanService.java

@@ -0,0 +1,31 @@
+package com.genersoft.iot.vmp.service;
+
+import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel;
+import com.genersoft.iot.vmp.service.bean.RecordPlan;
+import com.github.pagehelper.PageInfo;
+
+import java.util.List;
+
+public interface IRecordPlanService {
+
+
+    RecordPlan get(Integer planId);
+
+    void update(RecordPlan plan);
+
+    void delete(Integer planId);
+
+    PageInfo<RecordPlan> query(Integer page, Integer count, String query);
+
+    void add(RecordPlan plan);
+
+    void link(List<Integer> channelIds, Integer planId);
+
+    PageInfo<CommonGBChannel> queryChannelList(int page, int count, String query, Integer channelType, Boolean online, Integer planId, Boolean hasLink);
+
+    void linkAll(Integer planId);
+
+    void cleanAll(Integer planId);
+
+    Integer recording(String app, String stream);
+}

+ 32 - 0
src/main/java/com/genersoft/iot/vmp/service/bean/RecordPlan.java

@@ -0,0 +1,32 @@
+package com.genersoft.iot.vmp.service.bean;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(description = "录制计划")
+public class RecordPlan {
+
+    @Schema(description = "计划数据库ID")
+    private int id;
+
+    @Schema(description = "计划名称")
+    private String name;
+
+    @Schema(description = "计划关联通道数量")
+    private int channelCount;
+
+    @Schema(description = "是否开启定时截图")
+    private Boolean snap;
+
+    @Schema(description = "创建时间")
+    private String createTime;
+
+    @Schema(description = "更新时间")
+    private String updateTime;
+
+    @Schema(description = "计划内容")
+    private List<RecordPlanItem> planItemList;
+}

+ 25 - 0
src/main/java/com/genersoft/iot/vmp/service/bean/RecordPlanItem.java

@@ -0,0 +1,25 @@
+package com.genersoft.iot.vmp.service.bean;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "录制计划项")
+public class RecordPlanItem {
+
+    @Schema(description = "计划项数据库ID")
+    private int id;
+
+    @Schema(description = "计划开始时间的序号, 从0点开始,每半个小时增加1")
+    private Integer start;
+
+    @Schema(description = "计划结束时间的序号, 从0点开始,每半个小时增加1")
+    private Integer stop;
+
+    @Schema(description = "计划周几执行")
+    private Integer weekDay;
+
+    @Schema(description = "所属计划ID")
+    private Integer planId;
+
+}

+ 7 - 0
src/main/java/com/genersoft/iot/vmp/service/impl/MediaServiceImpl.java

@@ -15,6 +15,7 @@ import com.genersoft.iot.vmp.media.bean.MediaServer;
 import com.genersoft.iot.vmp.media.bean.ResultForOnPublish;
 import com.genersoft.iot.vmp.media.zlm.dto.StreamAuthorityInfo;
 import com.genersoft.iot.vmp.service.IMediaService;
+import com.genersoft.iot.vmp.service.IRecordPlanService;
 import com.genersoft.iot.vmp.service.IUserService;
 import com.genersoft.iot.vmp.storager.IRedisCatchStorage;
 import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
@@ -59,6 +60,9 @@ public class MediaServiceImpl implements IMediaService {
     @Autowired
     private SipInviteSessionManager sessionManager;
 
+    @Autowired
+    private IRecordPlanService recordPlanService;
+
     @Override
     public boolean authenticatePlay(String app, String stream, String callId) {
         if (app == null || stream == null) {
@@ -205,6 +209,9 @@ public class MediaServiceImpl implements IMediaService {
     @Override
     public boolean closeStreamOnNoneReader(String mediaServerId, String app, String stream, String schema) {
         boolean result = false;
+        if (recordPlanService.recording(app, stream) != null) {
+            return false;
+        }
         // 国标类型的流
         if ("rtp".equals(app)) {
             result = userSetting.getStreamOnDemand();

+ 293 - 0
src/main/java/com/genersoft/iot/vmp/service/impl/RecordPlanServiceImpl.java

@@ -0,0 +1,293 @@
+package com.genersoft.iot.vmp.service.impl;
+
+import com.genersoft.iot.vmp.common.StreamInfo;
+import com.genersoft.iot.vmp.conf.exception.ControllerException;
+import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel;
+import com.genersoft.iot.vmp.gb28181.dao.CommonGBChannelMapper;
+import com.genersoft.iot.vmp.gb28181.service.IGbChannelPlayService;
+import com.genersoft.iot.vmp.media.bean.MediaInfo;
+import com.genersoft.iot.vmp.media.event.media.MediaDepartureEvent;
+import com.genersoft.iot.vmp.media.service.IMediaServerService;
+import com.genersoft.iot.vmp.service.IRecordPlanService;
+import com.genersoft.iot.vmp.service.bean.InviteErrorCode;
+import com.genersoft.iot.vmp.service.bean.RecordPlan;
+import com.genersoft.iot.vmp.service.bean.RecordPlanItem;
+import com.genersoft.iot.vmp.storager.dao.RecordPlanMapper;
+import com.genersoft.iot.vmp.utils.DateUtil;
+import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
+import com.google.common.base.Joiner;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@Slf4j
+public class RecordPlanServiceImpl implements IRecordPlanService {
+
+    @Autowired
+    private RecordPlanMapper recordPlanMapper;
+
+    @Autowired
+    private CommonGBChannelMapper channelMapper;
+
+    @Autowired
+    private IGbChannelPlayService channelPlayService;
+
+    @Autowired
+    private IMediaServerService mediaServerService;
+
+
+
+    /**
+     * 流离开的处理
+     */
+    @Async("taskExecutor")
+    @EventListener
+    public void onApplicationEvent(MediaDepartureEvent event) {
+        // 流断开,检查是否还处于录像状态, 如果是则继续录像
+        Integer channelId = recording(event.getApp(), event.getStream());
+        if(channelId == null) {
+            return;
+        }
+        // 重新拉起
+        CommonGBChannel channel = channelMapper.queryById(channelId);
+        if (channel == null) {
+            log.warn("[录制计划] 流离开时拉起需要录像的流时, 发现通道不存在, id: {}", channelId);
+            return;
+        }
+        // 开启点播,
+        channelPlayService.play(channel, null, ((code, msg, streamInfo) -> {
+            if (code == InviteErrorCode.SUCCESS.getCode() && streamInfo != null) {
+                log.info("[录像] 流离开时拉起需要录像的流, 开启成功, 通道ID: {}", channel.getGbId());
+                recordStreamMap.put(channel.getGbId(), streamInfo);
+            } else {
+                recordStreamMap.remove(channelId);
+                log.info("[录像] 流离开时拉起需要录像的流, 开启失败, 十分钟后重试,  通道ID: {}", channel.getGbId());
+            }
+        }));
+    }
+
+    Map<Integer, StreamInfo> recordStreamMap = new HashMap<>();
+
+//    @Scheduled(cron = "0 */30 * * * *")
+    @Scheduled(fixedRate = 10, timeUnit = TimeUnit.MINUTES)
+    public void execution() {
+        log.info("[录制计划] 执行");
+        // 查询现在需要录像的通道Id
+        List<Integer> startChannelIdList = queryCurrentChannelRecord();
+
+        if (startChannelIdList.isEmpty()) {
+            // 当前没有录像任务, 如果存在旧的正在录像的就移除
+            if(!recordStreamMap.isEmpty()) {
+                stopStreams(recordStreamMap.keySet(), recordStreamMap);
+                recordStreamMap.clear();
+            }
+        }else {
+            // 当前存在录像任务, 获取正在录像中存在但是当前录制列表不存在的内容,进行停止; 获取正在录像中没有但是当前需录制的列表中存在的进行开启.
+            Set<Integer> recordStreamSet = new HashSet<>(recordStreamMap.keySet());
+            startChannelIdList.forEach(recordStreamSet::remove);
+            if (!recordStreamSet.isEmpty()) {
+                // 正在录像中存在但是当前录制列表不存在的内容,进行停止;
+                stopStreams(recordStreamSet, recordStreamMap);
+            }
+
+            // 移除startChannelIdList中已经在录像的部分, 剩下的都是需要新添加的(正在录像中没有但是当前需录制的列表中存在的进行开启)
+            recordStreamMap.keySet().forEach(startChannelIdList::remove);
+            if (!startChannelIdList.isEmpty()) {
+                // 获取所有的关联的通道
+                List<CommonGBChannel> channelList = channelMapper.queryByIds(startChannelIdList);
+                if (!channelList.isEmpty()) {
+                    // 查找是否已经开启录像, 如果没有则开启录像
+                    for (CommonGBChannel channel : channelList) {
+                        // 开启点播,
+                        channelPlayService.play(channel, null, ((code, msg, streamInfo) -> {
+                            if (code == InviteErrorCode.SUCCESS.getCode() && streamInfo != null) {
+                                log.info("[录像] 开启成功, 通道ID: {}", channel.getGbId());
+                                recordStreamMap.put(channel.getGbId(), streamInfo);
+                            } else {
+                                log.info("[录像] 开启失败, 十分钟后重试,  通道ID: {}", channel.getGbId());
+                            }
+                        }));
+                    }
+                } else {
+                    log.error("[录制计划] 数据异常, 这些关联的通道已经不存在了: {}", Joiner.on(",").join(startChannelIdList));
+                }
+            }
+        }
+    }
+
+    /**
+     * 获取当前时间段应该录像的通道Id列表
+     */
+    private List<Integer> queryCurrentChannelRecord(){
+        // 获取当前时间在一周内的序号, 数据库存储的从第几个30分钟开始, 0-47, 包括首尾
+        LocalDateTime now = LocalDateTime.now();
+        int week = now.getDayOfWeek().getValue();
+        int index = now.getHour() * 2 + (now.getMinute() > 30?1:0);
+
+        // 查询现在需要录像的通道Id
+        return recordPlanMapper.queryRecordIng(week, index);
+    }
+
+    private void stopStreams(Collection<Integer> channelIds, Map<Integer, StreamInfo> recordStreamMap) {
+        for (Integer channelId : channelIds) {
+            try {
+                StreamInfo streamInfo = recordStreamMap.get(channelId);
+                if (streamInfo == null) {
+                    continue;
+                }
+                // 查看是否有人观看,存在则不做处理,等待后续自然处理,如果无人观看,则关闭该流
+                MediaInfo mediaInfo = mediaServerService.getMediaInfo(streamInfo.getMediaServer(), streamInfo.getApp(), streamInfo.getStream());
+                if (mediaInfo.getReaderCount() == null ||  mediaInfo.getReaderCount() == 0) {
+                    mediaServerService.closeStreams(streamInfo.getMediaServer(), streamInfo.getApp(), streamInfo.getStream());
+                    log.info("[录制计划] 停止, 通道ID: {}", channelId);
+                }
+            }catch (Exception e) {
+                log.error("[录制计划] 停止时异常", e);
+            }finally {
+                recordStreamMap.remove(channelId);
+            }
+        }
+    }
+
+    @Override
+    public Integer recording(String app, String stream) {
+        for (Integer channelId : recordStreamMap.keySet()) {
+            StreamInfo streamInfo = recordStreamMap.get(channelId);
+            if (streamInfo != null && streamInfo.getApp().equals(app) && streamInfo.getStream().equals(stream)) {
+                return channelId;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    @Transactional
+    public void add(RecordPlan plan) {
+        plan.setCreateTime(DateUtil.getNow());
+        plan.setUpdateTime(DateUtil.getNow());
+        recordPlanMapper.add(plan);
+        if (plan.getId() > 0 && !plan.getPlanItemList().isEmpty()) {
+            for (RecordPlanItem recordPlanItem : plan.getPlanItemList()) {
+                recordPlanItem.setPlanId(plan.getId());
+            }
+            recordPlanMapper.batchAddItem(plan.getId(), plan.getPlanItemList());
+        }
+        // TODO  更新录像队列
+    }
+
+    @Override
+    public RecordPlan get(Integer planId) {
+        RecordPlan recordPlan = recordPlanMapper.get(planId);
+        if (recordPlan == null) {
+            return null;
+        }
+        List<RecordPlanItem> recordPlanItemList = recordPlanMapper.getItemList(planId);
+        if (!recordPlanItemList.isEmpty()) {
+            recordPlan.setPlanItemList(recordPlanItemList);
+        }
+        return recordPlan;
+    }
+
+    @Override
+    @Transactional
+    public void update(RecordPlan plan) {
+        plan.setUpdateTime(DateUtil.getNow());
+        recordPlanMapper.update(plan);
+        recordPlanMapper.cleanItems(plan.getId());
+        if (plan.getPlanItemList() != null && !plan.getPlanItemList().isEmpty()){
+            List<RecordPlanItem> planItemList = new ArrayList<>();
+            for (RecordPlanItem recordPlanItem : plan.getPlanItemList()) {
+                if (recordPlanItem.getStart() == null || recordPlanItem.getStop() == null || recordPlanItem.getWeekDay() == null){
+                    continue;
+                }
+                if (recordPlanItem.getPlanId() == null) {
+                    recordPlanItem.setPlanId(plan.getId());
+                }
+                planItemList.add(recordPlanItem);
+            }
+            if(!planItemList.isEmpty()) {
+                recordPlanMapper.batchAddItem(plan.getId(), planItemList);
+            }
+        }
+        // TODO  更新录像队列
+       
+    }
+
+    @Override
+    @Transactional
+    public void delete(Integer planId) {
+        RecordPlan recordPlan = recordPlanMapper.get(planId);
+        if (recordPlan == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "录制计划不存在");
+        }
+        // 清理关联的通道
+        channelMapper.removeRecordPlanByPlanId(recordPlan.getId());
+        recordPlanMapper.cleanItems(planId);
+        recordPlanMapper.delete(planId);
+        // TODO  更新录像队列
+    }
+
+    @Override
+    public PageInfo<RecordPlan> query(Integer page, Integer count, String query) {
+        PageHelper.startPage(page, count);
+        if (query != null) {
+            query = query.replaceAll("/", "//")
+                    .replaceAll("%", "/%")
+                    .replaceAll("_", "/_");
+        }
+        List<RecordPlan> all = recordPlanMapper.query(query);
+        return new PageInfo<>(all);
+    }
+
+    @Override
+    public void link(List<Integer> channelIds, Integer planId) {
+        if (channelIds == null || channelIds.isEmpty()) {
+            log.info("[录制计划] 关联/移除关联时, 通道编号必须存在");
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "通道编号必须存在");
+        }
+        if (planId == null) {
+            channelMapper.removeRecordPlan(channelIds);
+        }else {
+            channelMapper.addRecordPlan(channelIds, planId);
+        }
+        // 查看当前的待录制列表是否变化,如果变化,则调用录制计划马上开始录制
+        List<Integer> currentChannelRecord = queryCurrentChannelRecord();
+        recordStreamMap.keySet().forEach(currentChannelRecord::remove);
+        if (!currentChannelRecord.isEmpty()) {
+            execution();
+        }
+    }
+
+    @Override
+    public PageInfo<CommonGBChannel> queryChannelList(int page, int count, String query, Integer channelType, Boolean online, Integer planId, Boolean hasLink) {
+        PageHelper.startPage(page, count);
+        if (query != null) {
+            query = query.replaceAll("/", "//")
+                    .replaceAll("%", "/%")
+                    .replaceAll("_", "/_");
+        }
+        List<CommonGBChannel> all = channelMapper.queryForRecordPlanForWebList(planId, query, channelType, online, hasLink);
+        return new PageInfo<>(all);
+    }
+
+    @Override
+    public void linkAll(Integer planId) {
+        channelMapper.addRecordPlanForAll(planId);
+    }
+
+    @Override
+    public void cleanAll(Integer planId) {
+        channelMapper.removeRecordPlanByPlanId(planId);
+    }
+}

+ 67 - 0
src/main/java/com/genersoft/iot/vmp/storager/dao/RecordPlanMapper.java

@@ -0,0 +1,67 @@
+package com.genersoft.iot.vmp.storager.dao;
+
+import com.genersoft.iot.vmp.service.bean.RecordPlan;
+import com.genersoft.iot.vmp.service.bean.RecordPlanItem;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+
+@Mapper
+public interface RecordPlanMapper {
+
+    @Insert(" <script>" +
+            "INSERT INTO wvp_record_plan (" +
+            " name," +
+            " snap," +
+            " create_time," +
+            " update_time) " +
+            "VALUES (" +
+            " #{name}," +
+            " #{snap}," +
+            " #{createTime}," +
+            " #{updateTime})" +
+            " </script>")
+    @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
+    void add(RecordPlan plan);
+
+    @Insert(" <script>" +
+            "INSERT INTO wvp_record_plan_item (" +
+            "start," +
+            "stop, " +
+            "week_day," +
+            "plan_id) " +
+            "VALUES" +
+            "<foreach collection='planItemList' index='index' item='item' separator=','> " +
+            "(#{item.start}, #{item.stop}, #{item.weekDay},#{planId})" +
+            "</foreach> " +
+            " </script>")
+    void batchAddItem(@Param("planId") int planId, List<RecordPlanItem> planItemList);
+
+    @Select("select * from wvp_record_plan where  id = #{planId}")
+    RecordPlan get(@Param("planId") Integer planId);
+
+    @Select(" <script>" +
+            " SELECT wrp.*, (select count(1) from wvp_device_channel where record_plan_id = wrp.id) AS channelCount\n" +
+            " FROM wvp_record_plan wrp where  1=1" +
+            " <if test='query != null'> AND (name LIKE concat('%',#{query},'%') escape '/' )</if> " +
+            " </script>")
+    List<RecordPlan> query(@Param("query") String query);
+
+    @Update("UPDATE wvp_record_plan SET update_time=#{updateTime}, name=#{name}, snap=#{snap} WHERE id=#{id}")
+    void update(RecordPlan plan);
+
+    @Delete("DELETE FROM wvp_record_plan WHERE id=#{planId}")
+    void delete(@Param("planId") Integer planId);
+
+    @Select("select * from wvp_record_plan_item where  plan_id = #{planId}")
+    List<RecordPlanItem> getItemList(@Param("planId") Integer planId);
+
+    @Delete("DELETE FROM wvp_record_plan_item WHERE plan_id = #{planId}")
+    void cleanItems(@Param("planId") Integer planId);
+
+    @Select(" <script>" +
+            " select wdc.id from wvp_device_channel wdc left join wvp_record_plan_item wrpi on wrpi.plan_id = wdc.record_plan_id " +
+            " where  wrpi.week_day = #{week} and wrpi.start &lt;= #{index} and stop &gt;= #{index} group by wdc.id" +
+            " </script>")
+    List<Integer> queryRecordIng(@Param("week") int week, @Param("index") int index);
+}

+ 150 - 0
src/main/java/com/genersoft/iot/vmp/vmanager/recordPlan/RecordPlanController.java

@@ -0,0 +1,150 @@
+package com.genersoft.iot.vmp.vmanager.recordPlan;
+
+import com.genersoft.iot.vmp.conf.exception.ControllerException;
+import com.genersoft.iot.vmp.conf.security.JwtUtils;
+import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel;
+import com.genersoft.iot.vmp.gb28181.service.IDeviceChannelService;
+import com.genersoft.iot.vmp.service.IRecordPlanService;
+import com.genersoft.iot.vmp.service.bean.RecordPlan;
+import com.genersoft.iot.vmp.vmanager.bean.ErrorCode;
+import com.genersoft.iot.vmp.vmanager.recordPlan.bean.RecordPlanParam;
+import com.github.pagehelper.PageInfo;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Tag(name = "录制计划")
+@Slf4j
+@RestController
+@RequestMapping("/api/record/plan")
+public class RecordPlanController {
+
+    @Autowired
+    private IRecordPlanService recordPlanService;
+
+    @Autowired
+    private IDeviceChannelService deviceChannelService;
+
+
+    @ResponseBody
+    @PostMapping("/add")
+    @Operation(summary = "添加录制计划", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "plan", description = "计划", required = true)
+    public void add(@RequestBody RecordPlan plan) {
+        if (plan.getPlanItemList() == null || plan.getPlanItemList().isEmpty()) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "添加录制计划时,录制计划不可为空");
+        }
+        recordPlanService.add(plan);
+    }
+
+    @ResponseBody
+    @PostMapping("/link")
+    @Operation(summary = "通道关联录制计划", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "param", description = "通道关联录制计划", required = true)
+    public void link(@RequestBody RecordPlanParam param) {
+        if (param.getAllLink() != null) {
+            if (param.getAllLink()) {
+                recordPlanService.linkAll(param.getPlanId());
+            }else {
+                recordPlanService.cleanAll(param.getPlanId());
+            }
+            return;
+        }
+
+        if (param.getChannelIds() == null && param.getDeviceDbIds() == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "通道ID和国标设备ID不可都为NULL");
+        }
+
+        List<Integer> channelIds = new ArrayList<>();
+        if (param.getChannelIds() != null) {
+            channelIds.addAll(param.getChannelIds());
+        }else {
+            List<Integer> chanelIdList = deviceChannelService.queryChaneIdListByDeviceDbIds(param.getDeviceDbIds());
+            if (chanelIdList != null && !chanelIdList.isEmpty()) {
+                channelIds = chanelIdList;
+            }
+        }
+        recordPlanService.link(channelIds, param.getPlanId());
+    }
+
+    @ResponseBody
+    @GetMapping("/get")
+    @Operation(summary = "查询录制计划", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "planId", description = "计划ID", required = true)
+    public RecordPlan get(Integer planId) {
+        if (planId == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "计划ID不可为NULL");
+        }
+        return recordPlanService.get(planId);
+    }
+
+    @ResponseBody
+    @GetMapping("/query")
+    @Operation(summary = "查询录制计划列表", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "query", description = "检索内容", required = false)
+    @Parameter(name = "page", description = "当前页", required = true)
+    @Parameter(name = "count", description = "每页查询数量", required = true)
+    public PageInfo<RecordPlan> query(@RequestParam(required = false) String query, @RequestParam Integer page, @RequestParam Integer count) {
+        if (query != null && ObjectUtils.isEmpty(query.trim())) {
+            query = null;
+        }
+        return recordPlanService.query(page, count, query);
+    }
+
+    @Operation(summary = "分页查询级联平台的所有所有通道", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "page", description = "当前页", required = true)
+    @Parameter(name = "count", description = "每页条数", required = true)
+    @Parameter(name = "planId", description = "录制计划ID")
+    @Parameter(name = "channelType", description = "通道类型, 0:国标设备,1:推流设备,2:拉流代理")
+    @Parameter(name = "query", description = "查询内容")
+    @Parameter(name = "online", description = "是否在线")
+    @Parameter(name = "hasLink", description = "是否已经关联")
+    @GetMapping("/channel/list")
+    @ResponseBody
+    public PageInfo<CommonGBChannel> queryChannelList(int page, int count,
+                                                      @RequestParam(required = false) Integer planId,
+                                                      @RequestParam(required = false) String query,
+                                                      @RequestParam(required = false) Integer channelType,
+                                                      @RequestParam(required = false) Boolean online,
+                                                      @RequestParam(required = false) Boolean hasLink) {
+
+        Assert.notNull(planId, "录制计划ID不可为NULL");
+        if (org.springframework.util.ObjectUtils.isEmpty(query)) {
+            query = null;
+        }
+
+        return recordPlanService.queryChannelList(page, count, query, channelType,  online, planId, hasLink);
+    }
+
+    @ResponseBody
+    @PostMapping("/update")
+    @Operation(summary = "更新录制计划", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "plan", description = "计划", required = true)
+    public void update(@RequestBody RecordPlan plan) {
+        if (plan == null || plan.getId() == 0) {
+            throw new ControllerException(ErrorCode.ERROR400);
+        }
+        recordPlanService.update(plan);
+    }
+
+    @ResponseBody
+    @DeleteMapping("/delete")
+    @Operation(summary = "删除录制计划", security = @SecurityRequirement(name = JwtUtils.HEADER))
+    @Parameter(name = "planId", description = "计划ID", required = true)
+    public void delete(Integer planId) {
+        if (planId == null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "计划IDID不可为NULL");
+        }
+        recordPlanService.delete(planId);
+    }
+
+}

+ 23 - 0
src/main/java/com/genersoft/iot/vmp/vmanager/recordPlan/bean/RecordPlanParam.java

@@ -0,0 +1,23 @@
+package com.genersoft.iot.vmp.vmanager.recordPlan.bean;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema(description = "录制计划-添加/编辑参数")
+public class RecordPlanParam {
+
+    @Schema(description = "关联的通道ID")
+    private List<Integer> channelIds;
+
+    @Schema(description = "关联的设备ID,会为设备下的所有通道关联此录制计划,channelId存在是此项不生效,")
+    private List<Integer> deviceDbIds;
+
+    @Schema(description = "全部关联/全部取消关联")
+    private Boolean allLink;
+
+    @Schema(description = "录制计划ID, ID为空是删除关联的计划")
+    private Integer planId;
+}

+ 10 - 0
src/main/resources/index.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Title</title>
+</head>
+<body>
+111
+</body>
+</html>

+ 1 - 0
web_src/package.json

@@ -15,6 +15,7 @@
     "@liveqing/liveplayer": "^2.7.10",
     "@wchbrad/vue-easy-tree": "^1.0.12",
     "axios": "^0.24.0",
+    "byte-weektime-picker": "^1.1.1",
     "core-js": "^2.6.5",
     "echarts": "^4.9.0",
     "element-ui": "^2.15.14",

+ 236 - 0
web_src/src/components/RecordPLan.vue

@@ -0,0 +1,236 @@
+<template>
+  <div id="recordPLan" style="width: 100%">
+    <div class="page-header">
+        <div class="page-title">
+          <div >录像计划</div>
+        </div>
+        <div class="page-header-btn">
+          <div style="display: inline;">
+            搜索:
+            <el-input @input="search" style="margin-right: 1rem; width: auto;" size="mini" placeholder="关键字"
+                      prefix-icon="el-icon-search" v-model="searchSrt" clearable></el-input>
+            <el-button size="mini" type="primary" @click="add()">
+              添加
+            </el-button>
+            <el-button icon="el-icon-refresh-right" circle size="mini" @click="getRecordPlanList()"></el-button>
+          </div>
+        </div>
+      </div>
+      <el-table size="medium" ref="recordPlanListTable" :data="recordPlanList" :height="winHeight" style="width: 100%"
+                header-row-class-name="table-header" >
+        <el-table-column type="selection" width="55" >
+        </el-table-column>
+        <el-table-column prop="name" label="名称" >
+        </el-table-column>
+        <el-table-column prop="channelCount" label="关联通道" >
+        </el-table-column>
+        <el-table-column prop="updateTime" label="更新时间">
+        </el-table-column>
+        <el-table-column prop="createTime" label="创建时间">
+        </el-table-column>
+        <el-table-column label="操作" width="300" fixed="right">
+          <template v-slot:default="scope">
+            <el-button size="medium" icon="el-icon-link" type="text" @click="link(scope.row)">关联通道</el-button>
+            <el-button size="medium" icon="el-icon-edit" type="text" @click="edit(scope.row)">编辑</el-button>
+            <el-button size="medium" icon="el-icon-delete" style="color: #f56c6c" type="text" @click="deletePlan(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="text-align: right"
+        @size-change="handleSizeChange"
+        @current-change="currentChange"
+        :current-page="currentPage"
+        :page-size="count"
+        :page-sizes="[15, 25, 35, 50]"
+        layout="total, sizes, prev, pager, next"
+        :total="total">
+      </el-pagination>
+    <editRecordPlan ref="editRecordPlan"></editRecordPlan>
+    <LinkChannelRecord ref="linkChannelRecord"></LinkChannelRecord>
+  </div>
+</template>
+
+<script>
+import uiHeader from '../layout/UiHeader.vue'
+import EditRecordPlan from "./dialog/editRecordPlan.vue";
+import LinkChannelRecord from "./dialog/linkChannelRecord.vue";
+
+export default {
+  name: 'recordPLan',
+  components: {
+    EditRecordPlan,
+    LinkChannelRecord,
+    uiHeader,
+  },
+  data() {
+    return {
+      recordPlanList: [],
+      searchSrt: "",
+      winHeight: window.innerHeight - 180,
+      currentPage: 1,
+      count: 15,
+      total: 0,
+      loading: false,
+    };
+  },
+
+  created() {
+    this.initData();
+  },
+  destroyed() {
+  },
+  methods: {
+    initData: function () {
+      this.getRecordPlanList();
+    },
+    currentChange: function (val) {
+      this.currentPage = val;
+      this.initData();
+    },
+    handleSizeChange: function (val) {
+      this.count = val;
+      this.getRecordPlanList();
+    },
+    getRecordPlanList: function () {
+      this.$axios({
+        method: 'get',
+        url: `/api/record/plan/query`,
+        params: {
+          page: this.currentPage,
+          count: this.count,
+          query: this.searchSrt,
+        }
+      }).then((res) => {
+        if (res.data.code === 0) {
+          this.total = res.data.data.total;
+          this.recordPlanList = res.data.data.list;
+          // 防止出现表格错位
+          this.$nextTick(() => {
+            this.$refs.recordPlanListTable.doLayout();
+          })
+        }
+
+      }).catch((error) => {
+        console.log(error);
+      });
+    },
+    getSnap: function (row) {
+      let baseUrl = window.baseUrl ? window.baseUrl : "";
+      return ((process.env.NODE_ENV === 'development') ? process.env.BASE_API : baseUrl) + '/api/device/query/snap/' + this.deviceId + '/' + row.deviceId;
+    },
+    search: function () {
+      this.currentPage = 1;
+      this.total = 0;
+      this.initData();
+    },
+    refresh: function () {
+      this.initData();
+    },
+    add: function () {
+      this.$refs.editRecordPlan.openDialog(null, ()=>{
+        this.initData()
+      })
+    },
+    edit: function (plan) {
+      this.$refs.editRecordPlan.openDialog(plan, ()=>{
+        this.initData()
+      })
+    },
+    link: function (plan) {
+      this.$refs.linkChannelRecord.openDialog(plan.id, ()=>{
+        this.initData()
+      })
+    },
+    deletePlan: function (plan) {
+      this.$confirm('确定删除?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.$axios({
+          method: 'delete',
+          url: "/api/record/plan/delete",
+          params: {
+            planId: plan.id,
+          }
+        }).then((res) => {
+          if (res.data.code === 0) {
+            this.$message({
+              showClose: true,
+              message: '删除成功',
+              type: 'success',
+            });
+            this.initData();
+          } else {
+            this.$message({
+              showClose: true,
+              message: res.data.msg,
+              type: 'error'
+            });
+          }
+        }).catch((error) => {
+          console.error(error)
+        });
+      }).catch(() => {
+
+      });
+
+    },
+  }
+};
+</script>
+
+<style>
+.videoList {
+  display: flex;
+  flex-wrap: wrap;
+  align-content: flex-start;
+}
+
+.video-item {
+  position: relative;
+  width: 15rem;
+  height: 10rem;
+  margin-right: 1rem;
+  background-color: #000000;
+}
+
+.video-item-img {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+  width: 100%;
+  height: 100%;
+}
+
+.video-item-img:after {
+  content: "";
+  display: inline-block;
+  position: absolute;
+  z-index: 2;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+  width: 3rem;
+  height: 3rem;
+  background-image: url("../assets/loading.png");
+  background-size: cover;
+  background-color: #000000;
+}
+
+.video-item-title {
+  position: absolute;
+  bottom: 0;
+  color: #000000;
+  background-color: #ffffff;
+  line-height: 1.5rem;
+  padding: 0.3rem;
+  width: 14.4rem;
+}
+</style>

+ 228 - 0
web_src/src/components/dialog/editRecordPlan.vue

@@ -0,0 +1,228 @@
+<template>
+  <div id="editRecordPlan" v-loading="loading" style="text-align: left;">
+    <el-dialog
+      title="录制计划"
+      width="700px"
+      top="2rem"
+      :close-on-click-modal="false"
+      :visible.sync="showDialog"
+      :destroy-on-close="true"
+      @close="close()"
+    >
+      <div id="shared" style="margin-right: 20px;">
+        <el-form >
+          <el-form-item label="名称">
+            <el-input type="text" v-model="planName"></el-input>
+          </el-form-item>
+          <el-form-item>
+            <ByteWeektimePicker v-model="byteTime" name="name"/>
+          </el-form-item>
+          <el-form-item>
+            <div style="float: right; margin-top: 20px">
+              <el-button type="primary" @click="onSubmit">保存</el-button>
+              <el-button @click="close">取消</el-button>
+            </div>
+          </el-form-item>
+        </el-form>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { ByteWeektimePicker } from 'byte-weektime-picker'
+
+
+export default {
+  name: "editRecordPlan",
+  props: {},
+  components: {ByteWeektimePicker},
+  created() {
+  },
+  data() {
+    return {
+      options: [],
+      loading: false,
+      edit: false,
+      planName: null,
+      id: null,
+      showDialog: false,
+      endCallback: "",
+      byteTime: "",
+    };
+  },
+  methods: {
+    openDialog: function (recordPlan, endCallback) {
+      this.endCallback = endCallback;
+      this.showDialog = true;
+      this.byteTime= "";
+      if (recordPlan) {
+        this.edit = true
+        this.planName = recordPlan.name
+        this.id = recordPlan.id
+        this.$axios({
+          method: 'get',
+          url: "/api/record/plan/get",
+          params: {
+            planId: recordPlan.id,
+          }
+        }).then((res) => {
+          if (res.data.code === 0 && res.data.data.planItemList) {
+            this.byteTime = this.plan2Byte(res.data.data.planItemList)
+          }
+        }).catch((error) => {
+          console.error(error)
+        });
+
+      }
+    },
+    onSubmit: function () {
+      let planList = this.byteTime2PlanList();
+      if (!this.edit) {
+        this.$axios({
+          method: 'post',
+          url: "/api/record/plan/add",
+          data: {
+            name: this.planName,
+            planItemList: planList
+          }
+        }).then((res) => {
+          if (res.data.code === 0) {
+            this.$message({
+              showClose: true,
+              message: '添加成功',
+              type: 'success',
+            });
+            this.showDialog = false;
+            this.endCallback()
+          } else {
+            this.$message({
+              showClose: true,
+              message: res.data.msg,
+              type: 'error'
+            });
+          }
+        }).catch((error) => {
+          console.error(error)
+        });
+      }else {
+        this.$axios({
+          method: 'post',
+          url: "/api/record/plan/update",
+          data: {
+            id: this.id,
+            name: this.planName,
+            planItemList: planList
+          }
+        }).then((res) => {
+          if (res.data.code === 0) {
+            this.$message({
+              showClose: true,
+              message: '更新成功',
+              type: 'success',
+            });
+            this.showDialog = false;
+            this.endCallback()
+          } else {
+            this.$message({
+              showClose: true,
+              message: res.data.msg,
+              type: 'error'
+            });
+          }
+        }).catch((error) => {
+          console.error(error)
+        });
+      }
+
+    },
+    close: function () {
+      this.showDialog = false;
+      this.id = null
+      this.planName = null
+      this.byteTime = ""
+      this.endCallback = ""
+      if(this.endCallback) {
+        this.endCallback();
+      }
+    },
+    byteTime2PlanList() {
+      if (this.byteTime.length === 0) {
+        return;
+      }
+      const DayTimes = 24 * 2;
+      let planList = []
+      let week = 1;
+      // 把 336长度的 list 分成 7 组,每组 48 个
+      for (let i = 0; i < this.byteTime.length; i += DayTimes) {
+        let planArray = this.byteTime2Plan(this.byteTime.slice(i, i + DayTimes));
+        if(!planArray || planArray.length === 0) {
+          week ++;
+          continue
+        }
+        for (let j = 0; j < planArray.length; j++) {
+          planList.push({
+            planId: this.id,
+            start: planArray[j].start,
+            stop: planArray[j].stop,
+            weekDay: week
+          })
+        }
+        week ++;
+      }
+      return planList
+    },
+    byteTime2Plan(weekItem){
+      let start = null;
+      let stop = null;
+      let result = []
+      for (let i = 0; i < weekItem.length; i++) {
+        let item = weekItem[i]
+        if (item === '1') { // 表示选中
+          stop = i
+          if (start === null ) {
+            start = i
+          }
+          if (i === weekItem.length - 1 && start != null && stop != null) {
+            result.push({
+              start: start,
+              stop: stop,
+            })
+          }
+        } else {
+          if (stop !== null){
+            result.push({
+              start: start,
+              stop: stop,
+            })
+            start = null
+            stop = null
+          }
+        }
+      }
+      return result;
+    },
+    plan2Byte(planList) {
+      let byte = ""
+      let indexArray = {}
+      for (let i = 0; i < planList.length; i++) {
+
+        let weekDay = planList[i].weekDay
+        let index = planList[i].start
+        let endIndex = planList[i].stop
+        for (let j = index; j <= endIndex; j++) {
+          indexArray["key_" + (j + (weekDay - 1 )*48)] = 1
+        }
+      }
+      for (let i = 0; i < 336; i++) {
+        if (indexArray["key_" + i]){
+          byte += "1"
+        }else {
+          byte += "0"
+        }
+      }
+      return byte
+    }
+  },
+};
+</script>

+ 355 - 0
web_src/src/components/dialog/linkChannelRecord.vue

@@ -0,0 +1,355 @@
+<template>
+  <div id="linkChannelRecord" style="width: 100%;  background-color: #FFFFFF; display: grid; grid-template-columns: 200px auto;">
+    <el-dialog title="通道关联" v-loading="dialogLoading" v-if="showDialog" top="2rem" width="80%" :close-on-click-modal="false" :visible.sync="showDialog" :destroy-on-close="true" @close="close()">
+      <div style="display: grid; grid-template-columns: 100px auto;">
+        <el-tabs tab-position="left" style="" v-model="hasLink" @tab-click="search">
+          <el-tab-pane label="未关联" name="false"></el-tab-pane>
+          <el-tab-pane label="已关联" name="true"></el-tab-pane>
+        </el-tabs>
+        <div>
+          <div class="page-header">
+            <div class="page-header-btn" >
+              <div  style="display: inline;">
+                搜索:
+                <el-input @input="search" style="margin-right: 1rem; width: auto;" size="mini" placeholder="关键字"
+                          prefix-icon="el-icon-search" v-model="searchSrt" clearable></el-input>
+
+                在线状态:
+                <el-select size="mini" style="width: 8rem; margin-right: 1rem;" @change="search" v-model="online" placeholder="请选择"
+                           default-first-option>
+                  <el-option label="全部" value=""></el-option>
+                  <el-option label="在线" value="true"></el-option>
+                  <el-option label="离线" value="false"></el-option>
+                </el-select>
+                类型:
+                <el-select size="mini" style="width: 8rem; margin-right: 1rem;" @change="search" v-model="channelType" placeholder="请选择"
+                           default-first-option>
+                  <el-option label="全部" value=""></el-option>
+                  <el-option label="国标设备" :value="0"></el-option>
+                  <el-option label="推流设备" :value="1"></el-option>
+                  <el-option label="拉流代理" :value="2"></el-option>
+                </el-select>
+                <el-button v-if="hasLink !=='true'" size="mini" type="primary" @click="add()">
+                  添加
+                </el-button>
+                <el-button v-if="hasLink ==='true'" size="mini" type="danger" @click="remove()">
+                  移除
+                </el-button>
+                <el-button size="mini" v-if="hasLink !=='true'" @click="addByDevice()">按设备添加</el-button>
+                <el-button size="mini" v-if="hasLink ==='true'" @click="removeByDevice()">按设备移除</el-button>
+                <el-button size="mini" v-if="hasLink !=='true'" @click="addAll()">全部添加</el-button>
+                <el-button size="mini" v-if="hasLink ==='true'" @click="removeAll()">全部移除</el-button>
+                <el-button size="mini" @click="getChannelList()">刷新</el-button>
+              </div>
+            </div>
+          </div>
+          <el-table size="small"  ref="channelListTable" :data="channelList" :height="winHeight"
+                    header-row-class-name="table-header" @selection-change="handleSelectionChange" >
+            <el-table-column type="selection" width="55" >
+            </el-table-column>
+            <el-table-column prop="gbName" label="名称" min-width="180">
+            </el-table-column>
+            <el-table-column prop="gbDeviceId" label="编号" min-width="180">
+            </el-table-column>
+            <el-table-column prop="gbManufacturer" label="厂家" min-width="100">
+            </el-table-column>
+            <el-table-column label="类型" min-width="100">
+              <template v-slot:default="scope">
+                <div slot="reference" class="name-wrapper">
+                  <el-tag size="medium" effect="plain" v-if="scope.row.gbDeviceDbId">国标设备</el-tag>
+                  <el-tag size="medium" effect="plain" type="success" v-if="scope.row.streamPushId">推流设备</el-tag>
+                  <el-tag size="medium" effect="plain" type="warning" v-if="scope.row.streamProxyId">拉流代理</el-tag>
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="状态" min-width="100">
+              <template v-slot:default="scope">
+                <div slot="reference" class="name-wrapper">
+                  <el-tag size="medium" v-if="scope.row.gbStatus === 'ON'">在线</el-tag>
+                  <el-tag size="medium" type="info" v-if="scope.row.gbStatus !== 'ON'">离线</el-tag>
+                </div>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-pagination
+            style="text-align: right"
+            @size-change="handleSizeChange"
+            @current-change="currentChange"
+            :current-page="currentPage"
+            :page-size="count"
+            :page-sizes="[15, 25, 35, 50]"
+            layout="total, sizes, prev, pager, next"
+            :total="total">
+          </el-pagination>
+          <gbDeviceSelect ref="gbDeviceSelect"></gbDeviceSelect>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+
+import gbDeviceSelect from "./GbDeviceSelect.vue";
+
+export default {
+  name: 'linkChannelRecord',
+  components: {gbDeviceSelect},
+  data() {
+    return {
+      dialogLoading: false,
+      showDialog: false,
+      chooseData: {},
+      channelList: [],
+      searchSrt: "",
+      channelType: "",
+      online: "",
+      hasLink: "false",
+      winHeight: window.innerHeight - 250,
+      currentPage: 1,
+      count: 15,
+      total: 0,
+      loading: false,
+      planId: null,
+      loadSnap: {},
+      multipleSelection: []
+    };
+  },
+
+  created() {},
+  destroyed() {},
+  methods: {
+    openDialog(planId, closeCallback) {
+      this.planId = planId
+      this.showDialog = true
+      this.closeCallback = closeCallback
+      this.initData()
+    },
+    initData: function () {
+      this.currentPage= 1;
+      this.count= 15;
+      this.total= 0;
+      this.getChannelList();
+    },
+    currentChange: function (val) {
+      this.currentPage = val;
+      this.initData();
+    },
+    handleSizeChange: function (val) {
+      this.count = val;
+      this.getChannelList();
+    },
+    getChannelList: function () {
+      this.$axios({
+        method: 'get',
+        url: `/api/record/plan/channel/list`,
+        params: {
+          page: this.currentPage,
+          count: this.count,
+          query: this.searchSrt,
+          online: this.online,
+          channelType: this.channelType,
+          planId: this.planId,
+          hasLink: this.hasLink
+        }
+      }).then((res)=> {
+        if (res.data.code === 0) {
+          this.total = res.data.data.total;
+          this.channelList = res.data.data.list;
+          // 防止出现表格错位
+          this.$nextTick(() => {
+            this.$refs.channelListTable.doLayout();
+          })
+        }
+
+      }).catch((error)=> {
+
+        console.log(error);
+      });
+    },
+    handleSelectionChange: function (val){
+      this.multipleSelection = val;
+    },
+
+    linkPlan: function (data){
+      this.loading = true
+      return this.$axios({
+        method: 'post',
+        url: `/api/record/plan/link`,
+        data: data
+      }).then((res)=> {
+        if (res.data.code === 0) {
+          this.$message.success({
+            showClose: true,
+            message: "保存成功"
+          })
+          this.getChannelList()
+        }else {
+          this.$message.error({
+            showClose: true,
+            message: res.data.msg
+          })
+        }
+        this.loading = false
+      }).catch((error)=> {
+        this.$message.error({
+          showClose: true,
+          message: error
+        })
+        this.loading = false
+      })
+    },
+
+    add: function (row) {
+      let channels = []
+      for (let i = 0; i < this.multipleSelection.length; i++) {
+        channels.push(this.multipleSelection[i].gbId)
+      }
+      if (channels.length === 0) {
+        this.$message.info({
+          showClose: true,
+          message: "请选择通道"
+        })
+        return;
+      }
+      this.linkPlan({
+        planId: this.planId,
+        channelIds: channels
+      })
+    },
+    addAll: function (row) {
+      this.$confirm("确定全部添加?", '提示', {
+        dangerouslyUseHTMLString: true,
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.linkPlan({
+          planId: this.planId,
+          allLink: true
+        })
+        }).catch(() => {
+      });
+    },
+
+    addByDevice: function (row) {
+      this.$refs.gbDeviceSelect.openDialog((rows)=>{
+        let deviceIds = []
+        for (let i = 0; i < rows.length; i++) {
+          deviceIds.push(rows[i].id)
+        }
+        this.linkPlan({
+          planId: this.planId,
+          deviceDbIds: deviceIds
+        })
+      })
+    },
+
+    removeByDevice: function (row) {
+      this.$refs.gbDeviceSelect.openDialog((rows)=>{
+        let deviceIds = []
+        for (let i = 0; i < rows.length; i++) {
+          deviceIds.push(rows[i].id)
+        }
+        this.linkPlan({
+          deviceDbIds: deviceIds
+        })
+      })
+    },
+    remove: function (row) {
+      let channels = []
+      for (let i = 0; i < this.multipleSelection.length; i++) {
+        channels.push(this.multipleSelection[i].gbId)
+      }
+      if (channels.length === 0) {
+        this.$message.info({
+          showClose: true,
+          message: "请选择通道"
+        })
+        return;
+      }
+
+      this.linkPlan({
+        channelIds: channels
+      })
+    },
+    removeAll: function (row) {
+
+      this.$confirm("确定全部移除?", '提示', {
+        dangerouslyUseHTMLString: true,
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.linkPlan({
+          planId: this.planId,
+          allLink: false
+        })
+      }).catch(() => {
+      });
+    },
+    search: function () {
+      this.currentPage = 1;
+      this.total = 0;
+      this.initData();
+    },
+    refresh: function () {
+      this.initData();
+    },
+  }
+};
+</script>
+
+<style>
+.videoList {
+  display: flex;
+  flex-wrap: wrap;
+  align-content: flex-start;
+}
+
+.video-item {
+  position: relative;
+  width: 15rem;
+  height: 10rem;
+  margin-right: 1rem;
+  background-color: #000000;
+}
+
+.video-item-img {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+  width: 100%;
+  height: 100%;
+}
+
+.video-item-img:after {
+  content: "";
+  display: inline-block;
+  position: absolute;
+  z-index: 2;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  margin: auto;
+  width: 3rem;
+  height: 3rem;
+  background-image: url("../../assets/loading.png");
+  background-size: cover;
+  background-color: #000000;
+}
+
+.video-item-title {
+  position: absolute;
+  bottom: 0;
+  color: #000000;
+  background-color: #ffffff;
+  line-height: 1.5rem;
+  padding: 0.3rem;
+  width: 14.4rem;
+}
+</style>

+ 1 - 0
web_src/src/layout/UiHeader.vue

@@ -15,6 +15,7 @@
         <el-menu-item index="/channel/region">行政区划</el-menu-item>
         <el-menu-item index="/channel/group">业务分组</el-menu-item>
       </el-submenu>
+      <el-menu-item index="/recordPlan">录制计划</el-menu-item>
       <el-menu-item index="/cloudRecord">云端录像</el-menu-item>
       <el-menu-item index="/mediaServerManger">节点管理</el-menu-item>
       <el-menu-item index="/platformList/15/1">国标级联</el-menu-item>

+ 5 - 0
web_src/src/router/index.js

@@ -26,6 +26,7 @@ import rtcPlayer from '../components/dialog/rtcPlayer.vue'
 import region from '../components/region.vue'
 import group from '../components/group.vue'
 import operations from '../components/operations.vue'
+import recordPLan from '../components/RecordPLan.vue'
 
 const originalPush = VueRouter.prototype.push
 VueRouter.prototype.push = function push(location) {
@@ -148,6 +149,10 @@ export default new VueRouter({
           path: '/operations',
           component: operations,
         },
+        {
+          path: '/recordPLan',
+          component: recordPLan,
+        },
         ]
     },
     {

+ 21 - 0
数据库/2.7.3/初始化-mysql-2.7.3.sql

@@ -147,6 +147,7 @@ create table wvp_device_channel
     gb_download_speed            character varying(255),
     gb_svc_space_support_mod     integer,
     gb_svc_time_support_mode     integer,
+    record_plan_id               integer,
     stream_push_id               integer,
     stream_proxy_id              integer,
     constraint uk_wvp_device_channel_unique_device_channel unique (device_db_id, device_id),
@@ -427,3 +428,23 @@ CREATE TABLE wvp_common_region
     constraint uk_common_region_device_id unique (device_id)
 );
 
+create table wvp_record_plan
+(
+    id              serial primary key,
+    snap            bool default false,
+    name            varchar(255) NOT NULL,
+    create_time     character varying(50),
+    update_time     character varying(50)
+);
+
+create table wvp_record_plan_item
+(
+    id              serial primary key,
+    start           int,
+    stop            int,
+    week_day        int,
+    plan_id         int,
+    create_time     character varying(50),
+    update_time     character varying(50)
+);
+

+ 21 - 0
数据库/2.7.3/初始化-postgresql-kingbase-2.7.3.sql

@@ -163,6 +163,7 @@ create table wvp_device_channel
     gb_download_speed            character varying(255),
     gb_svc_space_support_mod     integer,
     gb_svc_time_support_mode     integer,
+    record_plan_id               integer,
     stream_push_id               integer,
     stream_proxy_id              integer,
     constraint uk_wvp_device_channel_unique_device_channel unique (device_db_id, device_id),
@@ -444,3 +445,23 @@ CREATE TABLE wvp_common_region
     constraint uk_common_region_device_id unique (device_id)
 );
 
+create table wvp_record_plan
+(
+    id              serial primary key,
+    snap            bool default false,
+    name            varchar(255) NOT NULL,
+    create_time     character varying(50),
+    update_time     character varying(50)
+);
+
+create table wvp_record_plan_item
+(
+    id              serial primary key,
+    start           int,
+    stop            int,
+    week_day        int,
+    plan_id        int,
+    create_time     character varying(50),
+    update_time     character varying(50)
+);
+