Explorar el Código

Add 新增点检签到模块

Yue hace 3 meses
padre
commit
15d8632158
Se han modificado 31 ficheros con 2666 adiciones y 18 borrados
  1. 68 7
      SERVER/ChickenFarmV3/.script/sql/new/init_y.sql
  2. 154 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/controller/InspectionRuleController.java
  3. 75 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/InspectionCheckin.java
  4. 101 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/InspectionRule.java
  5. 69 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/bo/InspectionCheckinBo.java
  6. 99 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/bo/InspectionRuleBo.java
  7. 84 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/vo/InspectionCheckinVo.java
  8. 119 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/vo/InspectionRuleVo.java
  9. 17 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/mapper/InspectionCheckinMapper.java
  10. 17 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/mapper/InspectionRuleMapper.java
  11. 72 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/service/IInspectionRuleService.java
  12. 237 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/service/impl/InspectionRuleServiceImpl.java
  13. 7 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/resources/mapper/device/InspectionCheckinMapper.xml
  14. 7 0
      SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/resources/mapper/device/InspectionRuleMapper.xml
  15. 15 4
      SERVER/ChickenFarmV3/vb-modules/vb-generator/src/main/resources/vm/vue/view.vue.vm
  16. 1 1
      SERVER/ChickenFarmV3/vb-modules/vb-job/src/main/java/cn/vber/job/xxl/jobhandler/DeviceTaskJob.java
  17. 1 0
      UI/VB.VUE/package.json
  18. 79 0
      UI/VB.VUE/pnpm-lock.yaml
  19. BIN
      UI/VB.VUE/public/media/logo.png
  20. 87 0
      UI/VB.VUE/src/api/device/_inspectionRule.ts
  21. 4 1
      UI/VB.VUE/src/api/device/index.ts
  22. 386 0
      UI/VB.VUE/src/components/camera/Capture.vue
  23. 2 1
      UI/VB.VUE/src/core/utils/sse.ts
  24. 2 2
      UI/VB.VUE/src/core/vb-dom/components/_MenuComponent.ts
  25. 9 0
      UI/VB.VUE/src/router/_staticRouter.ts
  26. 3 1
      UI/VB.VUE/src/stores/_notice.ts
  27. 97 0
      UI/VB.VUE/src/views/device/inspection/_checkinLog.vue
  28. 365 0
      UI/VB.VUE/src/views/device/inspection/checkin.vue
  29. 479 0
      UI/VB.VUE/src/views/device/inspection/index.vue
  30. 9 1
      UI/VB.VUE/src/views/mobile-home.vue
  31. 1 0
      UI/VB.VUE/src/views/workOrder/_order.vue

+ 68 - 7
SERVER/ChickenFarmV3/.script/sql/new/init_y.sql

@@ -156,6 +156,65 @@ CREATE TABLE `d_device_task`  (
       INDEX `idx_acceptor_id`(`acceptor_id` ASC) USING BTREE
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT = '设备任务表';
 
+-- ----------------------------
+-- Table structure for d_inspection_rule
+-- ----------------------------
+DROP TABLE IF EXISTS `d_inspection_rule`;
+CREATE TABLE `d_inspection_rule` (
+     `id` bigint NOT NULL AUTO_INCREMENT COMMENT '点检规则ID',
+     `task_name` varchar(255) NOT NULL COMMENT '点检任务名称',
+     `location` varchar(255) DEFAULT NULL COMMENT '地点',
+     `cycle_hours` float NOT NULL COMMENT '点检周期(小时)',
+     `tolerance_hours` float DEFAULT NULL COMMENT '允许误差范围(小时)',
+     `start_time` datetime DEFAULT NULL COMMENT '点检开始时间',
+     `end_time` datetime DEFAULT NULL COMMENT '点检结束时间',
+     `required_count` int NOT NULL COMMENT '需点检总次数',
+     `actual_count` int NOT NULL DEFAULT '0'COMMENT '实际点检次数',
+     `missed_count` int NOT NULL DEFAULT '0' COMMENT '漏检次数',
+     `executor_id` bigint DEFAULT NULL COMMENT '执行人ID',
+     `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态(0:正常 1:停用)',
+     `create_org` bigint NOT NULL COMMENT '创建组织',
+     `create_by` bigint NOT NULL COMMENT '创建人',
+     `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+     `update_by` bigint NOT NULL COMMENT '更新人',
+     `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+     `remark` text CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '备注',
+     `del_flag` char(1) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '0' COMMENT '删除标志(0:未删除, 1:已删除)',
+     PRIMARY KEY (`id`),
+     INDEX `idx_task_name` (`task_name`),
+     INDEX `idx_executor_id` (`executor_id`),
+     INDEX `idx_status` (`status`),
+     INDEX `idx_create_by` (`create_by`),
+     INDEX `idx_update_by` (`update_by`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点检规则管理表';
+
+-- ----------------------------
+-- Table structure for d_inspection_checkin
+-- ----------------------------
+DROP TABLE IF EXISTS `d_inspection_checkin`;
+CREATE TABLE `d_inspection_checkin` (
+    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '点检签到ID',
+    `inspection_rule_id` bigint NOT NULL COMMENT '点检任务ID',
+    `inspector_id` bigint NOT NULL COMMENT '点检人ID',
+    `planned_sequence` int NOT NULL COMMENT '点检计划次序',
+    `execute_time` datetime  NULL COMMENT '执行时间',
+    `image_url` varchar(255) DEFAULT NULL COMMENT '签到图片',
+    `checkin_status` tinyint NOT NULL DEFAULT '0' COMMENT '状态(0:正常 1:异常)',
+    `create_org` bigint NOT NULL COMMENT '创建组织',
+    `create_by` bigint NOT NULL COMMENT '创建人',
+    `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_by` bigint NOT NULL COMMENT '更新人',
+    `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `remark` text CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '备注',
+    `del_flag` char(1) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '0' COMMENT '删除标志(0:未删除, 1:已删除)',
+    PRIMARY KEY (`id`),
+    INDEX `idx_inspection_rule_id` (`inspection_rule_id`),
+    INDEX `idx_inspector_id` (`inspector_id`),
+    INDEX `idx_execute_time` (`execute_time`),
+    INDEX `idx_status` (`checkin_status`),
+    INDEX `idx_create_by` (`create_by`),
+    INDEX `idx_update_by` (`update_by`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点检签到表';
 
 INSERT INTO `sys_config` VALUES (11, '000000', '维修人员USER_ID', 'sys.repair.userIds', '5,6', 'Y', 100, 1, '2025-10-15 12:00:00', NULL, NULL, '维修人员ID');
 
@@ -203,6 +262,9 @@ INSERT INTO `sys_dict_data` VALUES (336, '000000', 0, '消毒', '2', 'device_cle
 INSERT INTO `sys_dict_data` VALUES (337, '000000', 0, '定期清洁', '3', 'device_clean_type', '', 'success', 'N', 100, 1, '2025-10-15 12:00:00', NULL, NULL, '故障报修');
 INSERT INTO `sys_dict_data` VALUES (338, '000000', 0, '定期消毒', '4', 'device_clean_type', '', 'danger', 'N', 100, 1, '2025-10-15 12:00:00', NULL, NULL, '保养管理');
 
+INSERT INTO `sys_dict_type` VALUES (307, '000000', '点检签到类型', 'device_inspection_type', 100, 1, '2025-10-15 12:00:00',  NULL, NULL, '0:未签到 1:签到');
+INSERT INTO `sys_dict_data` VALUES (341, '000000', 0, '未签到', '0', 'device_inspection_type', '', 'danger', 'N', 100, 1, '2025-10-15 12:00:00', NULL, NULL, '未签到');
+INSERT INTO `sys_dict_data` VALUES (342, '000000', 0, '签到', '1', 'device_inspection_type', '', 'primary', 'N', 100, 1, '2025-10-15 12:00:00', NULL, NULL, '签到');
 
 INSERT INTO `sys_menu` VALUES (21, '设备管理', 0, 21, 'deviceMg', NULL, '', 1, 0, 'M', '0', '0', '', 'suitcase-lg', '', '', 100, 1, '2025-10-15 12:00:00', 1, '2025-10-15 12:00:00', '设备管理');
 
@@ -211,13 +273,12 @@ INSERT INTO `sys_menu` VALUES (2001, '查询设备资产', 351, 0, '#', NULL, ''
 INSERT INTO `sys_menu` VALUES (2002, '新增设备资产', 351, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:device:add', 'plus-square', 'btn btn-light-primary', 'handleCreate', 100, 1, '2025-10-15 15:02:26', NULL, '2025-10-15 15:02:26', '');
 INSERT INTO `sys_menu` VALUES (2003, '修改设备资产', 351, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:device:edit', 'pencil-square', 'btn btn-light-success', 'handleUpdate@1', 100, 1, '2025-10-15 15:02:26', NULL, '2025-10-15 15:02:26', '');
 INSERT INTO `sys_menu` VALUES (2004, '删除设备资产', 351, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:device:remove', 'dash-square', 'btn btn-light-danger', 'handleDelete@0', 100, 1, '2025-10-15 15:02:26', NULL, '2025-10-15 15:02:26', '');
-INSERT INTO `sys_menu` VALUES (352, '点检签到规则', 21, 2, 'pointCheck', 'device/pointCheck/index', '', 1, 0, 'C', '0', '0', 'device:pointCheck', '#', NULL, NULL, 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2011, '查询点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:query', 'eye', '', '', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2012, '新增点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:add', 'plus-square', 'btn btn-light-primary', 'handleCreate', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2013, '修改点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:edit', 'pencil-square', 'btn btn-light-success', 'handleUpdate@1', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2014, '删除点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:remove', 'dash-square', 'btn btn-light-danger', 'handleDelete@0', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2015, '启动点检签到', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:remove', 'skip-start-circle', 'btn btn-light-primary', 'handleStart@1', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
-INSERT INTO `sys_menu` VALUES (2016, '停止点检签到', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:pointCheck:remove', 'stop-circle', 'btn btn-light-danger', 'handleStop@1', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (352, '点检签到规则', 21, 2, 'inspection', 'device/inspection/index', '', 1, 0, 'C', '0', '0', 'device:inspection', '#', NULL, NULL, 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (2011, '查询点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:inspection:query', 'eye', '', '', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (2012, '新增点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:inspection:add', 'plus-square', 'btn btn-light-primary', 'handleCreate', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (2013, '修改点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:inspection:edit', 'pencil-square', 'btn btn-light-success', 'handleUpdate@1', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (2014, '删除点检规则', 352, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:inspection:remove', 'dash-square', 'btn btn-light-danger', 'handleDelete@0', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
+INSERT INTO `sys_menu` VALUES (2015, '启停点检签到', 352, 0, '#', NULL, '', 1, 0, 'F', '1', '0', 'device:inspection:startStop', '', '', '', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
 INSERT INTO `sys_menu` VALUES (353, '设备维修工单', 21, 3, 'deviceRepairOrder', 'device/deviceRepairOrder/index', '', 1, 0, 'C', '0', '0', 'device:deviceRepairOrder', '#', NULL, NULL, 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
 INSERT INTO `sys_menu` VALUES (2021, '查询维修工单', 353, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:deviceRepairOrder:query', 'eye', '', '', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');
 INSERT INTO `sys_menu` VALUES (2022, '新增维修工单', 353, 0, '#', NULL, '', 1, 0, 'F', '0', '0', 'device:deviceRepairOrder:add', 'plus-square', 'btn btn-light-primary', 'handleCreate', 100, 1, '2025-10-15 15:02:19', NULL, '2025-10-15 15:02:19', '');

+ 154 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/controller/InspectionRuleController.java

@@ -0,0 +1,154 @@
+package cn.vber.device.controller;
+
+import java.util.Arrays;
+import java.util.List;
+
+import cn.hutool.core.io.FileUtil;
+import cn.vber.common.core.utils.StringUtils;
+import cn.vber.common.core.utils.file.MimeTypeUtils;
+import cn.vber.common.mybatis.helper.DataPermissionHelper;
+import cn.vber.common.satoken.utils.LoginHelper;
+import cn.vber.device.domain.vo.InspectionCheckinVo;
+import cn.vber.system.controller.system.SysProfileController;
+import cn.vber.system.domain.vo.SysOssVo;
+import cn.vber.system.service.ISysOssService;
+import lombok.RequiredArgsConstructor;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.constraints.*;
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.validation.annotation.Validated;
+import cn.vber.common.idempotent.annotation.RepeatSubmit;
+import cn.vber.common.log.annotation.Log;
+import cn.vber.common.web.core.BaseController;
+import cn.vber.common.mybatis.core.page.PageQuery;
+import cn.vber.common.core.domain.R;
+import cn.vber.common.core.validate.AddGroup;
+import cn.vber.common.core.validate.EditGroup;
+import cn.vber.common.log.enums.BusinessType;
+import cn.vber.common.excel.utils.ExcelUtil;
+import cn.vber.device.domain.vo.InspectionRuleVo;
+import cn.vber.device.domain.bo.InspectionRuleBo;
+import cn.vber.device.service.IInspectionRuleService;
+import cn.vber.common.mybatis.core.page.TableDataInfo;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 点检规则管理
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Validated
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/device/inspectionRule")
+public class InspectionRuleController extends BaseController {
+
+    private final IInspectionRuleService inspectionRuleService;
+    private final ISysOssService ossService;
+
+    /**
+     * 查询点检规则管理列表
+     */
+    @SaCheckPermission("device:inspectionRule")
+    @GetMapping("/list")
+    public TableDataInfo<InspectionRuleVo> list(InspectionRuleBo bo, PageQuery pageQuery) {
+        return inspectionRuleService.queryPageList(bo, pageQuery);
+    }
+
+    /**
+     * 导出点检规则管理列表
+     */
+    @SaCheckPermission("device:inspectionRule:export")
+    @Log(title = "点检规则管理", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public void export(InspectionRuleBo bo, HttpServletResponse response) {
+        List<InspectionRuleVo> list = inspectionRuleService.queryList(bo);
+        ExcelUtil.exportExcel(list, "点检规则管理", InspectionRuleVo.class, response);
+    }
+
+    /**
+     * 获取点检规则管理详细信息
+     *
+     * @param id 主键
+     */
+    @SaCheckPermission("device:inspectionRule:query")
+    @GetMapping("/{id}")
+    public R<InspectionRuleVo> getInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return R.ok(inspectionRuleService.queryById(id));
+    }
+
+    /**
+     * 新增点检规则管理
+     */
+    @SaCheckPermission("device:inspectionRule:add")
+    @Log(title = "点检规则管理", businessType = BusinessType.INSERT)
+    @RepeatSubmit()
+    @PostMapping()
+    public R<Void> add(@Validated(AddGroup.class) @RequestBody InspectionRuleBo bo) {
+        return toAjax(inspectionRuleService.insertByBo(bo));
+    }
+
+    /**
+     * 修改点检规则管理
+     */
+    @SaCheckPermission("device:inspectionRule:edit")
+    @Log(title = "点检规则管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping()
+    public R<Void> edit(@Validated(EditGroup.class) @RequestBody InspectionRuleBo bo) {
+        return toAjax(inspectionRuleService.updateByBo(bo));
+    }
+
+    /**
+     * 删除点检规则管理
+     *
+     * @param ids 主键串
+     */
+    @SaCheckPermission("device:inspectionRule:remove")
+    @Log(title = "点检规则管理", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public R<Void> remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) {
+        return toAjax(inspectionRuleService.deleteWithValidByIds(List.of(ids), true));
+    }
+
+    @SaCheckPermission("device:inspectionRule:startStop")
+    @Log(title = "点检规则管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PutMapping("/startStop/{ids}")
+    public R<Void> updateStatus(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return toAjax(inspectionRuleService.updateStatus(id));
+    }
+
+    @GetMapping("/queryCheckinList/{id}")
+    public R<List<InspectionCheckinVo>> queryCheckinList(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return R.ok(inspectionRuleService.queryCheckinList(id));
+    }
+
+    @Log(title = "点检规则管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping("/checkInWithPhoto/{id}")
+    public R<Void> checkInWithPhoto(@NotNull(message = "主键不能为空") @PathVariable Long id, @RequestPart("photo") MultipartFile photo) {
+        if (!inspectionRuleService.cnaCheckin(id)) {
+            return R.fail("签到失败");
+        }
+        if (!photo.isEmpty()) {
+            String extension = FileUtil.extName(photo.getOriginalFilename());
+            if (!StringUtils.equalsAnyIgnoreCase(extension, MimeTypeUtils.IMAGE_EXTENSION)) {
+                return R.fail("文件格式不正确,请上传" + Arrays.toString(MimeTypeUtils.IMAGE_EXTENSION) + "格式");
+            }
+            SysOssVo oss = ossService.upload(photo, "device");
+            String imageUrl = oss.getObjectId();
+            return toAjax(inspectionRuleService.checkin(id, imageUrl));
+        }
+        return R.fail("上传照片异常,请联系管理员");
+    }
+
+    @Log(title = "点检规则管理", businessType = BusinessType.UPDATE)
+    @RepeatSubmit()
+    @PostMapping("/checkIn/{id}")
+    public R<Void> checkIn(@NotNull(message = "主键不能为空") @PathVariable Long id) {
+        return toAjax(inspectionRuleService.checkin(id, null));
+    }
+}

+ 75 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/InspectionCheckin.java

@@ -0,0 +1,75 @@
+package cn.vber.device.domain;
+
+import cn.vber.common.mybatis.core.domain.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.io.Serial;
+
+/**
+ * 点检签到对象 d_inspection_checkin
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("d_inspection_checkin")
+public class InspectionCheckin extends BaseEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 点检签到ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 点检任务ID
+     */
+    private Long inspectionRuleId;
+
+    /**
+     * 点检人ID
+     */
+    private Long inspectorId;
+
+    /**
+     * 点检计划次序
+     */
+    private Integer plannedSequence;
+
+    /**
+     * 执行时间
+     */
+    private Date executeTime;
+
+    /**
+     * 状态
+     */
+    private Integer checkinStatus;
+
+    /**
+     * 备注
+     */
+    private String remark;
+    /**
+     * 签到图片
+     */
+    private String imageUrl;
+
+    /**
+     * 删除标志(0:未删除, 1:已删除)
+     */
+    @TableLogic
+    private String delFlag;
+
+
+}

+ 101 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/InspectionRule.java

@@ -0,0 +1,101 @@
+package cn.vber.device.domain;
+
+import cn.vber.common.mybatis.core.domain.BaseEntity;
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.io.Serial;
+
+/**
+ * 点检规则管理对象 d_inspection_rule
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("d_inspection_rule")
+public class InspectionRule extends BaseEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 点检规则ID
+     */
+    @TableId(value = "id")
+    private Long id;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 地点
+     */
+    private String location;
+
+    /**
+     * 点检周期(小时)
+     */
+    private float cycleHours;
+
+    /**
+     * 误差(小时)
+     */
+    private float toleranceHours;
+
+    /**
+     * 开始时间
+     */
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    private Date endTime;
+
+    /**
+     * 需点检总次数
+     */
+    private Integer requiredCount;
+
+    /**
+     * 实际点检次数
+     */
+    private Integer actualCount;
+
+    /**
+     * 漏检次数
+     */
+    private Integer missedCount;
+
+    /**
+     * 执行人
+     */
+    private Long executorId;
+
+    /**
+     * 启用状态
+     */
+    private Integer status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+    /**
+     * 删除标志(0:未删除, 1:已删除)
+     */
+    @TableLogic
+    private String delFlag;
+
+
+}

+ 69 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/bo/InspectionCheckinBo.java

@@ -0,0 +1,69 @@
+package cn.vber.device.domain.bo;
+
+import cn.vber.device.domain.InspectionCheckin;
+import cn.vber.common.mybatis.core.domain.BaseEntity;
+import cn.vber.common.core.validate.AddGroup;
+import cn.vber.common.core.validate.EditGroup;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import jakarta.validation.constraints.*;
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+/**
+ * 点检签到业务对象 d_inspection_checkin
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = InspectionCheckin.class,reverseConvertGenerate =false)
+
+public class InspectionCheckinBo extends BaseEntity {
+
+            /**
+             * 点检签到ID
+             */
+                @NotNull(message = "点检签到ID不能为空", groups = { EditGroup.class })
+        private Long id;
+
+            /**
+             * 点检任务ID
+             */
+                @NotNull(message = "点检任务ID不能为空", groups = { AddGroup.class, EditGroup.class })
+        private Long inspectionRuleId;
+
+            /**
+             * 点检人ID
+             */
+                @NotNull(message = "点检人ID不能为空", groups = { AddGroup.class, EditGroup.class })
+        private Long inspectorId;
+
+            /**
+             * 点检计划次序
+             */
+                @NotNull(message = "点检计划次序不能为空", groups = { AddGroup.class, EditGroup.class })
+        private Integer plannedSequence;
+
+            /**
+             * 执行时间
+             */
+                @NotNull(message = "执行时间不能为空", groups = { AddGroup.class, EditGroup.class })
+            @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+        private Date executeTime;
+
+            /**
+             * 状态
+             */
+                @NotNull(message = "状态不能为空", groups = { AddGroup.class, EditGroup.class })
+        private Integer checkinStatus;
+
+            /**
+             * 备注
+             */
+        private String remark;
+
+
+}

+ 99 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/bo/InspectionRuleBo.java

@@ -0,0 +1,99 @@
+package cn.vber.device.domain.bo;
+
+import cn.vber.device.domain.InspectionRule;
+import cn.vber.common.mybatis.core.domain.BaseEntity;
+import cn.vber.common.core.validate.AddGroup;
+import cn.vber.common.core.validate.EditGroup;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import jakarta.validation.constraints.*;
+
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+/**
+ * 点检规则管理业务对象 d_inspection_rule
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = InspectionRule.class, reverseConvertGenerate = false)
+
+public class InspectionRuleBo extends BaseEntity {
+
+    /**
+     * 点检规则ID
+     */
+    @NotNull(message = "点检规则ID不能为空", groups = {EditGroup.class})
+    private Long id;
+
+    /**
+     * 任务名称
+     */
+    @NotBlank(message = "任务名称不能为空", groups = {AddGroup.class, EditGroup.class})
+    private String taskName;
+
+    /**
+     * 地点
+     */
+    private String location;
+
+    /**
+     * 点检周期(小时)
+     */
+    @NotNull(message = "点检周期(小时)不能为空", groups = {AddGroup.class, EditGroup.class})
+    private float cycleHours;
+
+    /**
+     * 误差(小时)
+     */
+    private float toleranceHours;
+
+    /**
+     * 开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date endTime;
+
+//    /**
+//     * 需点检总次数
+//     */
+//    private Integer requiredCount;
+//
+//    /**
+//     * 实际点检次数
+//     */
+//    private Integer actualCount;
+//
+//    /**
+//     * 漏检次数
+//     */
+//    private Integer missedCount;
+
+    /**
+     * 执行人
+     */
+    @NotNull(message = "执行人不能为空", groups = {AddGroup.class, EditGroup.class})
+    private Long executorId;
+
+    /**
+     * 启用状态
+     */
+    private Integer status;
+
+    /**
+     * 备注
+     */
+    private String remark;
+
+}

+ 84 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/vo/InspectionCheckinVo.java

@@ -0,0 +1,84 @@
+package cn.vber.device.domain.vo;
+
+import java.util.Date;
+
+import cn.vber.common.translation.annotation.Translation;
+import cn.vber.common.translation.constant.TransConstant;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import cn.vber.device.domain.InspectionCheckin;
+import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
+import cn.idev.excel.annotation.ExcelProperty;
+import cn.vber.common.excel.annotation.ExcelDictFormat;
+import cn.vber.common.excel.convert.ExcelDictConvert;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+
+/**
+ * 点检签到视图对象 d_inspection_checkin
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@ExcelIgnoreUnannotated
+@AutoMapper(target = InspectionCheckin.class)
+
+public class InspectionCheckinVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 点检签到ID
+     */
+    @ExcelProperty(value = "点检签到ID")
+    private Long id;
+
+    /**
+     * 点检任务ID
+     */
+    @ExcelProperty(value = "点检任务ID")
+    private Long inspectionRuleId;
+
+    /**
+     * 点检人ID
+     */
+    @ExcelProperty(value = "点检人ID")
+    private Long inspectorId;
+    /**
+     * 点检人名称
+     */
+    @ExcelProperty(value = "点检人名称")
+    @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "inspectorId")
+    private String inspectorName;
+
+    /**
+     * 点检计划次序
+     */
+    @ExcelProperty(value = "点检计划次序")
+    private Integer plannedSequence;
+
+    /**
+     * 执行时间
+     */
+    @ExcelProperty(value = "执行时间")
+    private Date executeTime;
+    /**
+     * 签到图片
+     */
+    private String imageUrl;
+
+    /**
+     * 状态
+     */
+    @ExcelProperty(value = "状态", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(dictType = "device_inspection_type")
+    private Integer checkinStatus;
+
+
+}

+ 119 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/domain/vo/InspectionRuleVo.java

@@ -0,0 +1,119 @@
+package cn.vber.device.domain.vo;
+
+import java.util.Date;
+
+import cn.vber.common.translation.annotation.Translation;
+import cn.vber.common.translation.constant.TransConstant;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import cn.vber.device.domain.InspectionRule;
+import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
+import cn.idev.excel.annotation.ExcelProperty;
+import cn.vber.common.excel.annotation.ExcelDictFormat;
+import cn.vber.common.excel.convert.ExcelDictConvert;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+
+
+/**
+ * 点检规则管理视图对象 d_inspection_rule
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Data
+@ExcelIgnoreUnannotated
+@AutoMapper(target = InspectionRule.class)
+
+public class InspectionRuleVo implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 点检规则ID
+     */
+    @ExcelProperty(value = "点检规则ID")
+    private Long id;
+
+    /**
+     * 任务名称
+     */
+    @ExcelProperty(value = "任务名称")
+    private String taskName;
+
+    /**
+     * 地点
+     */
+    @ExcelProperty(value = "地点")
+    private String location;
+
+    /**
+     * 点检周期(小时)
+     */
+    @ExcelProperty(value = "点检周期", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "小=时")
+    private float cycleHours;
+
+    /**
+     * 误差(小时)
+     */
+    @ExcelProperty(value = "误差", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(readConverterExp = "小=时")
+    private float toleranceHours;
+
+    /**
+     * 开始时间
+     */
+    @ExcelProperty(value = "开始时间")
+    private Date startTime;
+
+    /**
+     * 结束时间
+     */
+    @ExcelProperty(value = "结束时间")
+    private Date endTime;
+
+    /**
+     * 需点检总次数
+     */
+    @ExcelProperty(value = "需点检总次数")
+    private Integer requiredCount;
+
+    /**
+     * 实际点检次数
+     */
+    @ExcelProperty(value = "实际点检次数")
+    private Integer actualCount;
+
+    /**
+     * 漏检次数
+     */
+    @ExcelProperty(value = "漏检次数")
+    private Integer missedCount;
+
+    /**
+     * 执行人
+     */
+    @ExcelProperty(value = "执行人")
+    private Long executorId;
+
+    /**
+     * 执行人名称
+     */
+    @ExcelProperty(value = "执行人名称")
+    @Translation(type = TransConstant.USER_ID_TO_NICKNAME, mapper = "executorId")
+    private String executorName;
+
+    /**
+     * 启用状态
+     */
+    @ExcelProperty(value = "启用状态", converter = ExcelDictConvert.class)
+    @ExcelDictFormat(dictType = "sys_normal_disable")
+    private Integer status;
+
+
+}

+ 17 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/mapper/InspectionCheckinMapper.java

@@ -0,0 +1,17 @@
+package cn.vber.device.mapper;
+
+import cn.vber.common.mybatis.core.mapper.BaseMapperPlus;
+import cn.vber.device.domain.InspectionCheckin;
+import cn.vber.device.domain.vo.InspectionCheckinVo;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 点检签到Mapper接口
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Repository
+public interface InspectionCheckinMapper extends BaseMapperPlus<InspectionCheckin, InspectionCheckinVo> {
+
+}

+ 17 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/mapper/InspectionRuleMapper.java

@@ -0,0 +1,17 @@
+package cn.vber.device.mapper;
+
+import cn.vber.common.mybatis.core.mapper.BaseMapperPlus;
+import cn.vber.device.domain.InspectionRule;
+import cn.vber.device.domain.vo.InspectionRuleVo;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 点检规则管理Mapper接口
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@Repository
+public interface InspectionRuleMapper extends BaseMapperPlus<InspectionRule, InspectionRuleVo> {
+
+}

+ 72 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/service/IInspectionRuleService.java

@@ -0,0 +1,72 @@
+package cn.vber.device.service;
+
+import cn.vber.device.domain.InspectionRule;
+import cn.vber.device.domain.vo.InspectionCheckinVo;
+import cn.vber.device.domain.vo.InspectionRuleVo;
+import cn.vber.device.domain.bo.InspectionRuleBo;
+import cn.vber.common.mybatis.core.page.TableDataInfo;
+import cn.vber.common.mybatis.core.page.PageQuery;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 点检规则管理Service接口
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+public interface IInspectionRuleService {
+
+    /**
+     * 查询点检规则管理
+     */
+    InspectionRuleVo queryById(Long id);
+
+    /**
+     * 查询点检规则管理列表
+     */
+    TableDataInfo<InspectionRuleVo> queryPageList(InspectionRuleBo bo, PageQuery pageQuery);
+
+    /**
+     * 查询点检规则管理列表
+     */
+    List<InspectionRuleVo> queryList(InspectionRuleBo bo);
+
+    /**
+     * 新增点检规则管理
+     */
+    Boolean insertByBo(InspectionRuleBo bo);
+
+    /**
+     * 修改点检规则管理
+     */
+    Boolean updateByBo(InspectionRuleBo bo);
+
+    /**
+     * 校验并批量删除点检规则管理信息
+     */
+    Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
+
+    /**
+     * 更新状态
+     */
+    int updateStatus(Long id);
+
+    /**
+     * 查询点检记录列表
+     */
+    List<InspectionCheckinVo> queryCheckinList(Long id);
+
+    /**
+     * 签到
+     */
+    int checkin(Long id, String imageUrl);
+
+    /**
+     * 是否可以签到
+     */
+    Boolean cnaCheckin(Long id);
+}

+ 237 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/java/cn/vber/device/service/impl/InspectionRuleServiceImpl.java

@@ -0,0 +1,237 @@
+package cn.vber.device.service.impl;
+
+import cn.vber.common.core.exception.ServiceException;
+import cn.vber.common.core.utils.DateUtils;
+import cn.vber.common.core.utils.MapstructUtils;
+import cn.vber.common.core.utils.StringUtils;
+import cn.vber.common.mybatis.core.page.TableDataInfo;
+import cn.vber.common.mybatis.core.page.PageQuery;
+import cn.vber.common.satoken.utils.LoginHelper;
+import cn.vber.device.domain.InspectionCheckin;
+import cn.vber.device.domain.bo.InspectionCheckinBo;
+import cn.vber.device.domain.vo.InspectionCheckinVo;
+import cn.vber.device.mapper.InspectionCheckinMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import cn.vber.device.domain.bo.InspectionRuleBo;
+import cn.vber.device.domain.vo.InspectionRuleVo;
+import cn.vber.device.domain.InspectionRule;
+import cn.vber.device.mapper.InspectionRuleMapper;
+import cn.vber.device.service.IInspectionRuleService;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 点检规则管理Service业务层处理
+ *
+ * @author IwbY
+ * @date 2025-10-22
+ */
+@RequiredArgsConstructor
+@Service
+public class InspectionRuleServiceImpl implements IInspectionRuleService {
+
+    private final InspectionRuleMapper baseMapper;
+    private final InspectionCheckinMapper checkinMapper;
+
+    /**
+     * 查询点检规则管理
+     */
+    @Override
+    public InspectionRuleVo queryById(Long id) {
+        return baseMapper.selectVoById(id);
+    }
+
+    /**
+     * 查询点检规则管理列表
+     */
+    @Override
+    public TableDataInfo<InspectionRuleVo> queryPageList(InspectionRuleBo bo, PageQuery pageQuery) {
+        LambdaQueryWrapper<InspectionRule> lqw = buildQueryWrapper(bo);
+        Page<InspectionRuleVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
+        return TableDataInfo.build(result);
+    }
+
+    /**
+     * 查询点检规则管理列表
+     */
+    @Override
+    public List<InspectionRuleVo> queryList(InspectionRuleBo bo) {
+        LambdaQueryWrapper<InspectionRule> lqw = buildQueryWrapper(bo);
+        return baseMapper.selectVoList(lqw);
+    }
+
+    private LambdaQueryWrapper<InspectionRule> buildQueryWrapper(InspectionRuleBo bo) {
+        Map<String, Object> params = bo.getParams();
+        LambdaQueryWrapper<InspectionRule> lqw = Wrappers.lambdaQuery();
+        lqw.like(StringUtils.isNotBlank(bo.getTaskName()), InspectionRule::getTaskName, bo.getTaskName());
+        lqw.eq(StringUtils.isNotBlank(bo.getLocation()), InspectionRule::getLocation, bo.getLocation());
+        lqw.eq(bo.getStatus() != null, InspectionRule::getStatus, bo.getStatus());
+        return lqw;
+    }
+
+    /**
+     * 新增点检规则管理
+     */
+    @Override
+    public Boolean insertByBo(InspectionRuleBo bo) {
+        InspectionRule add = MapstructUtils.convert(bo, InspectionRule.class);
+        validEntityBeforeSave(add);
+        boolean flag = baseMapper.insert(add) > 0;
+        if (flag) {
+            bo.setId(add.getId());
+        }
+        return flag;
+    }
+
+    /**
+     * 修改点检规则管理
+     */
+    @Override
+    public Boolean updateByBo(InspectionRuleBo bo) {
+        InspectionRule update = MapstructUtils.convert(bo, InspectionRule.class);
+        validEntityBeforeSave(update);
+        return baseMapper.updateById(update) > 0;
+    }
+
+    /**
+     * 保存前的数据校验
+     */
+    private void validEntityBeforeSave(InspectionRule entity) {
+        if (entity.getStartTime().after(entity.getEndTime())) {
+            throw new ServiceException("开始时间不能晚于结束时间");
+        }
+        long diff = DateUtils.difference(entity.getStartTime(), entity.getEndTime(), TimeUnit.HOURS);
+        if (diff < entity.getCycleHours()) {
+            throw new ServiceException("开始时间与结束时间间隔不能小于点检周期");
+        }
+        // 计算需要点检的次数, 取余
+        entity.setRequiredCount((int) (diff / entity.getCycleHours()));
+    }
+
+    /**
+     * 批量删除点检规则管理
+     */
+    @Override
+    public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
+        if (isValid) {
+            //TODO 做一些业务上的校验,判断是否需要校验
+        }
+        return baseMapper.deleteByIds(ids) > 0;
+    }
+
+    /**
+     * 更新点检规则管理状态
+     */
+    @Override
+    public int updateStatus(Long id) {
+        InspectionRule inspectionRule = baseMapper.selectById(id);
+        if (inspectionRule == null) {
+            throw new ServiceException("记录不存在");
+        }
+        inspectionRule.setStatus(inspectionRule.getStatus() == 0 ? 1 : 0);
+        return baseMapper.updateById(inspectionRule);
+    }
+
+    @Override
+    public List<InspectionCheckinVo> queryCheckinList(Long id) {
+        return checkinMapper.selectVoList(new LambdaQueryWrapper<InspectionCheckin>()
+                .eq(InspectionCheckin::getInspectionRuleId, id)
+                .orderByDesc(InspectionCheckin::getPlannedSequence));
+    }
+
+
+    /**
+     * 签到
+     */
+    @Override
+    @Transactional
+    public int checkin(Long id, String imageUrl) {
+        InspectionRule inspectionRule = baseMapper.selectById(id);
+        if (inspectionRule == null) {
+            throw new ServiceException("记录不存在");
+        }
+        Date now = DateUtils.getNowDate();
+        long sequence = getSequence(inspectionRule, now);
+        InspectionCheckin lastCheckin = checkinMapper.selectOne(Wrappers.lambdaQuery(InspectionCheckin.class)
+                .eq(InspectionCheckin::getInspectionRuleId, id)
+                .orderByDesc(InspectionCheckin::getExecuteTime)
+                .last("limit 1"));
+        if (lastCheckin != null && lastCheckin.getPlannedSequence() == sequence) {
+            throw new ServiceException("重复签到");
+        }
+        inspectionRule.setActualCount(inspectionRule.getActualCount() + 1);
+
+        int lastSequence = lastCheckin == null ? 1 : lastCheckin.getPlannedSequence();
+        InspectionCheckinBo bo = new InspectionCheckinBo();
+        bo.setInspectionRuleId(id);
+        bo.setInspectorId(LoginHelper.getUserId());
+        if (lastSequence != sequence - 1) {
+            int missedCount = (int) (sequence - lastSequence);
+            inspectionRule.setMissedCount(inspectionRule.getMissedCount() + missedCount);
+            bo.setCheckinStatus(0);
+            insertMissedCheckins(bo, sequence, missedCount);
+        }
+        bo.setPlannedSequence((int) sequence);
+        bo.setExecuteTime(now);
+        bo.setCheckinStatus(1);
+        InspectionCheckin checkin = MapstructUtils.convert(bo, InspectionCheckin.class);
+        if (checkin == null) {
+            throw new ServiceException("签到信息转换失败");
+        }
+        checkin.setImageUrl(imageUrl);
+        checkinMapper.insert(checkin);
+        return baseMapper.updateById(inspectionRule);
+    }
+
+    @Override
+    public Boolean cnaCheckin(Long id) {
+        InspectionRule inspectionRule = baseMapper.selectById(id);
+        if (inspectionRule == null) {
+            throw new ServiceException("记录不存在");
+        }
+        Date now = DateUtils.getNowDate();
+        return getSequence(inspectionRule, now) > 0;
+    }
+
+    private static long getSequence(InspectionRule inspectionRule, Date now) {
+        if (!Objects.equals(inspectionRule.getExecutorId(), LoginHelper.getUserId())) {
+            throw new ServiceException("非执行人不能签到");
+        }
+        if (inspectionRule.getStatus() == 1) {
+            throw new ServiceException("规则已停用");
+        }
+        if (inspectionRule.getEndTime().before(now)) {
+            throw new ServiceException("签到时间已过");
+        }
+        long diff = now.getTime() - inspectionRule.getStartTime().getTime();
+        long cycleMs = (long) (inspectionRule.getCycleHours() * 60 * 60 * 1000);
+        long sequence = diff / cycleMs;
+        long cycleDiff = diff - sequence * cycleMs;
+        long toleranceMs = (long) (inspectionRule.getToleranceHours() * 60 * 60 * 1000);
+        if (cycleDiff > toleranceMs) {
+            sequence++;
+            cycleDiff = sequence * cycleMs - diff;
+            if (cycleDiff > toleranceMs) {
+                throw new ServiceException("签到时间超出允许范围");
+            }
+        }
+        sequence++;
+        return sequence;
+    }
+
+    private void insertMissedCheckins(InspectionCheckinBo bo, long actualSequence, int missedCount) {
+        for (int i = 0; i < missedCount; i++) {
+            bo.setPlannedSequence((int) (actualSequence + i - missedCount));
+            InspectionCheckin checkin = MapstructUtils.convert(bo, InspectionCheckin.class);
+            checkinMapper.insert(checkin);
+        }
+    }
+
+
+}

+ 7 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/resources/mapper/device/InspectionCheckinMapper.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.vber.device.mapper.InspectionCheckinMapper">
+
+</mapper>

+ 7 - 0
SERVER/ChickenFarmV3/vb-modules/vb-device/src/main/resources/mapper/device/InspectionRuleMapper.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.vber.device.mapper.InspectionRuleMapper">
+
+</mapper>

+ 15 - 4
SERVER/ChickenFarmV3/vb-modules/vb-generator/src/main/resources/vm/vue/view.vue.vm

@@ -42,7 +42,7 @@
             #elseif($column.list)
                 #if(($field.indexOf("Time")>0&&$field.indexOf("Time")==${field.length()} - 4)
                 ||($field.indexOf("Date")>0&&$field.indexOf("Date")==${field.length()} - 4))
-                  {field: "$field", name: "$comment", visible: true, isSort: $sort, width: 185},
+                  {field: "$field", name: "$comment", visible: true, isSort: $sort, width: 145},
                 #elseif($field=="remark")
                   {field: "$field", name: "$comment", visible: true, isSort: $sort, tooltip: true},
                 #elseif($column.dictType)
@@ -351,9 +351,11 @@
       #end
     //
   }
+
   function handleCreate() {
     tableRef.value.defaultHandleFuns.handleCreate()
   }
+
   /** 修改按钮操作 */
   function handleUpdate(row: any) {
     tableRef.value.defaultHandleFuns.handleUpdate("", row)
@@ -425,15 +427,24 @@
               </template>
             #elseif($column.list && $field.indexOf("Time")>0 && $field.indexOf("Time")==${field.length()} - 4)
               <template #${field}="{ row }">
-                {{ dayjs(row.${field}).format("YYYY-MM-DD HH:mm:ss") }}
+                <template v-if="row.${field}">
+                  {{ dayjs(row.${field}).format("YYYY-MM-DD HH:mm:ss") }}
+                </template>
+                <template v-else>-</template>
               </template>
             #elseif($column.list && $field.indexOf("Date")>0 && $field.indexOf("Date")==${field.length()} - 4)
               <template #${field}="{ row }">
-                {{ dayjs(row.${field}).format("YYYY-MM-DD") }}
+                <template v-if="row.${field}">
+                  {{ dayjs(row.${field}).format("YYYY-MM-DD") }}
+                </template>
+                <template v-else>-</template>
               </template>
             #elseif($column.list && $column.javaType == "Date")
               <template #${field}="{ row }">
-                {{ dayjs(row.${field}).format("YYYY-MM-DD") }}
+                <template v-if="row.${field}">
+                  {{ dayjs(row.${field}).format("YYYY-MM-DD") }}
+                </template>
+                <template v-else>-</template>
               </template>
             #elseif($column.list && "" != $column.dictType)
               <template #${field}="{ row }">

+ 1 - 1
SERVER/ChickenFarmV3/vb-modules/vb-job/src/main/java/cn/vber/job/xxl/jobhandler/DeviceTaskJob.java

@@ -30,7 +30,7 @@ public class DeviceTaskJob {
                 XxlJobHelper.log("设备工单自动创建任务[{}_{}]:周期{}分钟,最后创建时间{}", task.getId(), task.getTaskName(), task.getCycle(), task.getLastCreateTime());
                 // 判断是否已超过周期
                 if (task.getLastCreateTime() != null && DateUtils.difference(task.getLastCreateTime(), DateUtils.getNowDate(), TimeUnit.MINUTES) < task.getCycle() * 60) {
-                    XxlJobHelper.log("设备工单自动创建任务[{}_{}]:已超过周期,跳过", task.getId(), task.getTaskName());
+                    XxlJobHelper.log("设备工单自动创建任务[{}_{}]:未达到周期,跳过", task.getId(), task.getTaskName());
                     continue;
                 }
                 if (task.getStartTime() != null && task.getStartTime().after(DateUtils.getNowDate())) {

+ 1 - 0
UI/VB.VUE/package.json

@@ -47,6 +47,7 @@
 		"vue-cropper": "1.0.3",
 		"vue-i18n": "^9.12.1",
 		"vue-json-pretty": "2.4.0",
+		"vue-qr": "^4.0.9",
 		"vue-router": "^4.2.4",
 		"vue3-treeselect-ts": "^0.0.4",
 		"vxe-table": "4.5.22",

+ 79 - 0
UI/VB.VUE/pnpm-lock.yaml

@@ -107,6 +107,9 @@ importers:
       vue-json-pretty:
         specifier: 2.4.0
         version: 2.4.0(vue@3.5.16(typescript@5.8.3))
+      vue-qr:
+        specifier: 4.0.9
+        version: 4.0.9
       vue-router:
         specifier: ^4.2.4
         version: 4.5.1(vue@3.5.16(typescript@5.8.3))
@@ -2064,6 +2067,10 @@ packages:
     resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
     engines: {node: '>=0.10'}
 
+  decompress-response@6.0.0:
+    resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+    engines: {node: '>=10'}
+
   deep-equal@1.1.2:
     resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==}
     engines: {node: '>= 0.4'}
@@ -2666,6 +2673,11 @@ packages:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
     deprecated: Glob versions prior to v9 are no longer supported
 
+  glob@8.1.0:
+    resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
+    engines: {node: '>=12'}
+    deprecated: Glob versions prior to v9 are no longer supported
+
   global-dirs@0.1.1:
     resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==}
     engines: {node: '>=4'}
@@ -3064,6 +3076,9 @@ packages:
   js-base64@2.6.4:
     resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==}
 
+  js-binary-schema-parser@2.0.3:
+    resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
+
   js-tokens@4.0.0:
     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
 
@@ -3380,6 +3395,10 @@ packages:
     resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
     engines: {node: '>=6'}
 
+  mimic-response@3.1.0:
+    resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+    engines: {node: '>=10'}
+
   min-dash@4.2.3:
     resolution: {integrity: sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==}
 
@@ -3393,6 +3412,10 @@ packages:
   minimatch@3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
 
+  minimatch@5.1.6:
+    resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
+    engines: {node: '>=10'}
+
   minimatch@9.0.3:
     resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==}
     engines: {node: '>=16 || 14 >=14.17'}
@@ -3613,6 +3636,9 @@ packages:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
 
+  parenthesis@3.1.8:
+    resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
+
   parse-json@5.2.0:
     resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
     engines: {node: '>=8'}
@@ -4068,6 +4094,12 @@ packages:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
 
+  simple-concat@1.0.1:
+    resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+  simple-get@4.0.1:
+    resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
   slash@3.0.0:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
@@ -4154,6 +4186,9 @@ packages:
     resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==}
     engines: {node: '>=0.10.0'}
 
+  string-split-by@1.0.0:
+    resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
+
   string-width@4.2.3:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
@@ -4702,6 +4737,9 @@ packages:
     peerDependencies:
       vue: '>=3.0.0'
 
+  vue-qr@4.0.9:
+    resolution: {integrity: sha512-pAISV94T0MNEYA3NGjykUpsXRE2QfaNxlu9ZhEL6CERgqNc21hJYuP3hRVzAWfBQlgO18DPmZTbrFerJC3+Ikw==}
+
   vue-router@4.5.1:
     resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
     peerDependencies:
@@ -6975,6 +7013,10 @@ snapshots:
 
   decode-uri-component@0.2.2: {}
 
+  decompress-response@6.0.0:
+    dependencies:
+      mimic-response: 3.1.0
+
   deep-equal@1.1.2:
     dependencies:
       is-arguments: 1.2.0
@@ -7762,6 +7804,14 @@ snapshots:
       once: 1.4.0
       path-is-absolute: 1.0.1
 
+  glob@8.1.0:
+    dependencies:
+      fs.realpath: 1.0.0
+      inflight: 1.0.6
+      inherits: 2.0.4
+      minimatch: 5.1.6
+      once: 1.4.0
+
   global-dirs@0.1.1:
     dependencies:
       ini: 1.3.8
@@ -8127,6 +8177,8 @@ snapshots:
 
   js-base64@2.6.4: {}
 
+  js-binary-schema-parser@2.0.3: {}
+
   js-tokens@4.0.0: {}
 
   js-tokens@9.0.1: {}
@@ -8417,6 +8469,8 @@ snapshots:
 
   mimic-fn@2.1.0: {}
 
+  mimic-response@3.1.0: {}
+
   min-dash@4.2.3: {}
 
   min-dom@4.2.1:
@@ -8431,6 +8485,10 @@ snapshots:
     dependencies:
       brace-expansion: 1.1.12
 
+  minimatch@5.1.6:
+    dependencies:
+      brace-expansion: 2.0.2
+
   minimatch@9.0.3:
     dependencies:
       brace-expansion: 2.0.2
@@ -8702,6 +8760,8 @@ snapshots:
     dependencies:
       callsites: 3.1.0
 
+  parenthesis@3.1.8: {}
+
   parse-json@5.2.0:
     dependencies:
       '@babel/code-frame': 7.27.1
@@ -9151,6 +9211,14 @@ snapshots:
 
   signal-exit@4.1.0: {}
 
+  simple-concat@1.0.1: {}
+
+  simple-get@4.0.1:
+    dependencies:
+      decompress-response: 6.0.0
+      once: 1.4.0
+      simple-concat: 1.0.1
+
   slash@3.0.0: {}
 
   slice-ansi@4.0.0:
@@ -9243,6 +9311,10 @@ snapshots:
 
   strict-uri-encode@1.1.0: {}
 
+  string-split-by@1.0.0:
+    dependencies:
+      parenthesis: 3.1.8
+
   string-width@4.2.3:
     dependencies:
       emoji-regex: 8.0.0
@@ -9873,6 +9945,13 @@ snapshots:
     dependencies:
       vue: 3.5.16(typescript@5.8.3)
 
+  vue-qr@4.0.9:
+    dependencies:
+      glob: 8.1.0
+      js-binary-schema-parser: 2.0.3
+      simple-get: 4.0.1
+      string-split-by: 1.0.0
+
   vue-router@4.5.1(vue@3.5.16(typescript@5.8.3)):
     dependencies:
       '@vue/devtools-api': 6.6.4

BIN
UI/VB.VUE/public/media/logo.png


+ 87 - 0
UI/VB.VUE/src/api/device/_inspectionRule.ts

@@ -0,0 +1,87 @@
+import Rs from "@/core/services/RequestService"
+
+class inspectionRuleApi {
+	tableUrl = "/device/inspectionRule/list"
+	exportUrl = "/device/inspectionRule/export"
+
+	// 查询点检规则管理列表
+	list = (query: any) => {
+		return Rs.get({
+			url: "/device/inspectionRule/list",
+			params: query,
+			loading: false
+		})
+	}
+
+	// 查询点检规则管理详细
+	get = (id: string) => {
+		return Rs.get({
+			url: "/device/inspectionRule/" + id,
+			loading: false
+		})
+	}
+
+	// 新增或修改点检规则管理
+	addOrUpdate = (data: any) => {
+		return new Promise((resolve) => {
+			if (data.id) {
+				this.update(data).then((res: any) => {
+					message.msgSuccess("修改成功")
+					resolve(res)
+				})
+			} else {
+				this.add(data).then((res: any) => {
+					message.msgSuccess("新增成功")
+					resolve(res)
+				})
+			}
+		})
+	}
+
+	// 新增点检规则管理
+	add = (data: any) => {
+		return Rs.post({
+			url: "/device/inspectionRule",
+			data: data,
+			successAlert: false
+		})
+	}
+
+	// 修改点检规则管理
+	update = (data: any) => {
+		return Rs.put({
+			url: "/device/inspectionRule",
+			data: data,
+			successAlert: false
+		})
+	}
+
+	// 删除点检规则管理
+	del = (id: string | string[]) => {
+		return Rs.del({
+			url: "/device/inspectionRule/" + id
+		})
+	}
+	queryCheckinList = (id: string | number) => {
+		return Rs.get({
+			url: "/device/inspectionRule/queryCheckinList/" + id
+		})
+	}
+	checkIn = (id: string) => {
+		return Rs.post({
+			url: "/device/inspectionRule/checkIn/" + id,
+			successAlert: false,
+			errorAlert: false
+		})
+	}
+	checkInWithPhoto = (data: any, id: string) => {
+		return Rs.post({
+			url: "/device/inspectionRule/checkInWithPhoto/" + id,
+			data: data,
+			successAlert: false,
+			errorAlert: false
+		})
+	}
+}
+
+export default inspectionRuleApi

+ 4 - 1
UI/VB.VUE/src/api/device/index.ts

@@ -1,17 +1,20 @@
 import Device from "./_device"
 import DeviceOrder from "./_deviceOrder"
 import DeviceTask from "./_deviceTask"
+import InspectionRule from "./_inspectionRule"
 
 export interface IDeviceApi {
 	deviceApi: Device
 	deviceOrderApi: DeviceOrder
 	deviceTaskApi: DeviceTask
+	inspectionRuleApi: InspectionRule
 }
 
 export const apis: IDeviceApi = {
 	deviceApi: new Device(),
 	deviceOrderApi: new DeviceOrder(),
-	deviceTaskApi: new DeviceTask()
+	deviceTaskApi: new DeviceTask(),
+	inspectionRuleApi: new InspectionRule()
 }
 
 export default apis

+ 386 - 0
UI/VB.VUE/src/components/camera/Capture.vue

@@ -0,0 +1,386 @@
+<script setup lang="ts">
+// 定义组件属性
+const props = withDefaults(
+	defineProps<{
+		confirmText?: string
+		autoClose?: boolean
+	}>(),
+	{
+		confirmText: "确认",
+		autoClose: true
+	}
+)
+
+// 定义事件
+const emit = defineEmits<{
+	(e: "capture", dataUrl: string): void
+	(e: "confirm", dataUrl: string): void
+	(e: "close"): void
+}>()
+
+const videoRef = ref<HTMLVideoElement | null>(null)
+const canvasRef = ref<HTMLCanvasElement | null>(null)
+const capturedPhoto = ref<string | null>(null)
+const stream = ref<MediaStream | null>(null)
+const showCamera = ref(false)
+const isRetaking = ref(false)
+
+// 打开相机
+async function openCamera() {
+	try {
+		// 先关闭可能存在的旧流
+		if (stream.value) {
+			stream.value.getTracks().forEach((track) => track.stop())
+			stream.value = null
+		}
+
+		// 针对移动设备的兼容性处理
+		const constraints = {
+			video: {
+				facingMode: "environment", // 优先使用后置摄像头
+				width: { ideal: 1280 },
+				height: { ideal: 720 }
+			}
+		}
+
+		// 在某些移动设备上可能需要降级配置
+		const mediaStream = await navigator.mediaDevices
+			.getUserMedia(constraints)
+			.catch(async () => {
+				// 降级尝试,不指定分辨率
+				return await navigator.mediaDevices.getUserMedia({
+					video: { facingMode: "environment" }
+				})
+			})
+			.catch(async () => {
+				// 再次降级尝试,使用默认摄像头
+				return await navigator.mediaDevices.getUserMedia({ video: true })
+			})
+
+		if (!mediaStream) {
+			throw new Error("无法访问摄像头")
+		}
+
+		stream.value = mediaStream
+		showCamera.value = true
+		capturedPhoto.value = null // 重置照片状态
+
+		// 等待DOM更新
+		await nextTick()
+
+		if (videoRef.value) {
+			videoRef.value.srcObject = mediaStream
+
+			// 针对iOS Safari的特殊处理
+			const playPromise = videoRef.value.play()
+			if (playPromise !== undefined) {
+				playPromise.catch((error) => {
+					console.warn("视频自动播放被阻止:", error)
+					// 在移动设备上,可能需要用户手势才能播放
+				})
+			}
+		}
+	} catch (err) {
+		console.error("无法访问摄像头:", err)
+		emit("capture", "")
+		showCamera.value = false
+	} finally {
+		// 无论成功与否,都结束loading状态
+		isRetaking.value = false
+	}
+}
+
+// 拍照
+function takePhoto() {
+	if (videoRef.value && canvasRef.value) {
+		const video = videoRef.value
+		const canvas = canvasRef.value
+		const context = canvas.getContext("2d")
+
+		if (context) {
+			// 获取设备屏幕尺寸
+			const screenWidth = window.screen.width
+			const screenHeight = window.screen.height
+
+			// 设置画布尺寸与设备屏幕一致
+			canvas.width = screenWidth
+			canvas.height = screenHeight
+
+			// 绘制视频帧到画布,保持宽高比并居中
+			const videoWidth = video.videoWidth || video.clientWidth || 1280
+			const videoHeight = video.videoHeight || video.clientHeight || 720
+
+			// 计算缩放比例,使视频填满整个画布(cover模式)
+			const scale = Math.max(screenWidth / videoWidth, screenHeight / videoHeight)
+			const scaledWidth = videoWidth * scale
+			const scaledHeight = videoHeight * scale
+
+			// 计算居中位置
+			const x = (screenWidth - scaledWidth) / 2
+			const y = (screenHeight - scaledHeight) / 2
+
+			// 绘制视频帧
+			context.drawImage(video, x, y, scaledWidth, scaledHeight)
+
+			// 添加日期水印
+			const now = new Date()
+			const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")} ${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`
+
+			context.font = "24px Arial"
+			context.fillStyle = "rgba(255, 255, 255, 0.9)"
+			context.strokeStyle = "rgba(0, 0, 0, 0.7)"
+			context.lineWidth = 3
+			context.textAlign = "center"
+			context.textBaseline = "middle"
+
+			// 将水印放在照片正中央
+			const watermarkX = screenWidth / 2
+			const watermarkY = screenHeight / 2
+
+			// 绘制带边框的文字(水印)
+			context.strokeText(dateStr, watermarkX, watermarkY)
+			context.fillText(dateStr, watermarkX, watermarkY)
+
+			// 保存图片数据
+			capturedPhoto.value = canvas.toDataURL("image/jpeg", 0.8)
+
+			// 触发拍照事件
+			emit("capture", capturedPhoto.value)
+		}
+	}
+}
+
+// 关闭相机
+function closeCamera() {
+	if (stream.value) {
+		stream.value.getTracks().forEach((track) => track.stop())
+		stream.value = null
+	}
+	showCamera.value = false
+	capturedPhoto.value = null
+	if (videoRef.value) {
+		videoRef.value.srcObject = null
+	}
+	emit("close")
+}
+
+// 重新拍照
+async function retakePhoto() {
+	// 设置loading状态
+	isRetaking.value = true
+
+	// 重置已拍摄的照片
+	capturedPhoto.value = null
+
+	// 如果视频流不存在,则重新打开摄像头
+	if (!stream.value || !videoRef.value || !videoRef.value.srcObject) {
+		await openCamera()
+	} else {
+		// 如果视频流仍然存在,只需重置状态并停止loading
+		isRetaking.value = false
+	}
+}
+
+// 确认拍照
+function confirmCapture() {
+	if (capturedPhoto.value) {
+		emit("confirm", capturedPhoto.value)
+
+		// 如果设置了自动关闭,则关闭相机
+		if (props.autoClose) {
+			closeCamera()
+		}
+	}
+}
+
+// 组件卸载时清理资源
+onUnmounted(() => {
+	closeCamera()
+})
+
+// 添加一个用户手势处理函数
+function handleUserGesture() {
+	if (videoRef.value && videoRef.value.srcObject) {
+		videoRef.value.play().catch((err) => {
+			console.error("视频播放失败:", err)
+		})
+	}
+}
+
+// 提供对外方法
+defineExpose({
+	openCamera,
+	closeCamera
+})
+</script>
+<template>
+	<div class="camera-container" v-if="showCamera">
+		<!-- 相机全屏页面 -->
+		<div class="camera-fullscreen" @click="handleUserGesture">
+			<!-- 左上角关闭按钮 -->
+			<div class="close-camera-btn" @click.stop="closeCamera">
+				<div class="transparent-close-btn">✕</div>
+			</div>
+			<!-- 拍照前显示相机 -->
+			<template v-if="!capturedPhoto">
+				<video ref="videoRef" autoplay playsinline muted class="camera-video"></video>
+				<canvas ref="canvasRef" class="d-none"></canvas>
+				<!-- 底部拍照按钮 -->
+				<div class="camera-controls">
+					<div class="capture-btn-outer" @click.stop="takePhoto">
+						<div class="capture-btn-inner"></div>
+					</div>
+				</div>
+			</template>
+
+			<!-- 拍照后显示照片和操作按钮 -->
+			<template v-else>
+				<div class="photo-preview-fullscreen d-flex flex-column">
+					<img :src="capturedPhoto" alt="预览照片" class="preview-image" />
+
+					<div class="photo-actions d-flex justify-content-center gap-3 mt-1">
+						<el-button @click.stop="retakePhoto" size="large" :loading="isRetaking">
+							{{ isRetaking ? "正在打开摄像头..." : "重新拍照" }}
+						</el-button>
+						<el-button type="primary" @click.stop="confirmCapture" size="large">
+							{{ confirmText }}
+						</el-button>
+					</div>
+				</div>
+			</template>
+		</div>
+	</div>
+</template>
+
+<style scoped lang="scss">
+.camera-container {
+	.camera-fullscreen {
+		position: fixed;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		z-index: 1000;
+		background-color: #000;
+
+		.camera-video {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+		}
+
+		.close-camera-btn {
+			position: absolute;
+			top: 20px;
+			left: 20px;
+			z-index: 1003;
+
+			.transparent-close-btn {
+				width: 40px;
+				height: 40px;
+				border-radius: 50%;
+				background-color: rgba(0, 0, 0, 0.5);
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				color: white;
+				font-size: 24px;
+				font-weight: bold;
+				cursor: pointer;
+				border: 1px solid rgba(255, 255, 255, 0.2);
+				user-select: none;
+			}
+		}
+
+		.camera-controls {
+			position: absolute;
+			bottom: 50px;
+			left: 0;
+			width: 100%;
+			height: 100px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			z-index: 1003;
+			pointer-events: none;
+
+			.capture-btn-outer {
+				width: 70px;
+				height: 70px;
+				border-radius: 50%;
+				background-color: rgba(255, 255, 255, 0.3);
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				cursor: pointer;
+				border: 2px solid white;
+				pointer-events: auto;
+
+				.capture-btn-inner {
+					width: 60px;
+					height: 60px;
+					border-radius: 50%;
+					background-color: white;
+				}
+			}
+		}
+
+		.photo-preview-fullscreen {
+			width: 100%;
+			height: 100%;
+			background-color: #000;
+			z-index: 1002;
+
+			.preview-image {
+				flex: 1;
+				object-fit: contain;
+				max-width: 100%;
+				max-height: calc(100vh - 120px);
+				margin: 20px auto 0;
+			}
+
+			.photo-actions {
+				padding: 20px;
+				background-color: rgba(0, 0, 0, 0.7);
+				position: absolute;
+				bottom: 0;
+				width: 100%;
+				display: flex;
+				justify-content: center;
+				flex-wrap: wrap;
+				gap: 10px;
+			}
+		}
+	}
+
+	// 移动端特殊样式
+	@media (max-width: 768px) {
+		.camera-fullscreen {
+			.close-camera-btn {
+				top: 15px;
+				left: 15px;
+
+				.transparent-close-btn {
+					width: 35px;
+					height: 35px;
+					font-size: 20px;
+				}
+			}
+
+			.camera-controls {
+				height: 80px;
+
+				.capture-btn-outer {
+					width: 60px;
+					height: 60px;
+
+					.capture-btn-inner {
+						width: 50px;
+						height: 50px;
+					}
+				}
+			}
+		}
+	}
+}
+</style>

+ 2 - 1
UI/VB.VUE/src/core/utils/sse.ts

@@ -52,9 +52,10 @@ export const initSSE = (url: any) => {
 			time: new Date().toLocaleString(),
 			data: noticeData
 		}
+
 		appStore.noticeStore.addNotice(notice)
 
-		console.log("SSE NOTICE:", notice, data.value)
+		// console.log("SSE NOTICE:", notice, data.value)
 
 		data.value = null
 	})

+ 2 - 2
UI/VB.VUE/src/core/vb-dom/components/_MenuComponent.ts

@@ -869,7 +869,7 @@ class MenuComponent {
 		}
 
 		// Element has .menu parent
-		const menu = element.closest(".menu")
+		const menu = element?.closest(".menu")
 		if (menu) {
 			const menuData = DataUtil.get(menu as HTMLElement, "menu")
 			if (menuData) {
@@ -878,7 +878,7 @@ class MenuComponent {
 		}
 
 		// Element has a parent with DOM reference to .menu in it's DATA storage
-		if (element.classList.contains("menu-link")) {
+		if (element?.classList.contains("menu-link")) {
 			const sub = element.closest(".menu-sub")
 			if (sub) {
 				const subMenu = DataUtil.get(sub as HTMLElement, "menu")

+ 9 - 0
UI/VB.VUE/src/router/_staticRouter.ts

@@ -77,6 +77,15 @@ export const staticRouter: RouteRecordRaw[] = [
 			}
 		]
 	},
+	{
+		path: "/is/checkin/:id?",
+		name: "inspectionRule",
+		component: () => import("@/views/device/inspection/checkin.vue"),
+		meta: {
+			title: "点检签到",
+			noCache: false
+		}
+	},
 	{
 		path: "/",
 		children: [

+ 3 - 1
UI/VB.VUE/src/stores/_notice.ts

@@ -24,7 +24,9 @@ export const useNoticeStore = defineStore("noticeStore", () => {
 		return notices.value
 	}
 	function addNotice(notice: NoticeItem, isAlert = true) {
-		notices.value.push(notice)
+		if (!notice.message.startsWith("欢迎登录")) {
+			notices.value.push(notice)
+		}
 
 		if (isAlert && notice.message) {
 			const elNoticeInstance = ElNotification({

+ 97 - 0
UI/VB.VUE/src/views/device/inspection/_checkinLog.vue

@@ -0,0 +1,97 @@
+<script setup lang="ts">
+import apis from "@a"
+import dayjs from "dayjs"
+
+const props = withDefaults(defineProps<{ isSelf?: boolean; showImage?: boolean }>(), {
+	isSelf: true,
+	showImage: false
+})
+const emits = defineEmits<{ (e: "update:modelValue", v: string): void }>()
+
+const logs = ref()
+
+function load(id: string | number) {
+	return new Promise((resolve) => {
+		apis.device.inspectionRuleApi.queryCheckinList(id).then((res: any) => {
+			logs.value = res.data
+			resolve(res)
+		})
+	})
+}
+defineExpose({ load })
+</script>
+<template>
+	<div class="text-center mt-5 mb-3 fs-1 fw-bold" v-if="props.isSelf && logs && logs.length > 0">
+		签到记录
+	</div>
+	<table class="checkin-log-table" v-if="logs && logs.length > 0">
+		<thead>
+			<tr>
+				<th :style="{ width: isSelf ? '55px' : '60px' }">次序</th>
+				<th v-if="!isSelf" :style="{ width: isSelf ? '90px' : '160px' }">签到人</th>
+				<th :style="{ width: isSelf ? '135px' : '160px' }">签到时间</th>
+				<th :style="{ width: isSelf ? 'auto' : '160px' }">签到状态</th>
+				<th v-if="showImage" :style="{ width: isSelf ? '135px' : 'auto' }">签到图片</th>
+			</tr>
+		</thead>
+		<tbody>
+			<tr v-for="(row, index) in logs" :key="index">
+				<td>{{ row.plannedSequence }}</td>
+				<td v-if="!isSelf" :style="{ width: isSelf ? '90px' : '160px' }">
+					{{ row.inspectorName }}
+				</td>
+				<td :colspan="row.checkinStatus == 0 ? 3 : 0">
+					<span v-if="row.executeTime">
+						{{ dayjs(row.executeTime).format("YYYY/MM/DD HH:mm:ss") }}
+					</span>
+					<el-tag v-else type="danger">未签到</el-tag>
+				</td>
+				<td v-if="row.checkinStatus != 0">
+					<el-tag v-if="row.checkinStatus == 1" type="success">已签到</el-tag>
+					<el-tag v-else type="danger">未签到</el-tag>
+				</td>
+				<td v-if="row.checkinStatus != 0 && showImage">
+					<VbImagePreview
+						v-if="row.imageUrl"
+						:src="row.imageUrl"
+						:width="45"
+						:height="30"
+						:image-style="{ margin: `4px 5px -4px 5px`, padding: 0 }"></VbImagePreview>
+					<span v-else>-</span>
+				</td>
+			</tr>
+		</tbody>
+	</table>
+	<div class="no-data" v-else>暂无签到记录</div>
+</template>
+
+<style scoped>
+.checkin-log-table {
+	width: 100%;
+	border-collapse: collapse;
+	border: 1px solid #ebeef5;
+	border-radius: 4px;
+	overflow: hidden;
+}
+
+.checkin-log-table th {
+	background-color: #f5f7fa;
+	padding: 12px 0;
+	text-align: center;
+	font-weight: 500;
+	color: #909399;
+}
+
+.checkin-log-table td {
+	padding: 12px 0;
+	text-align: center;
+	border-top: 1px solid #ebeef5;
+}
+
+.no-data {
+	text-align: center;
+	font-size: 18px;
+	padding: 100px 20px;
+	color: #909399;
+}
+</style>

+ 365 - 0
UI/VB.VUE/src/views/device/inspection/checkin.vue

@@ -0,0 +1,365 @@
+<script setup lang="ts">
+import apis from "@a"
+import Capture from "@/components/camera/Capture.vue"
+import CheckinLog from "./_checkinLog.vue"
+
+const route = useRoute()
+const inspectionId = computed(() => {
+	return route.params.id as any
+})
+const showImage = ref(false)
+const logsRef = ref()
+const captureRef = ref<InstanceType<typeof Capture> | null>(null)
+const showCapture = ref(false)
+
+// 拖拽相关变量
+const dragState = reactive({
+	isDragging: false,
+	startX: 0,
+	startY: 0,
+	offsetX: 0,
+	offsetY: 0,
+	positionX: 20,
+	positionY: window.innerHeight - 150, // 往上一点
+	moved: false // 用于区分点击和拖动
+})
+
+function handleSubmit() {
+	if (inspectionId.value == null) {
+		message.msgError("未选择任务")
+		return
+	}
+	if (showImage.value) {
+		openCapture()
+	} else {
+		message.confirm("请确认是否签到", "确认签到").then(() => {
+			apis.device.inspectionRuleApi
+				.checkIn(inspectionId.value)
+				.then(() => {
+					message.msgSuccess("签到成功")
+					logsRef.value.load(inspectionId.value)
+				})
+				.catch((msg) => {
+					message.alertError("签到失败: " + msg)
+					logsRef.value.load(inspectionId.value)
+				})
+		})
+	}
+}
+
+// 打开摄像头
+function openCapture() {
+	showCapture.value = true
+	nextTick(() => {
+		if (captureRef.value) {
+			captureRef.value.openCamera()
+		}
+	})
+}
+
+// 处理拍照完成事件
+const handleCapture = (dataUrl: string) => {
+	// 可以在这里处理拍照完成的逻辑
+	console.log("拍照完成")
+}
+
+// 处理确认提交事件
+const handleConfirm = async (dataUrl: string) => {
+	if (!dataUrl) {
+		message.msgError("请先拍照")
+		return
+	}
+
+	if (inspectionId.value == null) {
+		message.msgError("未选择任务")
+		return
+	}
+
+	try {
+		// 将 base64 转换为 Blob
+		const blob = await fetch(dataUrl).then((res) => res.blob())
+
+		// 创建 FormData 并添加图片
+		const formData = new FormData()
+		formData.append("photo", blob, `isCheckin_${inspectionId.value}_${new Date().getTime()}.jpg`)
+		// formData.append("id", inspectionId.value)
+
+		// 上传照片并签到
+		apis.device.inspectionRuleApi
+			.checkInWithPhoto(formData, inspectionId.value)
+			.then(() => {
+				message.msgSuccess("签到成功")
+				logsRef.value.load(inspectionId.value)
+			})
+			.catch((msg) => {
+				message.alertError("签到失败: " + msg)
+				logsRef.value.load(inspectionId.value)
+			})
+	} catch (err) {
+		console.error("签到失败:", err)
+		message.msgError("签到失败")
+	}
+}
+
+// 处理关闭事件
+const handleClose = () => {
+	showCapture.value = false
+	console.log("相机已关闭")
+}
+
+// 鼠标拖拽开始
+const handleDragStart = (event: MouseEvent) => {
+	dragState.isDragging = true
+	dragState.moved = false
+	dragState.startX = event.clientX
+	dragState.startY = event.clientY
+	dragState.offsetX = event.clientX - dragState.positionX
+	dragState.offsetY = event.clientY - dragState.positionY
+
+	// 添加事件监听器
+	document.addEventListener("mousemove", handleDragMove)
+	document.addEventListener("mouseup", handleDragEnd)
+
+	event.preventDefault()
+}
+
+// 触摸拖拽开始
+const handleTouchStart = (event: TouchEvent) => {
+	const touch = event.touches[0]
+	dragState.isDragging = true
+	dragState.moved = false
+	dragState.startX = touch.clientX
+	dragState.startY = touch.clientY
+	dragState.offsetX = touch.clientX - dragState.positionX
+	dragState.offsetY = touch.clientY - dragState.positionY
+
+	// 添加事件监听器
+	document.addEventListener("touchmove", handleTouchMove, { passive: false })
+	document.addEventListener("touchend", handleTouchEnd)
+
+	event.preventDefault()
+}
+
+// 鼠标拖拽移动
+const handleDragMove = (event: MouseEvent) => {
+	if (!dragState.isDragging) return
+
+	const deltaX = Math.abs(event.clientX - dragState.startX)
+	const deltaY = Math.abs(event.clientY - dragState.startY)
+
+	// 只有当移动超过一定距离时才认为是拖动
+	if (deltaX > 5 || deltaY > 5) {
+		dragState.moved = true
+
+		dragState.positionX = event.clientX - dragState.offsetX
+		dragState.positionY = event.clientY - dragState.offsetY
+
+		// 边界检查
+		const buttonSize = 80 // 调整为新的按钮大小
+		dragState.positionX = Math.max(0, Math.min(window.innerWidth - buttonSize, dragState.positionX))
+		dragState.positionY = Math.max(
+			0,
+			Math.min(window.innerHeight - buttonSize, dragState.positionY)
+		)
+	}
+
+	event.preventDefault()
+}
+
+// 触摸拖拽移动
+const handleTouchMove = (event: TouchEvent) => {
+	if (!dragState.isDragging) return
+
+	const touch = event.touches[0]
+	const deltaX = Math.abs(touch.clientX - dragState.startX)
+	const deltaY = Math.abs(touch.clientY - dragState.startY)
+
+	// 只有当移动超过一定距离时才认为是拖动
+	if (deltaX > 5 || deltaY > 5) {
+		dragState.moved = true
+
+		dragState.positionX = touch.clientX - dragState.offsetX
+		dragState.positionY = touch.clientY - dragState.offsetY
+
+		// 边界检查
+		const buttonSize = 80 // 调整为新的按钮大小
+		dragState.positionX = Math.max(0, Math.min(window.innerWidth - buttonSize, dragState.positionX))
+		dragState.positionY = Math.max(
+			0,
+			Math.min(window.innerHeight - buttonSize, dragState.positionY)
+		)
+	}
+
+	event.preventDefault()
+}
+
+// 鼠标拖拽结束
+const handleDragEnd = () => {
+	if (dragState.isDragging) {
+		dragState.isDragging = false
+
+		// 只有在没有移动或移动距离很小时才触发点击
+		if (!dragState.moved) {
+			handleSubmit()
+		} else {
+			// 根据位置靠左或靠右
+			const buttonSize = 80 // 调整为新的按钮大小
+			const middlePoint = window.innerWidth / 2
+			if (dragState.positionX < middlePoint) {
+				dragState.positionX = 20 // 靠左
+			} else {
+				dragState.positionX = window.innerWidth - buttonSize - 20 // 靠右
+			}
+		}
+
+		// 移除事件监听器
+		document.removeEventListener("mousemove", handleDragMove)
+		document.removeEventListener("mouseup", handleDragEnd)
+	}
+}
+
+// 触摸拖拽结束
+const handleTouchEnd = () => {
+	if (dragState.isDragging) {
+		dragState.isDragging = false
+
+		// 只有在没有移动或移动距离很小时才触发点击
+		if (!dragState.moved) {
+			handleSubmit()
+		} else {
+			// 根据位置靠左或靠右
+			const buttonSize = 80 // 调整为新的按钮大小
+			const middlePoint = window.innerWidth / 2
+			if (dragState.positionX < middlePoint) {
+				dragState.positionX = 20 // 靠左
+			} else {
+				dragState.positionX = window.innerWidth - buttonSize - 20 // 靠右
+			}
+		}
+
+		// 移除事件监听器
+		document.removeEventListener("touchmove", handleTouchMove)
+		document.removeEventListener("touchend", handleTouchEnd)
+	}
+}
+
+function init() {
+	nextTick(() => {
+		if (logsRef.value) {
+			// console.log("加载签到记录", inspectionId.value)
+			logsRef.value.load(inspectionId.value)
+		}
+	})
+}
+onMounted(() => {
+	init()
+})
+</script>
+
+<template>
+	<div class="app-container">
+		<div v-if="!showCapture" class="w-100">
+			<CheckinLog :show-image="showImage" :is-self="true" ref="logsRef" />
+		</div>
+		<!-- 圆形可拖拽签到按钮 -->
+		<div
+			class="drag-button"
+			:style="{
+				left: dragState.positionX + 'px',
+				top: dragState.positionY + 'px',
+				cursor: dragState.isDragging ? 'grabbing' : 'grab'
+			}"
+			@mousedown="handleDragStart"
+			@touchstart="handleTouchStart">
+			<div class="pulse-ring"></div>
+			<div class="pulse-ring pulse-ring-2"></div>
+			<el-button type="primary" class="circle-button" size="large" round>签到</el-button>
+		</div>
+		<Capture
+			v-if="showImage"
+			ref="captureRef"
+			confirm-text="确认签到"
+			:auto-close="true"
+			@capture="handleCapture"
+			@confirm="handleConfirm"
+			@close="handleClose" />
+	</div>
+</template>
+
+<style scoped lang="scss">
+.app-container {
+	width: 100vw;
+	height: 100vh;
+}
+
+.drag-button {
+	position: fixed;
+	z-index: 1000;
+	user-select: none;
+}
+
+.circle-button {
+	width: 80px; // 增大按钮尺寸
+	height: 80px; // 增大按钮尺寸
+	border-radius: 50%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+	border: none;
+	padding: 0;
+	font-size: 18px; // 增大字体
+	font-weight: bold;
+	z-index: 1002;
+	position: relative;
+	background-color: #409eff; // 确保按钮背景色为蓝色
+	border: none;
+}
+
+// 蓝色呼吸光圈动画
+.pulse-ring {
+	position: absolute;
+	width: 80px;
+	height: 80px;
+	border-radius: 50%;
+	top: 0;
+	left: 0;
+	animation: pulse 1.5s infinite;
+	z-index: 1001;
+	background-color: #409eff; // 使用 Element UI 主题蓝色
+	opacity: 0.3;
+}
+
+.pulse-ring-2 {
+	animation-delay: 0.75s;
+}
+
+@keyframes pulse {
+	0% {
+		transform: scale(0.8);
+		opacity: 0.4;
+	}
+	50% {
+		transform: scale(1.2);
+		opacity: 0.2;
+	}
+	100% {
+		transform: scale(1.5);
+		opacity: 0;
+	}
+}
+
+// 移动端适配
+@media (max-width: 768px) {
+	.circle-button {
+		width: 70px;
+		height: 70px;
+		font-size: 16px;
+	}
+
+	.pulse-ring {
+		width: 70px;
+		height: 70px;
+	}
+}
+</style>

+ 479 - 0
UI/VB.VUE/src/views/device/inspection/index.vue

@@ -0,0 +1,479 @@
+<script setup lang="ts" name="InspectionRule">
+import apis from "@a"
+import dayjs from "dayjs"
+import vueQr from "vue-qr/src/packages/vue-qr.vue"
+import CheckinLog from "./_checkinLog.vue"
+const tableRef = ref()
+const modalRef = ref()
+const opts = reactive({
+	columns: [
+		{ field: "id", name: "点检规则ID", width: 100, isSort: true, visible: false, tooltip: true },
+		{
+			field: "taskName",
+			name: "任务名称",
+			visible: true,
+			isSort: false,
+			width: "auto",
+			tooltip: true
+		},
+		{ field: "location", name: "地点", visible: true, isSort: false, width: "auto", tooltip: true },
+		{
+			field: "cycleHours",
+			name: "周期(单位:时)",
+			visible: true,
+			isSort: false,
+			width: 140,
+			tooltip: true
+		},
+		{
+			field: "toleranceHours",
+			name: "误差(单位:时)",
+			visible: true,
+			isSort: false,
+			width: 140,
+			tooltip: true
+		},
+		{ field: "startTime", name: "开始时间", visible: true, isSort: false, width: 185 },
+		{ field: "endTime", name: "结束时间", visible: true, isSort: false, width: 185 },
+		// {
+		// 	field: "requiredCount",
+		// 	name: "需点检总次数",
+		// 	visible: true,
+		// 	isSort: false,
+		// 	width: "auto",
+		// 	tooltip: true
+		// },
+		// {
+		// 	field: "actualCount",
+		// 	name: "实际点检次数",
+		// 	visible: true,
+		// 	isSort: false,
+		// 	width: "auto",
+		// 	tooltip: true
+		// },
+		// {
+		// 	field: "missedCount",
+		// 	name: "漏检次数",
+		// 	visible: true,
+		// 	isSort: false,
+		// 	width: "auto",
+		// 	tooltip: true
+		// },
+		{
+			field: "executorName",
+			name: "执行人",
+			visible: true,
+			isSort: false,
+			width: 100,
+			tooltip: true
+		},
+		{ field: "status", name: "启用状态", visible: true, isSort: false, width: 100 },
+		{ field: "actions", name: `操作`, width: 150 }
+	] as any[],
+	queryParams: {
+		taskName: undefined,
+		location: undefined,
+		status: undefined
+	},
+	searchFormItems: [
+		{
+			field: "taskName",
+			label: "任务名称",
+			class: "w-100",
+			required: false,
+			placeholder: "请输入任务名称",
+			component: "I",
+			listeners: {
+				keyup: (e: KeyboardEvent) => {
+					if (e.code == "Enter") {
+						handleQuery()
+					}
+				}
+			}
+		},
+		{
+			field: "location",
+			label: "地点",
+			class: "w-100",
+			required: false,
+			placeholder: "请输入地点",
+			component: "I",
+			listeners: {
+				keyup: (e: KeyboardEvent) => {
+					if (e.code == "Enter") {
+						handleQuery()
+					}
+				}
+			}
+		},
+		{
+			field: "status",
+			label: "启用状态",
+			class: "w-100",
+			required: false,
+			component: "Dict",
+			props: {
+				placeholder: "请选择启用状态",
+				dictType: "sys_normal_disable",
+				valueIsNumber: 1,
+				type: "select"
+			},
+			listeners: {
+				change: () => {
+					handleQuery()
+				}
+			}
+		}
+	] as any,
+	permission: "device:inspection",
+	handleBtns: [],
+	handleFuns: {
+		handleCreate,
+		handleUpdate: () => {
+			const row = tableRef.value.getSelected()
+			handleUpdate(row)
+		},
+		handleDelete: () => {
+			const rows = tableRef.value.getSelecteds()
+			handleDelete(rows)
+		}
+	},
+	customBtns: [],
+	tableListFun: apis.device.inspectionRuleApi.list,
+	getEntityFun: apis.device.inspectionRuleApi.get,
+	deleteEntityFun: apis.device.inspectionRuleApi.del,
+	exportUrl: apis.device.inspectionRuleApi.exportUrl,
+	exportName: "InspectionRule",
+	modalTitle: "点检规则管理",
+	formItems: [
+		{
+			field: "taskName",
+			label: "任务名称",
+			class: "w-100",
+			required: true,
+			placeholder: "请输入任务名称",
+			component: "I"
+		},
+		{
+			field: "location",
+			label: "地点",
+			class: "w-100",
+			required: false,
+			placeholder: "请输入地点",
+			component: "I"
+		},
+		{
+			field: "cycleHours",
+			label: "点检周期",
+			class: "w-100",
+			required: true,
+			placeholder: "请输入点检周期",
+			component: "I"
+		},
+		{
+			field: "toleranceHours",
+			label: "误差",
+			class: "w-100",
+			required: false,
+			placeholder: "请输入误差",
+			component: "I"
+		},
+		{
+			field: "startTime",
+			label: "开始时间",
+			class: "w-100",
+			required: false,
+			component: "D",
+			props: {
+				placeholder: "请选择开始时间",
+				type: "datetime",
+				valueFormat: "YYYY-MM-DD HH:mm:ss"
+			}
+		},
+		{
+			field: "endTime",
+			label: "结束时间",
+			class: "w-100",
+			required: false,
+			component: "D",
+			props: {
+				placeholder: "请选择结束时间",
+				type: "datetime",
+				valueFormat: "YYYY-MM-DD HH:mm:ss"
+			}
+		},
+		{
+			field: "executorId",
+			label: "执行人",
+			class: "w-100",
+			required: false,
+			placeholder: "请输入执行人",
+			component: "I"
+		}
+	] as any,
+	resetForm: () => {
+		form.value = emptyFormData.value
+	},
+	labelWidth: "80px",
+	emptyFormData: {
+		id: undefined,
+		taskName: undefined,
+		location: undefined,
+		cycleHours: undefined,
+		toleranceHours: undefined,
+		startTime: undefined,
+		endTime: undefined,
+		executorId: undefined
+	}
+})
+const { queryParams, emptyFormData } = toRefs(opts)
+const form = ref<any>(emptyFormData.value)
+
+/** 搜索按钮操作 */
+function handleQuery(query?: any) {
+	query = query || tableRef.value?.getQueryParams() || queryParams.value
+	addDateRange(query, query.dateRangeStartTime, "StartTime")
+	addDateRange(query, query.dateRangeEndTime, "EndTime")
+	addDateRange(query, query.dateRangeCreateTime)
+	addDateRange(query, query.dateRangeUpdateTime, "UpdateTime")
+	tableRef.value?.query(query)
+}
+
+/** 重置按钮操作 */
+function resetQuery(query?: any) {
+	query = query || tableRef.value?.getQueryParams() || queryParams.value
+	query.dateRangeStartTime = [] as any
+	addDateRange(query, query.dateRangeStartTime, "StartTime")
+	query.dateRangeEndTime = [] as any
+	addDateRange(query, query.dateRangeEndTime, "EndTime")
+	query.dateRangeCreateTime = [] as any
+	addDateRange(query, query.dateRangeCreateTime)
+	query.dateRangeUpdateTime = [] as any
+	addDateRange(query, query.dateRangeUpdateTime, "UpdateTime")
+	//
+}
+function handleCreate() {
+	tableRef.value.defaultHandleFuns.handleCreate()
+}
+/** 修改按钮操作 */
+function handleUpdate(row: any) {
+	tableRef.value.defaultHandleFuns.handleUpdate("", row)
+}
+
+/** 删除按钮操作 */
+function handleDelete(rows: any[]) {
+	tableRef.value.defaultHandleFuns.handleDelete("", rows)
+}
+
+/** 提交按钮 */
+function submitForm() {
+	apis.device.inspectionRuleApi.addOrUpdate(form.value).then(() => {
+		handleQuery()
+	})
+}
+
+const qrModalRef = ref()
+const qrModalData = ref()
+const qrCode = ref({
+	logo: "/media/logo.png",
+	size: 300,
+	colorDark: "#0e9489",
+	text: ""
+})
+function handleQrCode(row) {
+	qrModalData.value = row
+	qrCode.value.text = `vb@device@/is/checkin/${row.id}`
+	qrModalRef.value.show()
+}
+function handleDownloadQr(id: string) {
+	let myImg = document.querySelector("#" + id + " img") as HTMLImageElement
+	let url = myImg?.src
+	let a = document.createElement("a")
+	a.href = url
+	a.download = `${qrModalData.value.taskName}签到二维码`
+	a.click()
+}
+// function handlePrintQr(id: string) {
+// 	const printContent = document.querySelector("#" + id)
+// 	if (!printContent) return
+
+// 	// 创建打印样式
+// 	const style = document.createElement("style")
+// 	style.innerHTML = `
+// 		@media print {
+// 			body * {
+// 				display: none !important;
+//       }
+// 			#print-area, #print-area * {
+// 				display: block !important;
+// 			}
+// 			#print-area {
+// 				position: absolute;
+// 				top: 0;
+// 				left: 0;
+//         width: 100vw;
+//         height: 600px;
+// 				display: flex !important;
+// 				flex-direction: column;
+// 				align-items: center;
+// 				justify-content: center;
+// 				font-family: Arial, sans-serif;
+// 			}
+
+// 		}
+// 	`
+// 	document.head.appendChild(style)
+
+// 	// 创建打印区域
+// 	const printArea = document.createElement("div")
+// 	printArea.id = "print-area"
+// 	// printArea.innerHTML = `
+// 	// 	<div style="width:100%;display:flex;flex-direction: column;align-items: center;justify-content: center;">${printContent.innerHTML}</div>
+// 	// `
+// 	printArea.innerHTML = printContent.innerHTML
+// 	document.body.appendChild(printArea)
+
+// 	// 打印
+// 	window.print()
+
+// 	// 清理
+// 	setTimeout(() => {
+// 		document.head.removeChild(style)
+// 		document.body.removeChild(printArea)
+// 	}, 500)
+// }
+const logModalRef = ref()
+const logsRef = ref()
+function handleLogs(row: any) {
+	logsRef.value.load(row.id).then(() => {
+		logModalRef.value.show()
+	})
+}
+</script>
+<template>
+	<div class="app-container">
+		<VbDataTable
+			ref="tableRef"
+			keyField="id"
+			:columns="opts.columns"
+			:handle-perm="opts.permission"
+			:handle-btns="opts.handleBtns"
+			:handle-funs="opts.handleFuns"
+			:search-form-items="opts.searchFormItems"
+			:custom-btns="opts.customBtns"
+			:remote-fun="opts.tableListFun"
+			:get-entity-fun="opts.getEntityFun"
+			:delete-entity-fun="opts.deleteEntityFun"
+			:export-url="opts.exportUrl"
+			:export-name="opts.exportName"
+			:modal="modalRef"
+			:reset-form-fun="opts.resetForm"
+			v-model:form-data="form"
+			v-model:query-params="queryParams"
+			:check-multiple="true"
+			:reset-search-form-fun="resetQuery"
+			:custom-search-fun="handleQuery">
+			<template #startTime="{ row }">
+				<template v-if="row.startTime">
+					{{ dayjs(row.startTime).format("YYYY-MM-DD HH:mm:ss") }}
+				</template>
+				<template v-else>-</template>
+			</template>
+			<template #endTime="{ row }">
+				<template v-if="row.startTime">
+					{{ dayjs(row.endTime).format("YYYY-MM-DD HH:mm:ss") }}
+				</template>
+				<template v-else>-</template>
+			</template>
+			<template #status="{ row }">
+				<DictTag type="sys_normal_disable" :value-is-number="1" :value="row.status"></DictTag>
+			</template>
+			<template #actions="{ row }">
+				<vb-tooltip content="生成二维码" placement="top">
+					<el-button link type="success" @click="handleQrCode(row)">
+						<template #icon>
+							<VbIcon icon-name="scan-barcode" icon-type="duotone" class="fs-3"></VbIcon>
+						</template>
+					</el-button>
+				</vb-tooltip>
+				<vb-tooltip content="签到日志" placement="top">
+					<el-button link type="primary" @click="handleLogs(row)">
+						<template #icon>
+							<VbIcon icon-name="book" icon-type="duotone" class="fs-3"></VbIcon>
+						</template>
+					</el-button>
+				</vb-tooltip>
+				<vb-tooltip content="修改" placement="top">
+					<el-button
+						link
+						type="primary"
+						@click="handleUpdate(row)"
+						v-hasPermission="'device:inspectionRule:edit'">
+						<template #icon>
+							<VbIcon icon-name="notepad-edit" icon-type="duotone" class="fs-3"></VbIcon>
+						</template>
+					</el-button>
+				</vb-tooltip>
+				<vb-tooltip content="删除" placement="top">
+					<el-button
+						link
+						type="primary"
+						@click="handleDelete([row])"
+						v-hasPermission="'device:inspectionRule:remove'">
+						<template #icon>
+							<VbIcon icon-name="trash-square" icon-type="duotone" class="fs-3"></VbIcon>
+						</template>
+					</el-button>
+				</vb-tooltip>
+			</template>
+		</VbDataTable>
+		<VbModal
+			v-model:modal="modalRef"
+			:title="opts.modalTitle"
+			:form-data="form"
+			:form-items="opts.formItems"
+			:label-width="opts.labelWidth"
+			append-to-body
+			@confirm="submitForm"></VbModal>
+		<VbModal
+			v-model:modal="qrModalRef"
+			title="签到二维码"
+			:confirm-btn="false"
+			:close-btn-class="'btn btn-danger'"
+			append-to-body>
+			<template #body>
+				<div
+					id="qr"
+					class="w-100 w-100 d-flex flex-column justify-content-center align-items-center">
+					<span class="fs-5 fw-bold my-5 qr-title">{{ qrModalData?.taskName }} 签到二维码</span>
+					<vue-qr
+						:text="qrCode.text"
+						:size="200"
+						:logoSrc="qrCode.logo"
+						:logoCornerRadius="`50%`"
+						:color-dark="qrCode.colorDark"
+						:width="qrCode.size"
+						:height="qrCode.size"></vue-qr>
+				</div>
+				<div class="text-center">
+					<el-button type="primary" class="mx-5 w-150px" @click="handleDownloadQr('qr')">
+						保存
+					</el-button>
+					<!-- <el-button type="primary" class="mx-5 w-150px" @click="handlePrintQr('qr')">
+						打印
+					</el-button> -->
+				</div>
+			</template>
+		</VbModal>
+		<VbModal
+			v-model:modal="logModalRef"
+			title="签到记录"
+			:confirm-btn="false"
+			:close-btn-class="'btn btn-danger'"
+			append-to-body>
+			<template #body>
+				<CheckinLog :is-self="false" ref="logsRef" />
+			</template>
+		</VbModal>
+	</div>
+</template>

+ 9 - 1
UI/VB.VUE/src/views/mobile-home.vue

@@ -2,6 +2,7 @@
 import appStore from "@s"
 import { ref, onBeforeUnmount } from "vue"
 import VbQrScan from "@/components/qrcode/VbQrScan.vue"
+import router from "@r"
 
 const name = ref("中科轼峰")
 const showScanner = ref(false)
@@ -18,7 +19,14 @@ function handleScan() {
 
 function handleScanResult(result: string) {
 	scanResult.value = result
-	alert("扫描结果: " + result)
+	if (result.startsWith("vb@")) {
+		const arr = result.split("@")
+		if (arr.length > 2) {
+			router.push(arr[2])
+			return
+		}
+	}
+	message.msgError("无效二维码")
 }
 
 function handleCloseScanner() {

+ 1 - 0
UI/VB.VUE/src/views/workOrder/_order.vue

@@ -42,6 +42,7 @@ const props = withDefaults(
 		}
 	}
 )
+
 const emits = defineEmits<{ (e: "update:modelValue", v: string): void }>()
 const tableRef = ref()
 const modalRef = ref()