Просмотр исходного кода

Merge branch 'feat/production-safety' of http://192.168.6.110/product-group-fe/sfy-safety-group/sfy-safety into feat/production-safety

sunqijun 1 месяц назад
Родитель
Сommit
be3723c77e
27 измененных файлов с 1625 добавлено и 739 удалено
  1. 133 0
      src/api/sensor-group/index.ts
  2. 134 0
      src/api/sensor-group/type.ts
  3. 69 4
      src/hooks/useFormConfigHook.ts
  4. 42 5
      src/layout/components/left-menu/LeftMenu.vue
  5. 32 1
      src/views/production-safety/hiddenTroubleInvestigationAndGovernance/employeeReportHiddenTroubleManagement/components/employeeReportHiddenTroubleManagementDetail.vue
  6. 4 2
      src/views/production-safety/productionSafetySystem/safetyOrganizationSystemManagement/configs/form.ts
  7. 10 1
      src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/components/safetyStandardizationSystemManagementDetail.vue
  8. 1 0
      src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/configs/form.ts
  9. 5 5
      src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/safetyStandardizationSystemManagement.vue
  10. 2 2
      src/views/production-safety/productionSafetySystem/safetySystemConstructionWorkPlanManagement/configs/tables.ts
  11. 14 0
      src/views/production-safety/productionSafetySystem/safetySystemConstructionWorkPlanManagement/safetySystemConstructionWorkPlanManagement.vue
  12. 3 3
      src/views/production-safety/productionSafetySystem/safetyTraining/safetyTraining.vue
  13. 72 64
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue
  14. 110 13
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroupList.vue
  15. 39 26
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue
  16. 139 86
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue
  17. 137 217
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/CamerasGrid.vue
  18. 358 235
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/ScreenToolbar.vue
  19. 164 2
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/VideosGridBase.vue
  20. 130 0
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/useSensorRealtime.ts
  21. 1 39
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/hooks/useCameraStatus.ts
  22. 17 20
      src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/index.vue
  23. 1 8
      src/views/production-safety/risk-identification-and-control/labor-products-purchase-apply-manage/components/detail.vue
  24. 1 1
      src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/Item.vue
  25. 2 2
      src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/components/detail.vue
  26. 3 2
      src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/configs/form.ts
  27. 2 1
      src/views/production-safety/safety-culture/safetyCultureMaterialManagement/configs/form.ts

+ 133 - 0
src/api/sensor-group/index.ts

@@ -0,0 +1,133 @@
+import { http } from '@/utils/http/axios';
+import type {
+  AddSensorGroupReq,
+  BatchSaveSensor2GroupReq,
+  DeleteSensorFromGroupReq,
+  ProductionSensorGroup,
+  QueryDeviceListReq,
+  QueryDeviceListRes,
+  SaveSensor2GroupReq,
+  UpdateSensorGroupReq,
+  UpdateSensorGroupOrderItem,
+  UpdateSensorInGroupReq,
+  UpdateSensorOrderItem,
+} from './type';
+
+/**
+ * 查询传感器分组列表
+ */
+export function querySensorGroupListApi() {
+  return http.request<ProductionSensorGroup[]>({
+    url: '/sensor/querySensorGroupList',
+    method: 'get',
+  });
+}
+
+/**
+ * 新建传感器分组
+ */
+export function addSensorGroupApi(data: AddSensorGroupReq) {
+  return http.request<number>({
+    url: '/sensor/addSensor',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 删除传感器分组
+ */
+export function deleteSensorGroupApi(id: number) {
+  return http.request({
+    url: `/sensor/deleteSensor?id=${id}`,
+    method: 'delete',
+  });
+}
+
+/**
+ * 更新传感器分组
+ */
+export function updateSensorGroupApi(data: UpdateSensorGroupReq) {
+  return http.request({
+    url: '/sensor/updateSensor',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 单个添加传感器到分组
+ */
+export function saveSensor2GroupApi(data: SaveSensor2GroupReq) {
+  return http.request({
+    url: '/sensor/saveSensor2Group',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 批量添加传感器到分组
+ */
+export function batchSaveSensor2GroupApi(data: BatchSaveSensor2GroupReq) {
+  return http.request({
+    url: '/sensor/batchSaveSensor2Group',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 编辑分组内传感器(全量替换)
+ */
+export function updateSensorInGroupApi(data: UpdateSensorInGroupReq) {
+  return http.request({
+    url: '/sensor/updateSensorInGroup',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 删除分组内的传感器
+ */
+export function deleteSensorFromGroupApi(data: DeleteSensorFromGroupReq) {
+  return http.request({
+    url: '/sensor/deleteSensorFromGroup',
+    method: 'delete',
+    params: data,
+  });
+}
+
+/**
+ * 修改传感器分组顺序
+ */
+export function updateSensorGroupOrderApi(data: UpdateSensorGroupOrderItem[]) {
+  return http.request({
+    url: '/sensor/updateSensorGroupOrder',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 修改分组内传感器顺序
+ */
+export function updateSensorOrderApi(data: UpdateSensorOrderItem[]) {
+  return http.request({
+    url: '/sensor/updateSensorOrder',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 弹窗查询可添加设备列表
+ */
+export function queryDeviceListApi(data: QueryDeviceListReq) {
+  return http.request<QueryDeviceListRes | QueryDeviceListRes['records']>({
+    url: '/sensor/queryDeviceList',
+    method: 'post',
+    data,
+  });
+}

+ 134 - 0
src/api/sensor-group/type.ts

@@ -0,0 +1,134 @@
+export interface ProductionSensorGroupDetail {
+  id: number;
+  groupId: number;
+  sensorId: number;
+  orderNum: number;
+  createdAt: string;
+  updatedAt: string;
+  isDeleted: number;
+  deviceNo: string;
+  status: string;
+}
+
+export interface ProductionSensorGroup {
+  id: number;
+  groupName: string;
+  userId: number;
+  isDefault: number;
+  refreshIntervalSec: number;
+  isPaused: number;
+  type: number;
+  createdAt: string;
+  updatedAt: string;
+  isDeleted: number;
+  sourceId: number;
+  orderNum: number;
+  details: ProductionSensorGroupDetail[];
+  onlineRate: string;
+  exceptionCount: number;
+  outlineCount: number;
+  failureCount: number;
+}
+
+export interface SensorDeviceViewItem {
+  id: number;
+  cameraGroupDetailId: number;
+  code: string;
+  name: string;
+  url: string;
+  imageUrl: string;
+  status: string;
+}
+
+export interface SensorGroupView extends Omit<ProductionSensorGroup, 'details'> {
+  details: SensorDeviceViewItem[];
+}
+
+export interface AddSensorGroupReq {
+  groupName: string;
+}
+
+export interface UpdateSensorGroupReq {
+  groupId: number;
+  groupName?: string;
+  isDefault?: 0 | 1;
+  refreshIntervalSec?: number;
+  isPaused?: 0 | 1;
+}
+
+export interface QueryDeviceListReq {
+  pageNumber: number;
+  pageSize: number;
+  queryParam: {
+    deviceName?: string;
+  };
+}
+
+export interface DeviceListItem {
+  id: number;
+  deviceNo: string;
+  deviceName: string;
+  deviceType: string;
+  deviceModel: string;
+  deptId: number;
+  deptName: string;
+  regionId: number;
+  regionName: string;
+  deptHead: number;
+  deptHeadName: string;
+  deviceHead: number;
+  deviceHeadName: string;
+  deviceStatus: string;
+  location: string;
+  imgUrl: string;
+  filename: string;
+  buyDate: string;
+  createdAt: string;
+  updatedAt: string;
+  createdBy: number;
+  updatedBy: number;
+  isAlarm: number;
+  isMonitor: number;
+  isDashboardShow: number;
+  isMaintain: number;
+}
+
+export interface QueryDeviceListRes {
+  records: DeviceListItem[];
+  pageNumber: number;
+  pageSize: number;
+  totalPage: number;
+  totalRow: number;
+}
+
+export interface UpdateSensorInGroupReq {
+  groupId: number;
+  deviceNoList: string[];
+}
+
+export interface BatchSaveSensor2GroupReq {
+  groupId: number;
+  deviceNoList: string[];
+}
+
+export interface SaveSensor2GroupReq {
+  groupId: number;
+  deviceNo: string;
+  orderNum: number;
+}
+
+export interface DeleteSensorFromGroupReq {
+  groupId: number;
+  deviceNo: string;
+}
+
+export interface UpdateSensorGroupOrderItem {
+  id: number;
+  orderNum: number;
+}
+
+export interface UpdateSensorOrderItem {
+  groupId: number;
+  deviceNo: string;
+  orderNum: number;
+}

+ 69 - 4
src/hooks/useFormConfigHook.ts

@@ -12,7 +12,7 @@
  * @description 用于RuleForm的配置
  * @author Chauncey
  */
-import { ref, reactive } from 'vue';
+import { ref, reactive, watch, onUnmounted } from 'vue';
 import type { FormRules } from 'element-plus';
 import { cloneDeep, isEqual } from 'lodash-es';
 import { onBeforeRouteLeave } from 'vue-router';
@@ -26,18 +26,78 @@ export const useFormConfigHook = <T extends Record<string, any> = Record<string,
 ) => {
   const ruleFormConfig = ref<FormConfig[]>(config);
   const ruleFormData = reactive<T>(cloneDeep(data));
-  const initRuleFormData = ref<T>();
+  const initRuleFormData = ref<T>(cloneDeep(data));
   const formRules = reactive<FormRules<T>>(rules || {});
+  const routeLeaveGuardRegistered = ref(false);
+  let stopAutoSyncWatch: (() => void) | null = null;
+  let autoSyncTimer: ReturnType<typeof setTimeout> | null = null;
+  let removeInteractionListeners: (() => void) | null = null;
+
+  const stopAutoSyncBaseline = () => {
+    if (stopAutoSyncWatch) {
+      stopAutoSyncWatch();
+      stopAutoSyncWatch = null;
+    }
+    if (autoSyncTimer) {
+      clearTimeout(autoSyncTimer);
+      autoSyncTimer = null;
+    }
+    if (removeInteractionListeners) {
+      removeInteractionListeners();
+      removeInteractionListeners = null;
+    }
+  };
+
+  const startAutoSyncBaseline = () => {
+    stopAutoSyncBaseline();
+
+    stopAutoSyncWatch = watch(
+      () => ruleFormData,
+      () => {
+        initRuleFormData.value = cloneDeep(ruleFormData as T);
+      },
+      { deep: true, flush: 'post' },
+    );
+
+    const stopByUserInteraction = () => {
+      stopAutoSyncBaseline();
+    };
+
+    if (typeof window !== 'undefined') {
+      const events: Array<keyof WindowEventMap> = ['input', 'change', 'keydown', 'compositionend'];
+      events.forEach((eventName) => {
+        window.addEventListener(eventName, stopByUserInteraction, true);
+      });
+      removeInteractionListeners = () => {
+        events.forEach((eventName) => {
+          window.removeEventListener(eventName, stopByUserInteraction, true);
+        });
+      };
+    }
+
+    autoSyncTimer = setTimeout(() => {
+      stopAutoSyncBaseline();
+    }, 1500);
+  };
+
   const cloneRuleFormData = () => {
+    if (routeLeaveGuardRegistered.value) return;
     initRuleFormData.value = cloneDeep(ruleFormData as T);
   };
+
   const hasFormChanged = () => {
-    return isEqual(initRuleFormData.value, ruleFormData);
+    return !isEqual(initRuleFormData.value, ruleFormData);
   };
+
   const beforeRouteLeave = () => {
+    if (routeLeaveGuardRegistered.value) return;
+    initRuleFormData.value = cloneDeep(ruleFormData as T);
+    routeLeaveGuardRegistered.value = true;
+    startAutoSyncBaseline();
+
     onBeforeRouteLeave((to, from, next) => {
       const hasChange = hasFormChanged();
-      if (hasChange) {
+      if (!hasChange) {
         next();
         return;
       }
@@ -56,6 +116,11 @@ export const useFormConfigHook = <T extends Record<string, any> = Record<string,
       }, 200);
     });
   };
+
+  onUnmounted(() => {
+    stopAutoSyncBaseline();
+  });
+
   return {
     ruleFormConfig,
     ruleFormData,

+ 42 - 5
src/layout/components/left-menu/LeftMenu.vue

@@ -44,8 +44,7 @@
   import BottomMenuItem from '../../components/menu-tree/BottomMenuItem.vue';
   // 当前路由
   const currentRoute = useRoute();
-  const activeMenu = currentRoute.meta?.activeMenu || null; // activeMenu undefined,null 或 空字符串,统一变为 null。
-  const selectedKeys = ref<string>((activeMenu ?? currentRoute.path) as string);
+  const selectedKeys = ref<string>('');
 
   const openKeys = ref<string[]>([]);
 
@@ -65,6 +64,37 @@
     return filterHiddenItems(currentRoute.matched[0].children);
   });
 
+  function collectMenuPaths(list: any[]): string[] {
+    const paths: string[] = [];
+    list.forEach((item) => {
+      if (item?.path) {
+        paths.push(item.path);
+      }
+      if (item?.children?.length) {
+        paths.push(...collectMenuPaths(item.children));
+      }
+    });
+    return paths;
+  }
+
+  function resolveSelectedKey(path: string, activeMenu?: string) {
+    const menuPaths = collectMenuPaths(subMenus.value || []);
+    if (activeMenu && menuPaths.includes(activeMenu)) {
+      return activeMenu;
+    }
+    if (menuPaths.includes(path)) {
+      return path;
+    }
+
+    // 回退到最深的前缀菜单路径
+    const prefixMatched = menuPaths.filter((menuPath) => path.startsWith(menuPath)).sort((a, b) => b.length - a.length);
+    if (prefixMatched.length > 0) {
+      return prefixMatched[0];
+    }
+
+    return activeMenu || path;
+  }
+
   // 跟随页面路由变化,切换菜单选中状态
   watch(
     () => currentRoute.fullPath,
@@ -72,7 +102,7 @@
       const matched = currentRoute.matched;
       openKeys.value = matched.map((item) => item.name) as string[];
       const activeMenu: string = (currentRoute.meta?.activeMenu as string) || '';
-      selectedKeys.value = activeMenu ? activeMenu : (currentRoute.path as string);
+      selectedKeys.value = resolveSelectedKey(currentRoute.path as string, activeMenu);
     },
     {
       immediate: true,
@@ -108,12 +138,15 @@
     gap: 10px;
     padding: 10px;
     height: 100%;
+    min-height: 0;
+    overflow: hidden;
   }
   .aside {
     display: flex;
     flex-direction: column;
-    width: 228px;
+    width: 260px;
     height: 100%;
+    min-height: 0;
     flex-shrink: 0;
     border-radius: 4px;
     background-color: $white-color;
@@ -125,16 +158,20 @@
     &__main {
       width: inherit;
       flex: 1;
+      min-height: 0;
+      overflow-y: auto;
+      overflow-x: auto;
     }
   }
   .main {
     flex: 1;
+    min-height: 0;
     overflow: auto;
     border-radius: 4px;
   }
   .el-menu-vertical {
     width: 100%;
-    height: 100%;
+    min-height: 100%;
     border: none;
     border-radius: 4px;
     .el-menu-item,

+ 32 - 1
src/views/production-safety/hiddenTroubleInvestigationAndGovernance/employeeReportHiddenTroubleManagement/components/employeeReportHiddenTroubleManagementDetail.vue

@@ -2,14 +2,38 @@
   <main class="safety-platform-container__main">
     <BasicForm ref="basicFormRef" :formData="ruleFormData" :formRules="formRules" :formConfig="formConfig">
       <template #attachment>
-        <UploadFiles
+        <!-- <UploadFiles
           label="上传附件"
           :file-list="attachmentFileList"
           :readonly="!isCreateOrEditMode"
           :disabled="!isCreateOrEditMode"
           @uploadSuccess="handleAttachmentUploadSuccess"
           @preview="handlePreview"
+        /> -->
+        <UploadFiles
+          v-if="!isViewMode"
+          label="上传文件"
+          :maxCount="1"
+          :file-list="attachmentFileList" 
+          :disabled="isViewMode"
+          :allow-all-file-types="true"
+          :accept="'.jpg,.jpeg,.png'"
+          :desc="'支持.jpg,.jpeg,.png'"
+          @uploadSuccess="(list: FileItem[]) => handleAttachmentUploadSuccess(list)"
         />
+        <div class="file-list" v-else>
+          <div class="file-item" v-for="file in attachmentFileList" :key="file.fileId">
+            <span class="file-item--name">{{ file.fileName }}</span>
+            <div class="file-item--footer">
+              <el-button link type="primary" @click="previewOnline(file.fileUrl, file.fileType)"
+                >预览</el-button
+              >
+              <el-button link type="primary" @click.stop="downloadFile(file.fileUrl, file.fileName)"
+                >下载</el-button
+              >
+            </div>
+          </div>
+        </div>
       </template>
     </BasicForm>
     <!-- 审核弹窗 -->
@@ -69,6 +93,7 @@
     type ApproveEmployeeHazardReportReq,
   } from '@/api/production-safety';
   import PreviewOnline from '@/views/disaster/components/PreviewOnline.vue';
+  import { downloadFile } from '@/views/disaster/utils';
 
   const router = useRouter();
   const route = useRoute();
@@ -279,6 +304,12 @@
     }
   };
 
+  const previewOnline = (url: string | undefined, type) => {
+    if (url) {
+      previewOnlineRef.value?.open(url, type);
+    }
+  };
+
   onMounted(() => {
     cloneRuleFormData();
     if (currentId.value) {

+ 4 - 2
src/views/production-safety/productionSafetySystem/safetyOrganizationSystemManagement/configs/form.ts

@@ -54,6 +54,8 @@ export const INVENTORY_FORM_DATA = {
   warehouseDate: '',
   itemQuantity: 1, // 最小值为1
   remarks: '',
+  orgId: '',
+  jobResp: '',
 };
 // 表单验证规则
 export const FORM_RULES = {
@@ -65,8 +67,8 @@ export const FORM_RULES = {
     { required: true, message: '请输入员工姓名', trigger: 'blur' },
     { min: 1, max: 10, message: '长度在 1 到 10 个字符', trigger: 'blur' },
   ],
-  organizationId: [{ required: true, message: '请选择组织名称', trigger: 'change' }],
-  jobDuty: [
+  orgId: [{ required: true, message: '请选择组织名称', trigger: 'change' }],
+  jobResp: [
     { required: true, message: '请填写岗位职责', trigger: 'blur' },
     { min: 1, max: 300, message: '最大字数300字', trigger: 'blur' },
   ],

+ 10 - 1
src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/components/safetyStandardizationSystemManagementDetail.vue

@@ -237,6 +237,11 @@
       return;
     }
 
+    if(!ruleFormData.content || ruleFormData.content === '<p><br></p>') {
+      ElMessage.error('请输入文档内容');
+      return;
+    }
+    
     try {
       // 处理文件上传:先上传文件获取 URL,然后提取 fileUrl
       // let fileUrl = '';
@@ -305,11 +310,15 @@
   };
 
   onMounted(() => {
-    cloneRuleFormData();
     beforeRouteLeave();
     if (isEditMode.value || isViewMode.value) {
       getDetail();
     }
+    if(isCreateMode.value){
+      setTimeout(() => {
+        cloneRuleFormData();
+      }, 100);
+    }
   });
 
   onBeforeUnmount(() => {

+ 1 - 0
src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/configs/form.ts

@@ -90,4 +90,5 @@ export const SAFETY_STANDARDIZATION_FORM_RULES = {
   fileFormat: [{ required: true, message: '请选择文件格式', trigger: 'change' }],
   releaseDate: [{ required: true, message: '请选择发布日期', trigger: 'change' }],
   fileUrl: [{ required: true, message: '请上传文件', trigger: 'change' }],
+  content: [{ required: true, message: '请输入文档内容', trigger: 'blur' }],
 };

+ 5 - 5
src/views/production-safety/productionSafetySystem/safetyStandardizationSystemManagement/safetyStandardizationSystemManagement.vue

@@ -4,6 +4,10 @@
       <div class="breadcrumb-title"> 安全标准化体系建设管理 </div>
     </header>
     <main class="safety-platform-container__main">
+      <div style="position: relative">
+        <el-button type="primary" class="search-table-container--button" @click="handleCreate"> 添加 </el-button>
+        <el-button plain class="search-table-container--button" @click="handleImport"> 导入 </el-button>
+      </div>
       <div class="search-table-container">
         <header>
           <div class="act-search">
@@ -43,16 +47,12 @@
             <section class="search-btn">
               <el-button type="primary" @click="handleSearch">查询</el-button>
               <el-button @click="handleReset">重置</el-button>
+              <el-button plain @click="handleDownload"> 导出 </el-button>
             </section>
           </div>
         </header>
 
         <div class="batch-table">
-          <div style="position: relative">
-            <el-button type="primary" class="search-table-container--button" @click="handleCreate"> 添加 </el-button>
-            <el-button plain class="search-table-container--button" @click="handleImport"> 导入 </el-button>
-            <el-button plain class="search-table-container--button" @click="handleDownload"> 导出 </el-button>
-          </div>
           <BasicTable
             ref="basicTableRef"
             :tableData="tableData"

+ 2 - 2
src/views/production-safety/productionSafetySystem/safetySystemConstructionWorkPlanManagement/configs/tables.ts

@@ -168,13 +168,13 @@ export const VIEW_SENDS_TABLE_COLUMNS: TableColumnProps[] = [
   },
   {
     label: '下发责任人',
-    prop: 'issuedByName',
+    prop: 'employeeName',
     align: 'left',
     minWidth: '120px',
   },
   {
     label: '计划完成时间',
-    prop: 'plannedComplateTime',
+    prop: 'plannedEndTime',
     align: 'left',
     minWidth: '160px',
   },

+ 14 - 0
src/views/production-safety/productionSafetySystem/safetySystemConstructionWorkPlanManagement/safetySystemConstructionWorkPlanManagement.vue

@@ -330,6 +330,20 @@
     });
   };
   const handleConfirmIssue = async () => {
+
+    if (!issueWorkPlanForm.executGroupIds || issueWorkPlanForm.executGroupIds.length === 0) {
+      ElMessage.error('请选择执行部门');
+      return;
+    }
+    if (!issueWorkPlanForm.plannedStartTime || !issueWorkPlanForm.plannedEndTime) {
+      ElMessage.error('请选择计划时间');
+      return;
+    }
+    if (issueWorkPlanForm.plannedStartTime >= issueWorkPlanForm.plannedEndTime) {
+      ElMessage.error('计划开始时间不能晚于计划结束时间');
+      return;
+    }
+
     try {
       await issueWorkPlan({
         ...issueWorkPlanForm,

+ 3 - 3
src/views/production-safety/productionSafetySystem/safetyTraining/safetyTraining.vue

@@ -13,9 +13,6 @@
             <el-button plain class="search-table-container--button" @click="handleImport">
               导入
             </el-button>
-            <el-button plain class="search-table-container--button" @click="handleDownload">
-              导出
-            </el-button>
           </div>
 
           <div class="act-search">
@@ -66,6 +63,9 @@
             <section class="search-btn">
               <el-button type="primary" @click="handleSearch">查询</el-button>
               <el-button @click="handleReset">重置</el-button>
+              <el-button plain @click="handleDownload">
+                导出
+              </el-button>
             </section>
           </div>
         </header>

+ 72 - 64
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroup.vue

@@ -51,7 +51,7 @@
         <div class="addCameraIcon">
           <img src="@/assets/icons/nine-square-grid/add.png" />
         </div>
-        <div>添加点位</div>
+        <div>添加传感器</div>
       </div>
 
       <CameraListOfGroup :cameraGroup="cameraGroup" />
@@ -59,8 +59,8 @@
   </div>
 
   <div>
-    <el-dialog v-model="showCameraTreeDialog" width="500" :title="`添加相机至“${cameraGroup.groupName}”区域`">
-      <CameraTreeOfGroupList :cameraGroup="cameraGroup" />
+    <el-dialog v-model="showCameraTreeDialog" width="500" :title="`添加传感器至“${cameraGroup.groupName}”区域`">
+      <CameraTreeOfGroupList :cameraGroup="cameraGroup" @close="showCameraTreeDialog = false" />
     </el-dialog>
   </div>
 </template>
@@ -72,45 +72,88 @@
   import { ElCollapseItem, ElDialog, ElMessageBox, ElInput, ElMessage } from 'element-plus';
   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
   import { userGridType } from '@/store/modules/userGridType';
-  import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
-  import type { CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
-  import { modifyCameraGroupApi } from '@/api/nine-square-grid';
+  import { GridType } from '../../type';
+  import { deleteSensorGroupApi, updateSensorGroupApi } from '@/api/sensor-group';
+  import type { SensorGroupView } from '@/api/sensor-group/type';
   import CameraListOfGroup from './CameraListOfGroup.vue';
 
   const showGroupOperation = ref(false);
   const showCameraTreeDialog = ref(false);
   const props = defineProps<{
-    cameraGroup: CameraGroupType;
+    cameraGroup: SensorGroupView;
     activeGroup: string[];
   }>();
 
   const showRenameGroupInput = ref(false);
-  let groupOriginName = props.cameraGroup.groupName;
+  const groupOriginName = ref('');
   const inputNewGroupName = ref('');
   const groupNameInputRef = ref();
 
   const { cameraGroupList, playingGroup, isPlaying, isPaused, playIntervalTime } = storeToRefs(useCameraGroupList());
-  const { deleteCameraGroup, groupStartPlay, setPlayGroup, stopPlay } = useCameraGroupList();
+  const { stopPlay, setPlayGroup, groupStartPlay } = useCameraGroupList();
   const { changeGridType } = userGridType();
 
   // 控制播放/删除菜单的隐藏
   const closeGroupOperation = (event) => {
     if (event.target.className !== 'groupOperationItem') showGroupOperation.value = false;
-    if (event.target.innerText === '开始播放') {
-      handelStartPlay();
-      showGroupOperation.value = false;
-    }
   };
 
+  async function handelStartPlay() {
+    showGroupOperation.value = false;
+    const isCurrentPlayingGroup = props.cameraGroup.id === playingGroup.value?.id;
+
+    if (isPlaying.value && isCurrentPlayingGroup) {
+      try {
+        await ElMessageBox.confirm('是否取消当前区域播放?', '提示', {
+          cancelButtonText: '取消',
+          confirmButtonText: '确定',
+          customClass: 'customMessageBox--warning',
+        });
+      } catch {
+        return;
+      }
+      await updateSensorGroupApi({ groupId: props.cameraGroup.id, isDefault: 0 });
+      stopPlay(props.cameraGroup.id);
+      return;
+    }
+
+    if (isPlaying.value && !isCurrentPlayingGroup && playingGroup.value) {
+      try {
+        await ElMessageBox.confirm('是否切换播放区域?', '提示', {
+          cancelButtonText: '取消',
+          confirmButtonText: '确定',
+          customClass: 'customMessageBox--warning',
+        });
+      } catch {
+        return;
+      }
+      await updateSensorGroupApi({ groupId: playingGroup.value.id, isDefault: 0 });
+      stopPlay(playingGroup.value.id);
+    }
+
+    await updateSensorGroupApi({
+      groupId: props.cameraGroup.id,
+      isDefault: 1,
+    });
+
+    isPlaying.value = true;
+    setPlayGroup({ ...(props.cameraGroup as any), children: props.cameraGroup.details } as any);
+    playIntervalTime.value = props.cameraGroup.refreshIntervalSec || 60;
+    isPaused.value = !props.cameraGroup.isPaused;
+    changeGridType(GridType.nineGrids);
+    groupStartPlay(playIntervalTime.value);
+  }
+
   function handleRenameGroup() {
     showRenameGroupInput.value = true;
+    groupOriginName.value = props.cameraGroup.groupName;
     nextTick(() => {
       groupNameInputRef.value.focus();
-      inputNewGroupName.value = groupOriginName;
+      inputNewGroupName.value = groupOriginName.value;
     });
   }
 
-  function enterNewName() {
+  async function enterNewName() {
     if (inputNewGroupName.value === '') {
       showRenameGroupInput.value = false;
       return;
@@ -126,67 +169,35 @@
       showRenameGroupInput.value = false;
     }
 
-    props.cameraGroup.groupName = inputNewGroupName.value;
-    groupOriginName = inputNewGroupName.value;
-    showRenameGroupInput.value = false;
-    modifyCameraGroupApi({
-      groupId: props.cameraGroup.id,
-      groupName: inputNewGroupName.value,
-    });
-  }
-
-  function handelStartPlay() {
-    // 如果点击的是其他区域的轮播按钮
-    const changeGroup = props.cameraGroup.id !== playingGroup.value?.id;
-    if (isPlaying.value) {
-      ElMessageBox.confirm(changeGroup ? '是否切换播放区域' : '是否取消当前区域相机播放', '提示', {
-        cancelButtonText: '取消',
-        confirmButtonText: '确定',
-        customClass: 'customMessageBox--warning',
-      })
-        .then(() => {
-          if (changeGroup) {
-            isPlaying.value = false;
-            stopPlay(playingGroup.value?.id!);
-            playClickedGroup();
-          } else {
-            stopPlay(props.cameraGroup.id, true);
-          }
-        })
-        .catch(() => {
-          return;
-        });
-    } else {
-      playClickedGroup();
+    const targetGroup = cameraGroupList.value.find((x) => x.id === props.cameraGroup.id) as any;
+    if (targetGroup) {
+      targetGroup.groupName = inputNewGroupName.value;
     }
-  }
-
-  function playClickedGroup() {
-    isPlaying.value = true;
-    setPlayGroup(props.cameraGroup);
-    playIntervalTime.value = props.cameraGroup.playIntervalSec ? props.cameraGroup.playIntervalSec : 60;
-    isPaused.value = Boolean(props.cameraGroup.isPaused);
-    changeGridType(GridType.nineGrids);
-    groupStartPlay(playIntervalTime.value);
-    modifyCameraGroupApi({
+    await updateSensorGroupApi({
       groupId: props.cameraGroup.id,
-      isDefault: 1,
+      groupName: inputNewGroupName.value,
     });
+    groupOriginName.value = inputNewGroupName.value;
+    showRenameGroupInput.value = false;
   }
 
   function addCamera() {
     showCameraTreeDialog.value = true;
   }
 
-  function handleDelete(cameraGroup: CameraGroupType) {
+  async function handleDelete(cameraGroup: SensorGroupView) {
     const text = '删除后,区域数据不可恢复,需要重新添加,是否确认删除该区域?';
     ElMessageBox.confirm(text, '提示', {
       cancelButtonText: '取消',
       confirmButtonText: '确定',
       customClass: 'customMessageBox--warning',
     })
-      .then(() => {
-        deleteCameraGroup(cameraGroup.id);
+      .then(async () => {
+        await deleteSensorGroupApi(cameraGroup.id);
+        if (playingGroup.value?.id === cameraGroup.id) {
+          stopPlay(cameraGroup.id);
+        }
+        cameraGroupList.value = cameraGroupList.value.filter((x) => x.id !== cameraGroup.id);
       })
       .catch(() => {
         return;
@@ -382,7 +393,4 @@
     text-align: left;
     font-weight: bold;
   }
-
-  :deep(.el-dialog__body) {
-  }
 </style>

+ 110 - 13
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraGroupList.vue

@@ -26,8 +26,10 @@
     <div class="groupList">
       <el-scrollbar>
         <el-collapse v-model="activeGroup">
-          <div v-for="cameraGroup in cameraGroupList" :key="cameraGroup.id">
-            <CameraGroup :cameraGroup="cameraGroup" :activeGroup="activeGroup" />
+          <div ref="groupDragRef">
+            <div v-for="cameraGroup in groupListData" :key="cameraGroup.id">
+              <CameraGroup :cameraGroup="cameraGroup" :activeGroup="activeGroup" />
+            </div>
           </div>
         </el-collapse>
       </el-scrollbar>
@@ -36,19 +38,94 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, onMounted, nextTick } from 'vue';
-  import { ElInput, ElScrollbar, ElCollapse } from 'element-plus';
+  import { ref, nextTick, onMounted, computed, watch } from 'vue';
+  import { ElInput, ElScrollbar, ElCollapse, ElMessage } from 'element-plus';
   import CameraGroup from './CameraGroup.vue';
   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import {
+    addSensorGroupApi,
+    querySensorGroupListApi,
+    updateSensorGroupApi,
+    updateSensorGroupOrderApi,
+  } from '@/api/sensor-group';
+  import type { ProductionSensorGroup, SensorGroupView, SensorDeviceViewItem } from '@/api/sensor-group/type';
   import { storeToRefs } from 'pinia';
+  import { userGridType } from '@/store/modules/userGridType';
+  import { GridType } from '../../type';
+  import { useDraggable } from 'vue-draggable-plus';
 
   const activeGroup = ref(['']);
   const showCreateGroupInput = ref(false);
   const inputNewGroupName = ref('');
   const groupNameInputRef = ref();
-  const { cameraGroupList } = storeToRefs(useCameraGroupList());
-  const { getCameraGroupList, saveOrUpdateCameraGroupAll, queryCameraGroupListByTypeAndSourceId } =
-    useCameraGroupList();
+  const groupDragRef = ref<HTMLElement | null>(null);
+  const groupListData = ref<SensorGroupView[]>([]);
+  const { cameraGroupList, isPlaying, isPaused, playIntervalTime } = storeToRefs(useCameraGroupList());
+  const { setPlayGroup, groupStartPlay } = useCameraGroupList();
+  const { changeGridType } = userGridType();
+  const sensorGroupList = computed(() => cameraGroupList.value as unknown as SensorGroupView[]);
+
+  watch(
+    sensorGroupList,
+    (val) => {
+      groupListData.value = val;
+    },
+    { immediate: true, deep: true },
+  );
+
+  useDraggable(groupDragRef, groupListData, {
+    animation: 150,
+    ghostClass: 'ghost',
+    onUpdate() {
+      cameraGroupList.value = groupListData.value as any;
+      updateSensorGroupOrderApi(groupListData.value.map((g, idx) => ({ id: g.id, orderNum: idx + 1 })));
+    },
+  });
+
+  function normalizeSensorGroups(list: ProductionSensorGroup[]): SensorGroupView[] {
+    return (list || []).map((group) => {
+      const details: SensorDeviceViewItem[] = (group.details || []).map((detail) => ({
+        id: detail.sensorId || detail.id,
+        cameraGroupDetailId: detail.id,
+        code: detail.deviceNo || String(detail.sensorId || detail.id),
+        name: detail.deviceNo || `传感器${String(detail.sensorId || detail.id).slice(-3)}`,
+        url: '',
+        imageUrl: '',
+        status: detail.status,
+      }));
+
+      return {
+        ...group,
+        isPaused: group.isPaused ?? 1,
+        details,
+        // store 的 carouselList 通过 .children 读取播放列表,必须同步
+        children: details,
+      } as any;
+    });
+  }
+
+  async function loadSensorGroupList() {
+    const res = await querySensorGroupListApi();
+    cameraGroupList.value = normalizeSensorGroups(res) as any;
+
+    // 查找 isDefault === 1 的分组并自动播放
+    const defaultGroup = cameraGroupList.value.find((x) => x.isDefault === 1) as unknown as SensorGroupView;
+    if (defaultGroup) {
+      activeGroup.value = [defaultGroup.groupName];
+      // 自动启动播放
+      await updateSensorGroupApi({
+        groupId: defaultGroup.id,
+        isDefault: 1,
+      });
+
+      isPlaying.value = true;
+      setPlayGroup({ ...(defaultGroup as any), children: defaultGroup.details } as any);
+      playIntervalTime.value = defaultGroup.refreshIntervalSec || 60;
+      isPaused.value = !defaultGroup.isPaused;
+      changeGridType(GridType.nineGrids);
+      groupStartPlay(playIntervalTime.value);
+    }
+  }
 
   function handleCreateGroup() {
     showCreateGroupInput.value = !showCreateGroupInput.value;
@@ -61,12 +138,32 @@
     }
   }
 
-  function handleEnterGroupName() {
+  async function handleEnterGroupName() {
     checkGroupName();
-    saveOrUpdateCameraGroupAll(inputNewGroupName.value, 6);
-    activeGroup.value = [inputNewGroupName.value]; // 添加区域后展开区域
-    inputNewGroupName.value = '';
-    showCreateGroupInput.value = false;
+    const groupName = inputNewGroupName.value.trim();
+    if (!groupName) {
+      showCreateGroupInput.value = false;
+      return;
+    }
+    if (cameraGroupList.value.find((x) => x.groupName === groupName)) return;
+
+    try {
+      const res = await addSensorGroupApi({ groupName });
+      const newGroup = {
+        id: (res as any).data,
+        groupName,
+        isDefault: 0,
+        isPaused: 1,
+        refreshIntervalSec: 60,
+        details: [],
+        children: [],
+      } as unknown as SensorGroupView;
+      cameraGroupList.value = [newGroup, ...(cameraGroupList.value as any)] as any;
+      inputNewGroupName.value = '';
+      showCreateGroupInput.value = false;
+    } catch (error) {
+      ElMessage({ message: '新建分组失败', type: 'error' });
+    }
   }
 
   function checkGroupName() {
@@ -95,7 +192,7 @@
   }
 
   onMounted(() => {
-    queryCameraGroupListByTypeAndSourceId(6);
+    loadSensorGroupList();
   });
 </script>
 

+ 39 - 26
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraListOfGroup.vue

@@ -1,6 +1,6 @@
 <template>
   <div ref="dragRef">
-    <div class="camera" v-for="camera in cameraGroup.children" :key="camera.cameraGroupDetailId">
+    <div class="camera" v-for="camera in groupSensors" :key="camera.cameraGroupDetailId || camera.id">
       <div class="IconAndCameraName">
         <div class="cameraIcon">
           <WarningFilled
@@ -43,27 +43,29 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, watch } from 'vue';
+  import { computed, ref, watch } from 'vue';
   import { storeToRefs } from 'pinia';
-  import type { Camera, CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
-  import { updateCameraOrderApi } from '@/api/nine-square-grid';
+  import type { SensorDeviceViewItem, SensorGroupView } from '@/api/sensor-group/type';
   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
   import { WarningFilled } from '@element-plus/icons-vue';
   import { ElMessageBox } from 'element-plus';
   import { useDraggable } from 'vue-draggable-plus';
   import Thumbnail from '@/components/thumbnail/Thumbnail.vue';
+  import { deleteSensorFromGroupApi, updateSensorOrderApi } from '@/api/sensor-group';
 
   const props = defineProps<{
-    cameraGroup: CameraGroupType;
+    cameraGroup: SensorGroupView;
   }>();
   const dragRef = ref<HTMLElement | null>(null);
-  const list = ref<Camera[]>([]);
+  const list = ref<SensorDeviceViewItem[]>([]);
+  const groupSensors = computed(() => {
+    return props.cameraGroup.details;
+  });
 
   const { carouselList, playingGroup, cameraInPlay } = storeToRefs(useCameraGroupList());
+  const { restartPlay } = useCameraGroupList();
 
-  const { deleteCameraFromGroup, deleteCameraInPlaylist, restartPlay } = useCameraGroupList();
-
-  function handleDelete(cameraGroup: CameraGroupType, camera: Camera) {
+  function handleDelete(cameraGroup: SensorGroupView, camera: SensorDeviceViewItem) {
     const text = '删除后,相机数据不可恢复,是否确认删除?';
     ElMessageBox.confirm(text, '提示', {
       cancelButtonText: '取消',
@@ -71,13 +73,24 @@
       customClass: 'customMessageBox--warning',
       lockScroll: false,
     })
-      .then(() => {
-        if (cameraInPlay.value.includes(camera)) {
-          deleteCameraInPlaylist(cameraGroup, camera);
-        } else {
-          deleteCameraFromGroup(cameraGroup, camera);
-          restartPlay();
-        }
+      .then(async () => {
+        await deleteSensorFromGroupApi({ groupId: cameraGroup.id, deviceNo: camera.code });
+        const newSensors = groupSensors.value.filter((x) => x.id !== camera.id);
+        cameraGroup.details = newSensors;
+        cameraInPlay.value = cameraInPlay.value.map((x) =>
+          x.id === camera.id
+            ? {
+                ...x,
+                id: -Date.now(),
+                cameraGroupDetailId: -Date.now(),
+                name: '',
+                code: '',
+                url: '',
+                imageUrl: '',
+              }
+            : x,
+        );
+        restartPlay();
       })
       .catch(() => {
         return;
@@ -85,7 +98,7 @@
   }
 
   watch(
-    () => props.cameraGroup.children,
+    groupSensors,
     (children) => {
       list.value = children;
     },
@@ -96,16 +109,16 @@
     animation: 150,
     ghostClass: 'ghost',
     onUpdate() {
-      const updateIds = list.value.map((x, index) => {
-        return {
-          groupId: props.cameraGroup.id,
-          cameraId: x.id,
-          orderNum: index + 1,
-        };
-      });
-      props.cameraGroup.children = list.value;
-      updateCameraOrderApi(updateIds);
+      // eslint-disable-next-line vue/no-mutating-props
+      props.cameraGroup.details = list.value;
       restartPlay();
+      updateSensorOrderApi(
+        list.value.map((s, idx) => ({
+          groupId: props.cameraGroup.id,
+          deviceNo: s.code,
+          orderNum: idx + 1,
+        })),
+      );
     },
   });
 </script>

+ 139 - 86
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/CameraGroupListAndTree/CameraGroupList/CameraTreeOfGroupList.vue

@@ -3,7 +3,7 @@
     <div class="cameraTreeInputWrapper">
       <el-input
         v-model="filterText"
-        placeholder="请输入相机的名称进行搜索"
+        placeholder="请输入传感器名称进行搜索"
         :suffix-icon="Search"
         class="filterTextInput"
       />
@@ -12,10 +12,9 @@
       <el-tree
         :data="cameraTree"
         :props="defaultProps"
-        @node-click="handleClickCamera"
+        show-checkbox
         node-key="code"
-        :default-expand-all="false"
-        :default-expanded-keys="cameraTree"
+        :default-expand-all="true"
         :filter-node-method="filterNode"
         ref="treeRef"
         v-loading="treeLoading"
@@ -23,46 +22,41 @@
         element-loading-background="rgba(0, 0, 0, 0)"
       >
         <template #default="{ node, data }">
-          <div
-            class="treeNode"
-            :class="{
-              selectedCamera: isSelected(node, data.id),
-            }"
-          >
-            <div v-if="data.nodeType === CameraTreeNodeType.camera" class="icons">
+          <div class="treeNode">
+            <div class="icons">
               <VideoCamera class="cameraIcon" />
               <WarningFilled v-if="isInvalid(data)" class="invalidCamera" style="color: red" />
             </div>
-
-            <div class="cameraName">
-              {{ node.label }}
-            </div>
-
-            <Thumbnail
-              v-if="data.nodeType === CameraTreeNodeType.camera"
-              :imageUrl="data.imageUrl"
-              :code="data.code"
-              position="right"
-            >
+            <div class="cameraName">{{ node.label }}</div>
+            <Thumbnail :imageUrl="data.imageUrl" :code="data.code" position="right">
               <div class="mask"></div>
             </Thumbnail>
           </div>
         </template>
       </el-tree>
     </el-scrollbar>
+    <div class="dialogFooter">
+      <el-button @click="emit('close')">取消</el-button>
+      <el-button type="primary" :loading="saving" @click="handleConfirm">确认</el-button>
+    </div>
   </div>
 </template>
 <script setup lang="ts">
-  import { ElInput, ElTree, ElScrollbar } from 'element-plus';
-  import { onMounted, ref, watch } from 'vue';
+  import { ElInput, ElTree, ElScrollbar, ElButton, ElMessage } from 'element-plus';
+  import { computed, nextTick, ref, watch } from 'vue';
   import { storeToRefs } from 'pinia';
   import { VideoCamera, WarningFilled, Search } from '@element-plus/icons-vue';
-  import { CameraTreeNodeType, getCameraTree } from '@/api/camera/camera-preview';
   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
-  import type { CameraGroupType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
+  import { queryDeviceListApi, updateSensorInGroupApi } from '@/api/sensor-group';
+  import type { DeviceListItem, SensorGroupView, SensorDeviceViewItem } from '@/api/sensor-group/type';
   import Thumbnail from '@/components/thumbnail/Thumbnail.vue';
 
-  interface Tree {
+  enum CameraTreeNodeType {
+    group = 'group',
+    camera = 'camera',
+  }
+
+  interface TreeNode {
     [key: string]: any;
   }
 
@@ -71,55 +65,104 @@
     label: 'name',
   };
 
-  const cameraTree = ref();
+  const cameraTree = ref<any[]>([]);
   const filterText = ref('');
   const treeRef = ref<InstanceType<typeof ElTree>>();
   const childrenNodeList = ref<string[]>([]);
+  const deviceList = ref<DeviceListItem[]>([]);
   const treeLoading = ref(false);
   const treeEmptyText = ref('');
-  const props = defineProps<{ cameraGroup: CameraGroupType }>();
+  const saving = ref(false);
+  const props = defineProps<{ cameraGroup: SensorGroupView }>();
+  const emit = defineEmits<{ (e: 'close'): void }>();
 
-  const { cameraInPlay, playingGroup, totalRound } = storeToRefs(useCameraGroupList());
-  const { addCameraIntoGroup, deleteCameraFromGroup, deleteCameraInPlaylist, getValidateCameraNum, restartPlay } =
-    useCameraGroupList();
+  const groupSensors = computed(() => props.cameraGroup.details);
+  // 已在分组中的设备 code(deviceNo),用于初始勾选
+  const initialCheckedKeys = computed(() => groupSensors.value.map((s) => s.code));
 
-  const isSelected = (nodeData, id: number) => {
-    return props.cameraGroup.children.find((x) => x.id === id && nodeData.data.nodeType === CameraTreeNodeType.camera);
-  };
+  const { cameraInPlay, cameraGroupList } = storeToRefs(useCameraGroupList());
+  const { restartPlay } = useCameraGroupList();
 
-  const handleClickCamera = (nodeData) => {
-    // 如果点击的不是摄像头
-    if (nodeData.nodeType !== CameraTreeNodeType.camera) return;
-
-    const cameraInGroup = props.cameraGroup.children.find((x) => x.id === nodeData.id);
-
-    // 如果该摄像头在分组里,则从分组移除该摄像头,否则添加该摄像头进分组
-    if (cameraInGroup) {
-      cameraInPlay.value.includes(cameraInGroup)
-        ? deleteCameraInPlaylist(props.cameraGroup, cameraInGroup)
-        : deleteCameraFromGroup(props.cameraGroup, cameraInGroup);
-    } else {
-      const camera = {
-        code: nodeData.code,
-        name: nodeData.name,
-        url: nodeData.pushStreamDTO.videoUrls?.pushstreamIp || '',
-        id: nodeData.id,
-        cameraGroupDetailId: -1,
-        imageUrl: '',
-      };
-
-      addCameraIntoGroup(props.cameraGroup.id, camera);
-
-      // 如果往正在轮播的分组添加相机,且该分组的有效相机数未能填满宫格数,则刷新当前播放的画面
-      if (props.cameraGroup.id === playingGroup.value?.id && getValidateCameraNum() >= 0 && totalRound.value <= 1) {
-        restartPlay();
-      }
+  function syncGroupSensors(newSensors: SensorDeviceViewItem[]) {
+    const targetGroup = cameraGroupList.value.find((x: any) => x.id === props.cameraGroup.id) as
+      | SensorGroupView
+      | undefined;
+    if (!targetGroup) return;
+    targetGroup.details = newSensors;
+  }
+
+  async function loadDeviceList(deviceName = '') {
+    treeLoading.value = true;
+    try {
+      const res = await queryDeviceListApi({
+        pageNumber: 1,
+        pageSize: 99999,
+        queryParam: { deviceName },
+      });
+      deviceList.value = Array.isArray(res) ? res : res.records || [];
+      treeEmptyText.value = deviceList.value.length === 0 ? '暂无数据' : '';
+    } finally {
+      treeLoading.value = false;
     }
-  };
+  }
+
+  async function handleConfirm() {
+    saving.value = true;
+    try {
+      const checkedNodes = (treeRef.value?.getCheckedNodes() as any[]) || [];
+      const deviceNoList = checkedNodes.map((n) => n.code as string);
+
+      await updateSensorInGroupApi({ groupId: props.cameraGroup.id, deviceNoList });
+
+      // 更新本地状态
+      const newSensors: SensorDeviceViewItem[] = checkedNodes.map((n) => ({
+        id: n.id,
+        cameraGroupDetailId: n.id,
+        code: n.code,
+        name: n.name,
+        url: '',
+        imageUrl: n.imageUrl || '',
+        status: n.disable ? 'offline' : 'online',
+      }));
 
-  watch(filterText, (val) => {
+      syncGroupSensors(newSensors);
+
+      // 移除已不在分组中的正在播放的传感器
+      cameraInPlay.value = cameraInPlay.value.map((x) => {
+        const stillExists = newSensors.find((s) => s.id === x.id);
+        return stillExists
+          ? x
+          : { ...x, id: -Date.now(), cameraGroupDetailId: -Date.now(), name: '', code: '', url: '', imageUrl: '' };
+      });
+
+      restartPlay();
+      ElMessage({ message: '保存成功', type: 'success' });
+      emit('close');
+    } catch {
+      ElMessage({ message: '保存失败', type: 'error' });
+    } finally {
+      saving.value = false;
+    }
+  }
+
+  const treeSource = computed(() => {
+    return deviceList.value.map((device) => ({
+      id: device.id,
+      code: device.deviceNo || String(device.id),
+      name: device.deviceName || device.deviceNo || `设备${String(device.id).slice(-3)}`,
+      url: '',
+      imageUrl: device.imgUrl || '',
+      nodeType: CameraTreeNodeType.camera,
+      networkingState: device.deviceStatus === 'offline' ? 1 : 0,
+      disable: device.deviceStatus === 'offline',
+      pushStreamDTO: { videoUrls: { pushstreamIp: '' } },
+    }));
+  });
+
+  watch(filterText, async (val) => {
+    await loadDeviceList(val);
     childrenNodeList.value = [];
-    treeRef.value!.filter(val);
+    treeRef.value?.filter(val);
   });
 
   function extractCodes(data: any[], codes: string[] = []) {
@@ -132,22 +175,18 @@
     return codes;
   }
 
-  const filterNode = (value: string, data: Tree, node) => {
+  const filterNode = (value: string, data: TreeNode, node) => {
     if (!value) return true;
-    // 检查当前节点的 label 是否包含关键词
     const labelMatch = data.name.includes(value);
-
     if (labelMatch) {
       if (node.childNodes && node.childNodes.length > 0) {
         childrenNodeList.value = extractCodes(node.childNodes, []);
       }
     }
-
     if (childrenNodeList.value.includes(data.code)) {
       return true;
-    } else {
-      return labelMatch;
     }
+    return labelMatch;
   };
 
   const isInvalid = (data) => {
@@ -155,20 +194,27 @@
     return data.networkingState !== 0;
   };
 
-  onMounted(() => {
-    treeLoading.value = true;
-    getCameraTree()
-      .then((res) => {
-        cameraTree.value = res;
-        if (cameraTree.value.length === 0) treeEmptyText.value = '暂无数据';
-      })
-      .catch(() => {
-        treeEmptyText.value = '暂无数据';
-      })
-      .finally(() => {
-        treeLoading.value = false;
+  // 树数据加载完成后,回填已勾选项
+  watch(
+    cameraTree,
+    () => {
+      nextTick(() => {
+        treeRef.value?.setCheckedKeys(initialCheckedKeys.value);
       });
-  });
+    },
+    { deep: true },
+  );
+
+  watch(
+    treeSource,
+    (value) => {
+      cameraTree.value = value;
+      treeEmptyText.value = deviceList.value.length === 0 ? '暂无数据' : '';
+    },
+    { immediate: true, deep: true },
+  );
+
+  loadDeviceList();
 </script>
 <style lang="scss" scoped>
   .cameraTreeWrapper {
@@ -206,8 +252,6 @@
             top: -5px;
           }
         }
-        .cameraName {
-        }
       }
       .mask {
         height: 26px;
@@ -229,4 +273,13 @@
   :deep(.el-tree-node__content) {
     position: relative;
   }
+
+  .dialogFooter {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+    padding: 12px 8px 4px;
+    border-top: 1px solid #f0f0f0;
+    margin-top: 8px;
+  }
 </style>

+ 137 - 217
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/CamerasGrid.vue

@@ -1,258 +1,178 @@
 <template>
   <div class="main-grid" id="main-grid">
-    <div v-if="cameraInPlay.some((x) => x.url !== '')" class="video-grid" :class="setGridSize()" id="video-grid">
-      <div :id="`video-${index}`" v-for="(camera, index) in props.cameraInPlay" :key="camera.id" class="video-box">
-        <div class="LiveVideo" v-if="camera.url !== ''">
-          <LiveVideo
-            :url="getWsUrl(camera.url)"
-            :poster="camera.imageUrl"
-            @dblclick="isFullScreen ? exitFullscreen() : fullScreen(`video-${index}`, 'single')"
-          />
-        </div>
-
-        <div class="emptyCameraGrid" v-else>
-          <div class="emptyCameraImgAndText">
-            <img class="emptyCameraGridImg" src="@/assets/icons/nine-square-grid/cameraEmpty.png" />
-            <div>暂未接入相机</div>
-          </div>
-        </div>
-
-        <div class="video-controlBar" v-if="camera.url && !isFullScreen">
-          <div class="cameraName">{{ camera.name }}</div>
-          <div class="controlIconContainer">
-            <Delete class="controlIcon fullScreen" @click="handleDeleteCamera(camera)" />
-          </div>
+    <div class="sensor-grid-wrap" id="video-grid">
+      <div class="sensor-grid" :class="gridClassName">
+        <div v-for="sensor in displaySensors" :key="sensor.id" class="sensor-card">
+          <template v-if="hasSensor(sensor)">
+            <div class="sensor-title">{{ sensor.name || `传感器${String(sensor.id).slice(-3)}` }}</div>
+            <template v-if="getMetrics(sensor.id).length > 0">
+              <div class="metric-list">
+                <div v-for="item in getMetrics(sensor.id)" :key="item.label" class="metric-item">
+                  <div class="metric-label">{{ item.label }}</div>
+                  <div class="metric-value">{{ item.value }}</div>
+                </div>
+              </div>
+            </template>
+            <div v-else class="metric-empty">等待数据...</div>
+          </template>
+          <div v-else class="empty-card">暂未接入传感器</div>
         </div>
       </div>
     </div>
-
-    <div class="allCameraEmpty" v-else>
-      <img class="cameraEmptyImg" src="@/assets/icons/nine-square-grid/cameraEmpty.png" />
-      <div>暂未接入相机</div>
-    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { onUnmounted } from 'vue';
-  import { storeToRefs } from 'pinia';
-  import LiveVideo from '@/components/live/LiveVideoFlv.vue';
-  import screenfull from 'screenfull';
-  import { Delete } from '@element-plus/icons-vue';
-  import urlJoin from 'url-join';
-  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
-  import { userGridType } from '@/store/modules/userGridType';
-  import { GridType } from '@/types/camera-group';
-  import { type Camera, CameraInPlay } from '../type';
-  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
-  import { ElMessageBox } from 'element-plus';
-
-  const props = defineProps<{ cameraInPlay: CameraInPlay[] }>();
-  const { currentGrid } = storeToRefs(userGridType());
-  const { isFullScreen, curFullScreenType } = storeToRefs(userSplitScreenFullScreen());
+  import { computed } from 'vue';
+  import { CameraInPlay } from '../type';
+  import type { LocalGridType } from './ScreenToolbar.vue';
+  import type { SensorRealtimeData } from './useSensorRealtime';
+
+  const props = defineProps<{
+    cameraInPlay: CameraInPlay[];
+    currentGrid: LocalGridType;
+    sensorData: SensorRealtimeData;
+  }>();
+
+  const displaySensors = computed(() => {
+    const list = props.cameraInPlay;
+    const appendNum = Math.max(0, props.currentGrid - list.length);
+    const emptyList: CameraInPlay[] = Array.from({ length: appendNum }, (_, index) => ({
+      id: -1000 - index,
+      cameraGroupDetailId: -1000 - index,
+      url: '',
+      name: '',
+      code: '',
+      imageUrl: '',
+    }));
+
+    return list.concat(emptyList);
+  });
 
-  const { playingGroup } = storeToRefs(useCameraGroupList());
-  const { deleteCameraInPlaylist } = useCameraGroupList();
-  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+  const gridClassName = computed(() => `grid-${props.currentGrid}`);
 
-  function handleDeleteCamera(camera: Camera) {
-    ElMessageBox.confirm('删除后,相机数据不可恢复,是否确认删除?', '提示', {
-      cancelButtonText: '取消',
-      confirmButtonText: '确定',
-      customClass: 'customMessageBox--warning',
-    })
-      .then(() => {
-        deleteCameraInPlaylist(playingGroup.value!, camera);
-      })
-      .catch(() => {
-        return;
-      });
+  function getMetrics(sensorId: number) {
+    if (sensorId <= 0) return [];
+    const data = props.sensorData[sensorId] ?? {};
+    return Object.entries(data).map(([, { name, value, unit }]) => ({
+      label: name,
+      value: `${value}${unit || ''}`,
+    }));
   }
 
-  const isHttps = () => {
-    return window.location.protocol.startsWith('https');
-  };
-
-  const getWsUrl = (videoUrl: string) => {
-    if (!videoUrl) return '';
-    const protocol = isHttps() ? 'wss' : 'ws';
-    // 如果是绝对地址
-    if (videoUrl.startsWith('http')) {
-      // 如果是https的话,websocket要用wss
-      return videoUrl.replace('http', protocol);
-    }
-    const u = urlJoin(
-      `${protocol}://`,
-      window.location.host,
-      window.location.pathname === '/' ? '' : window.location.pathname,
-      videoUrl,
-    );
-
-    return u;
-  };
-
-  // 根据当前宫格数设置每个宫格宽高
-  const setGridSize = () => {
-    if (currentGrid.value === GridType.oneGrid) return 'oneGrid';
-    if (currentGrid.value === GridType.fourGrids) return 'fourGrid';
-    if (currentGrid.value === GridType.nineGrids) return 'nineGrids';
-    if (currentGrid.value === GridType.sixteenGrids) return 'sixteenGrids';
-  };
-
-  // 全屏之后,无法监听到键盘按键的点击事件,所以只能监听窗口的变化进行判断
-  window.onresize = () => {
-    if (!screenfull.isFullscreen) {
-      isFullScreen.value = false; //判断退出全屏,进行赋值
-      curFullScreenType.value = 'single';
-    }
-  };
-
-  onUnmounted(() => {
-    window.onresize = null;
-  });
+  const hasSensor = (sensor: CameraInPlay) => sensor.id > 0 && (sensor.name !== '' || sensor.code !== '');
 </script>
 
 <style lang="scss" scoped>
   .main-grid {
-    height: calc(100% - 54px);
+    height: calc(100% - 148px);
     background: #f4f7ff;
-    padding: 0 10px 10px 10px;
+    padding: 8px 10px 10px;
     border-radius: 0 0 4px 4px;
-    .video-grid {
-      height: 100%;
-      display: grid;
-      gap: 4px;
 
-      .video-box {
-        position: relative;
-        background: #fff;
-        .LiveVideo {
-          width: 100%;
-          height: 100%;
-          video {
-            object-fit: fill;
-          }
-        }
-        &:hover {
-          .video-controlBar {
-            display: flex;
-          }
-        }
-        .emptyCameraGrid {
-          height: 100%;
-          font-size: 12px;
-          color: #ebedf1;
-          margin: auto;
-          text-align: center;
-          .emptyCameraImgAndText {
-            margin: auto;
-            color: #999999;
-            .emptyCameraGridImg {
-              height: 50%;
-              width: 40%;
-            }
-          }
-        }
-        .video-controlBar {
-          position: absolute;
-          display: none;
-          bottom: 10px;
-          left: 0;
-          width: 100%;
-          justify-content: space-between;
-          padding: 0 10px;
+    .sensor-grid-wrap {
+      height: 100%;
+    }
 
-          .video-name {
-            background-color: rgba(255, 255, 255, 0.7);
-            padding: 0px 5px;
-            border-radius: 5px;
-            font-size: 12px;
-            color: black;
-          }
-          .controlIconContainer {
-            display: flex;
-            justify-content: space-around;
-            align-items: center;
+    .sensor-grid {
+      height: 100%;
+      display: grid;
+      gap: 10px;
+    }
 
-            border-radius: 16px;
-            padding: 0 16px;
-            background: rgba(0, 0, 0, 0.4);
-            .controlIcon {
-              height: 20px;
-              color: #fff;
-            }
-            .fullScreen {
-              height: 18px;
-              width: 18px;
-            }
-            .controlIcon:hover {
-              color: #1777ff;
-              cursor: pointer;
-            }
-          }
-        }
-        .videoDisconnected {
-          height: 20%;
-          width: 30%;
-          position: absolute;
-          color: white;
-          font-size: 24px;
-          top: 0;
-          bottom: 0;
-          left: 0;
-          right: 0;
-          margin: auto;
-        }
-      }
-      .selectedVideo {
-        outline: 4px;
-        outline-style: solid;
-        outline-color: #a1a2a2;
-      }
+    .grid-1 {
+      grid-template-columns: repeat(1, minmax(0, 1fr));
     }
 
-    .oneGrid {
-      grid-template-columns: repeat(1, 100%);
-      grid-template-rows: repeat(1, 100%);
+    .grid-4 {
+      grid-template-columns: repeat(2, minmax(0, 1fr));
     }
 
-    .fourGrid {
-      grid-template-columns: repeat(2, calc(50% - 2px));
-      grid-template-rows: repeat(2, calc(50% - 2px));
+    .grid-9 {
+      grid-template-columns: repeat(3, minmax(0, 1fr));
     }
 
-    .nineGrids {
-      grid-template-columns: repeat(3, calc(33.3% - 2px));
-      grid-template-rows: repeat(3, calc(33.3% - 2px));
+    .grid-16 {
+      grid-template-columns: repeat(4, minmax(0, 1fr));
     }
 
-    .sixteenGrids {
-      gap: 2px;
-      grid-template-columns: repeat(4, calc(25% - 1px));
-      grid-template-rows: repeat(4, calc(25% - 1px));
+    .sensor-card {
+      border: 1px solid #d8e3f6;
+      border-radius: 4px;
+      background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
+      box-shadow: 0 1px 8px rgba(23, 119, 255, 0.05);
+      padding: 10px;
+      overflow: hidden;
+
+      .sensor-title {
+        font-size: 16px;
+        color: #253b61;
+        font-weight: 600;
+        margin-bottom: 10px;
+      }
+
+      .metric-list {
+        display: grid;
+        grid-template-columns: repeat(2, minmax(120px, 1fr));
+        gap: 8px;
+        align-content: start;
+      }
+
+      .metric-item {
+        border: 1px solid #d8e3f6;
+        border-radius: 4px;
+        background: #ffffff;
+        padding: 8px;
+
+        .metric-label {
+          font-size: 14px;
+          line-height: 20px;
+          color: #1f2f46;
+          font-weight: 600;
+          margin-bottom: 4px;
+        }
+
+        .metric-value {
+          font-size: 14px;
+          line-height: 20px;
+          color: #8d9cb3;
+        }
+      }
+
+      .metric-empty {
+        font-size: 13px;
+        color: #b0bec5;
+        margin-top: 8px;
+      }
     }
 
-    .allCameraEmpty {
+    .empty-card {
       height: 100%;
       display: flex;
-      flex-direction: column;
-      justify-content: center;
       align-items: center;
-      margin: auto;
-      color: #999999;
-      .cameraEmptyImg {
-        height: 350px;
-        width: 350px;
+      justify-content: center;
+      color: #a0abc0;
+      font-size: 14px;
+      border: 1px dashed #d4deef;
+      border-radius: 4px;
+      background: #fbfdff;
+    }
+  }
+
+  @media screen and (max-width: 1380px) {
+    .main-grid {
+      .sensor-grid {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+        grid-template-rows: auto;
       }
     }
   }
 
-  .cameraName {
-    border-radius: 16px;
-    height: 32px;
-    line-height: 32px;
-    padding: 0 16px;
-    background: rgba(0, 0, 0, 0.4);
-    color: #fff;
+  @media screen and (max-width: 1024px) {
+    .main-grid {
+      .sensor-grid {
+        grid-template-columns: repeat(1, minmax(0, 1fr));
+      }
+    }
   }
 </style>

+ 358 - 235
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/ScreenToolbar.vue

@@ -1,133 +1,135 @@
 <template>
-  <div class="control-panel">
-    <div class="changeGridBar">
-      <div class="changeGrid">
-        <el-tooltip class="box-item" effect="dark" content="一屏" placement="bottom">
-          <img
-            :src="currentGrid === GridType.oneGrid ? oneGrid_selected : oneGrid"
-            class="changeGridIcon"
-            :class="currentGrid === GridType.oneGrid ? 'selectedGridIcon' : ''"
-            @click="changeGridType(GridType.oneGrid)"
-          />
-        </el-tooltip>
-      </div>
-
-      <div class="changeGrid">
-        <el-tooltip effect="dark" content="四屏" placement="bottom">
-          <img
-            :src="currentGrid === GridType.fourGrids ? fourGrids_selected : fourGrids"
-            class="changeGridIcon"
-            :class="currentGrid === GridType.fourGrids ? 'selectedGridIcon' : ''"
-            @click="changeGridType(GridType.fourGrids)"
-          />
-        </el-tooltip>
-      </div>
-
-      <div class="changeGrid">
-        <el-tooltip class="box-item" effect="dark" content="九屏" placement="bottom">
-          <img
-            :src="currentGrid === GridType.nineGrids ? nineGrids_selected : nineGrids"
-            class="changeGridIcon"
-            :class="currentGrid === GridType.nineGrids ? 'selectedGridIcon' : ''"
-            @click="changeGridType(GridType.nineGrids)"
-          />
-        </el-tooltip>
-      </div>
-
-      <div class="changeGrid">
-        <el-tooltip class="box-item" effect="dark" content="十六屏" placement="bottom">
-          <img
-            :src="currentGrid === GridType.sixteenGrids ? sixteenGrids_selected : sixteenGrids"
-            class="changeGridIcon"
-            :class="currentGrid === GridType.sixteenGrids ? 'selectedGridIcon' : ''"
-            @click="changeGridType(GridType.sixteenGrids)"
-          />
-        </el-tooltip>
-      </div>
-
-      <div>|</div>
-    </div>
-
-    <div class="controlBtns">
-      <div class="lockAndSetTime">
-        <div class="pausePlay" @click="handleClickLock">
-          <el-tooltip class="box-item" effect="dark" :content="isPaused ? '恢复轮播' : '取消轮播'" placement="bottom">
-            <div v-if="isPaused">
-              <img src="@/assets/icons/nine-square-grid/lock.png" />
-            </div>
-            <div v-else>
-              <img src="@/assets/icons/nine-square-grid/unlock.png" />
-            </div>
+  <div class="toolbar">
+    <div class="control-row">
+      <div style="display: flex">
+        <div class="grid-switch">
+          <el-tooltip effect="dark" content="一屏" placement="bottom">
+            <img
+              :src="currentGrid === GridType.oneGrid ? oneGrid_selected : oneGrid"
+              class="grid-icon"
+              :class="{ selected: currentGrid === GridType.oneGrid }"
+              @click="handleChange(GridType.oneGrid)"
+            />
+          </el-tooltip>
+          <el-tooltip effect="dark" content="四屏" placement="bottom">
+            <img
+              :src="currentGrid === GridType.fourGrids ? fourGrids_selected : fourGrids"
+              class="grid-icon"
+              :class="{ selected: currentGrid === GridType.fourGrids }"
+              @click="handleChange(GridType.fourGrids)"
+            />
+          </el-tooltip>
+          <el-tooltip effect="dark" content="九屏" placement="bottom">
+            <img
+              :src="currentGrid === GridType.nineGrids ? nineGrids_selected : nineGrids"
+              class="grid-icon"
+              :class="{ selected: currentGrid === GridType.nineGrids }"
+              @click="handleChange(GridType.nineGrids)"
+            />
+          </el-tooltip>
+          <el-tooltip effect="dark" content="十六屏" placement="bottom">
+            <img
+              :src="currentGrid === GridType.sixteenGrids ? sixteenGrids_selected : sixteenGrids"
+              class="grid-icon"
+              :class="{ selected: currentGrid === GridType.sixteenGrids }"
+              @click="handleChange(GridType.sixteenGrids)"
+            />
           </el-tooltip>
         </div>
 
-        <div class="setIntervalTime" v-show="isPlaying && !isPaused">
-          <div>相机轮播时间间隔:</div>
-          <el-input
-            v-model="playIntervalTime"
-            ref="inputRef"
-            placeholder="默认60"
-            class="intervalTimeInputBar"
-            @blur="handelStartPlay"
-            @keyup.enter="handelStartPlay"
-          >
-            <template #append>
-              <div>秒</div>
-            </template>
-          </el-input>
+        <div class="play-control">
+          <div class="lock-btn" @click="handleClickLock">
+            <el-tooltip effect="dark" :content="isPaused ? '恢复轮播' : '取消轮播'" placement="bottom">
+              <img v-if="isPaused" src="@/assets/icons/nine-square-grid/lock.png" />
+              <img v-else src="@/assets/icons/nine-square-grid/unlock.png" />
+            </el-tooltip>
+          </div>
+
+          <div v-show="isPlaying && !isPaused" class="interval-input">
+            <span>轮播间隔</span>
+            <el-input
+              v-model="playIntervalTime"
+              ref="inputRef"
+              placeholder="默认60"
+              class="input"
+              @blur="handelStartPlay"
+              @keyup.enter="handelStartPlay"
+            >
+              <template #append>
+                <div>秒</div>
+              </template>
+            </el-input>
+          </div>
         </div>
       </div>
 
-      <div class="RoundAndFullScreen">
-        <div class="controlRound">
+      <div style="display: flex">
+        <div class="control-round">
           <div
-            class="previousRound"
-            :class="currentRound === 1 || currentRound === 0 ? 'disableChangeRound' : ''"
+            class="previous-round"
+            :class="displayCurrentRound <= 1 ? 'disable-change-round' : ''"
             @click="playPreviousRound"
           >
-            <el-tooltip class="box-item" effect="dark" content="上一轮" placement="bottom">
+            <el-tooltip effect="dark" content="上一轮" placement="bottom">
               <ArrowLeft />
             </el-tooltip>
           </div>
 
           <div class="rounds">
-            <div class="currentRound">{{ currentRound }}</div>
-            <div class="totalRound">/{{ totalRound }}</div>
+            <div class="current-round">{{ displayCurrentRound }}</div>
+            <div class="total-round">/{{ displayTotalRound }}</div>
           </div>
 
           <div
-            class="nextRound"
+            class="next-round"
+            :class="displayCurrentRound >= displayTotalRound ? 'disable-change-round' : ''"
             @click="playNextRound"
-            :class="currentRound === totalRound ? 'disableChangeRound' : ''"
           >
-            <el-tooltip class="box-item" effect="dark" content="下一轮" placement="bottom">
+            <el-tooltip effect="dark" content="下一轮" placement="bottom">
               <ArrowRight />
             </el-tooltip>
           </div>
         </div>
 
-        <el-tooltip class="box-item" effect="dark" content="全屏" placement="bottom">
-          <img
-            src="@/assets/icons/nine-square-grid/fullScreen2.png"
-            class="fullScreenIcon"
-            @click="isFullScreen ? exitFullscreen() : fullScreen('video-grid', 'all')"
-          />
-        </el-tooltip>
+        <div class="full-screen" @click="isFullScreen ? exitFullscreen() : fullScreen('video-grid', 'all')">
+          <el-tooltip effect="dark" content="全屏" placement="bottom">
+            <img src="@/assets/icons/nine-square-grid/fullScreen2.png" class="full-screen-icon" />
+          </el-tooltip>
+        </div>
+      </div>
+    </div>
+
+    <div class="stats-row">
+      <div class="stats">
+        <div class="stat stat-blue">
+          <div class="label">● 在线率</div>
+          <div class="value">{{ groupStats.onlineRate }}</div>
+        </div>
+        <div class="stat stat-orange">
+          <div class="label">● 异常</div>
+          <div class="value">{{ groupStats.exceptionCount }}</div>
+        </div>
+        <div class="stat stat-green">
+          <div class="label">● 离线</div>
+          <div class="value">{{ groupStats.outlineCount }}</div>
+        </div>
+        <div class="stat stat-red">
+          <div class="label">● 故障</div>
+          <div class="value">{{ groupStats.failureCount }}</div>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { ref, watch } from 'vue';
+  import { computed, ref, toRefs, watch } from 'vue';
   import { storeToRefs } from 'pinia';
   import { ElInput, ElTooltip } from 'element-plus';
   import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
-  import { userGridType } from '@/store/modules/userGridType';
-  import { GridType } from '@/views/disaster/monitor/splitScreenRetrieval/type';
   import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
-  import { modifyCameraGroupApi } from '@/api/nine-square-grid';
+  import { updateSensorGroupApi } from '@/api/sensor-group';
   import oneGrid from '@/assets/icons/nine-square-grid/oneGrid.png';
   import oneGrid_selected from '@/assets/icons/nine-square-grid/oneGrid-selected.png';
   import fourGrids from '@/assets/icons/nine-square-grid/fourGrids.png';
@@ -136,199 +138,320 @@
   import nineGrids_selected from '@/assets/icons/nine-square-grid/nineGrids-selected.png';
   import sixteenGrids from '@/assets/icons/nine-square-grid/sixteenGrids.png';
   import sixteenGrids_selected from '@/assets/icons/nine-square-grid/sixteenGrids-selected.png';
+  import type { SensorGroupStats } from './useSensorRealtime';
 
-  const inputRef = ref();
+  export type LocalGridType = 1 | 4 | 9 | 16;
+
+  const GridType = {
+    oneGrid: 1,
+    fourGrids: 4,
+    nineGrids: 9,
+    sixteenGrids: 16,
+  } as const;
+
+  const props = defineProps<{
+    currentGrid: LocalGridType;
+    isPlaying: boolean;
+    isPaused: boolean;
+    playIntervalTime: number;
+    currentRound: number;
+    totalRound: number;
+    realtimeGroupStats?: SensorGroupStats;
+  }>();
+
+  const { currentGrid, isPlaying, isPaused, currentRound, totalRound } = toRefs(props);
 
-  const { currentGrid } = storeToRefs(userGridType());
-  const { changeGridType } = userGridType();
+  const emit = defineEmits<{
+    (event: 'update:currentGrid', value: LocalGridType): void;
+    (event: 'update:playIntervalTime', value: number): void;
+    (event: 'prev-round'): void;
+    (event: 'next-round'): void;
+    (event: 'toggle-pause'): void;
+  }>();
+
+  const inputRef = ref();
 
   const { isFullScreen } = storeToRefs(userSplitScreenFullScreen());
   const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+  const { playingGroup, cameraGroupList } = storeToRefs(useCameraGroupList());
 
-  const { playingGroup, isPlaying, isPaused, playIntervalTime, totalRound, currentRound } = storeToRefs(
-    useCameraGroupList(),
-  );
-  const { groupStartPlay, continuePlay, pausePlay, playPreviousRound, playNextRound } = useCameraGroupList();
+  function syncGroupState(payload: { refreshIntervalSec?: number; isPaused?: 0 | 1 }) {
+    if (!playingGroup.value?.id) return;
 
-  function handelStartPlay() {
-    // 如果输入为0或非数字则返回
-    if (playIntervalTime.value === null || playIntervalTime.value === 0 || !Number(playIntervalTime.value)) {
-      inputRef.value.blur();
-      playIntervalTime.value = 60;
-      return;
+    Object.assign(playingGroup.value as any, payload);
+    const target = (cameraGroupList.value as any[]).find((x) => x.id === playingGroup.value?.id);
+    if (target) {
+      Object.assign(target, payload);
     }
+  }
 
-    inputRef.value.blur();
-    playingGroup.value!.playIntervalSec = Number(playIntervalTime.value);
-    groupStartPlay(Number(playIntervalTime.value));
-    // inputRef.value.focus();
-    modifyCameraGroupApi({
-      groupId: playingGroup.value?.id!,
-      playIntervalSec: playIntervalTime.value,
-    });
+  const groupStats = computed(() => {
+    const g = playingGroup.value as any;
+    const realtime = props.realtimeGroupStats;
+    return {
+      onlineRate: realtime?.onlineRate ?? g?.onlineRate ?? '--',
+      exceptionCount: realtime?.exceptionCount ?? g?.exceptionCount ?? '--',
+      outlineCount: realtime?.outlineCount ?? g?.outlineCount ?? '--',
+      failureCount: realtime?.failureCount ?? g?.failureCount ?? '--',
+    };
+  });
+
+  const playIntervalTime = computed({
+    get: () => props.playIntervalTime,
+    set: (value: number | string) => {
+      const numValue = Number(value);
+      emit('update:playIntervalTime', Number.isNaN(numValue) ? 60 : numValue);
+    },
+  });
+
+  function handleChange(value: LocalGridType) {
+    emit('update:currentGrid', value);
   }
 
-  function handleClickLock() {
-    isPaused.value = !isPaused.value;
-    if (isPaused.value) {
-      pausePlay();
-    } else {
-      continuePlay();
+  async function handelStartPlay() {
+    if (playIntervalTime.value === null || playIntervalTime.value === 0 || !Number(playIntervalTime.value)) {
+      inputRef.value?.blur();
+      emit('update:playIntervalTime', 60);
+      return;
     }
 
-    if (isPlaying.value) {
-      playingGroup.value!.isPaused = isPaused.value;
-      modifyCameraGroupApi({
-        groupId: playingGroup.value?.id!,
-        isPaused: Number(isPaused.value),
+    if (playingGroup.value?.id) {
+      const interval = Number(playIntervalTime.value);
+      await updateSensorGroupApi({
+        groupId: playingGroup.value.id,
+        refreshIntervalSec: interval,
+      });
+      syncGroupState({ refreshIntervalSec: interval });
+    }
+
+    inputRef.value?.blur();
+    emit('update:playIntervalTime', Number(playIntervalTime.value));
+  }
+
+  async function handleClickLock() {
+    if (playingGroup.value?.id && isPlaying.value) {
+      const nextIsPaused = (isPaused.value ? 1 : 0) as 0 | 1;
+      await updateSensorGroupApi({
+        groupId: playingGroup.value.id,
+        isPaused: nextIsPaused,
       });
+      syncGroupState({ isPaused: nextIsPaused });
     }
+    emit('toggle-pause');
+  }
+
+  function playPreviousRound() {
+    emit('prev-round');
+  }
+
+  function playNextRound() {
+    emit('next-round');
   }
 
   watch(
     () => isPlaying.value,
-    (newValue) => {
-      if (newValue) {
-        inputRef.value.focus();
-        inputRef.value.select();
+    (value) => {
+      if (value) {
+        inputRef.value?.focus();
+        inputRef.value?.select();
       }
     },
   );
+
+  const displayCurrentRound = computed(() => (currentRound.value <= 0 ? 1 : currentRound.value));
+  const displayTotalRound = computed(() => (totalRound.value <= 0 ? 1 : totalRound.value));
 </script>
 
 <style lang="scss" scoped>
-  .control-panel {
-    height: 54px;
+  .toolbar {
+    min-height: 148px;
     width: 100%;
     background: #f4f7ff;
     display: flex;
-    justify-content: space-between;
-    font-size: 12px;
+    flex-direction: column;
+    justify-content: flex-start;
+    gap: 10px;
+    padding: 10px 12px;
     border-radius: 4px 4px 0 0;
 
-    .changeGridBar {
+    .control-row {
+      width: 100%;
       display: flex;
       align-items: center;
-      padding-left: 10px;
-      margin-right: 19px;
-      .changeGrid {
-        margin-right: 32px;
-        .changeGridIcon {
-          width: 25px;
-          color: white;
-        }
-        .changeGridIcon:hover {
-          outline: 1px solid #1777ff;
-          color: #1777ff;
-        }
+      justify-content: space-between;
+      min-height: 46px;
+      border: 1px solid #d8e3f6;
+      border-radius: 4px;
+      background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
+      padding: 0 10px;
+    }
+
+    .grid-switch {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+
+      .grid-icon {
+        width: 24px;
+        height: 24px;
+        cursor: pointer;
+        border-radius: 4px;
+        transition: all 0.2s;
+      }
+
+      .grid-icon:hover,
+      .grid-icon.selected {
+        outline: 1px solid #1777ff;
+        background: #f2f7ff;
       }
     }
 
-    .controlBtns {
-      width: 100%;
+    .play-control {
       display: flex;
-      justify-content: space-between;
       align-items: center;
-      .lockAndSetTime {
+      gap: 12px;
+      margin-left: 20px;
+
+      .lock-btn {
         display: flex;
-        justify-content: space-between;
         align-items: center;
-        .pausePlay {
-          display: flex;
-          justify-content: space-between;
-          align-items: center;
-          margin-right: 19px;
-          img {
-            height: 24px;
-            width: 24px;
-          }
-        }
-        .setIntervalTime {
-          width: 230px;
-          display: flex;
-          justify-content: space-between;
-          align-items: center;
-          .intervalTimeInputBar {
-            margin-left: 10px;
-            width: 110px;
-          }
+        cursor: pointer;
+
+        img {
+          width: 24px;
+          height: 24px;
         }
       }
 
-      .RoundAndFullScreen {
+      .interval-input {
         display: flex;
-        justify-content: space-between;
         align-items: center;
-        .controlRound {
-          display: flex;
-          justify-content: space-between;
-          align-items: center;
-          margin-right: 69px;
-          .previousRound,
-          .nextRound {
-            width: 15px;
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            color: #000;
-            border-radius: 4px;
-            margin-right: 14px;
-            cursor: pointer;
-            &:hover {
-              background-color: rgba(255, 255, 255, 0.2);
-            }
-          }
-          .disableChangeRound {
-            color: #909399;
-            pointer-events: none;
-          }
-          .rounds {
-            display: flex;
-            line-height: 59px;
-            color: #ffffff;
-            font-weight: 400;
-            font-size: 20px;
-            margin-right: 14px;
-            .currentRound {
-              color: #1777ff;
-            }
-            .totalRound {
-              color: rgb(0, 0, 0, 0.3);
-            }
-          }
+        gap: 8px;
+        color: #355a9b;
+
+        .input {
+          width: 130px;
         }
+      }
+    }
+
+    .control-round {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+
+      .previous-round,
+      .next-round {
+        width: 18px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        color: #3a4f73;
+        border-radius: 4px;
+        cursor: pointer;
 
-        .fullScreenIcon {
-          width: 25px;
-          color: white;
-          margin-right: 20px;
+        &:hover {
+          background-color: #eef5ff;
         }
-        .fullScreenIcon:hover {
+      }
+
+      .disable-change-round {
+        color: #b7c2d6;
+        pointer-events: none;
+      }
+
+      .rounds {
+        display: flex;
+        align-items: baseline;
+        min-width: 58px;
+        justify-content: center;
+
+        .current-round {
           color: #1777ff;
+          font-weight: 600;
+          font-size: 16px;
+        }
+
+        .total-round {
+          color: #9aacbf;
+          font-size: 14px;
         }
       }
     }
-  }
 
-  :deep(.el-input) {
-    background-color: #f4f7ff;
-  }
-  :deep(.el-input__inner) {
-    color: #333333;
-    border: 0px;
-  }
-  :deep(.el-input__wrapper) {
-    background-color: #f4f7ff;
-  }
-  :deep(.el-input-group__append) {
-    width: 0px;
-    background-color: #f4f7ff;
-  }
-  :deep(.el-input.is-disabled .el-input__wrapper) {
-    background-color: #f4f7ff;
+    .full-screen {
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+      margin-left: 20px;
+    }
+
+    .full-screen-icon {
+      width: 22px;
+      height: 22px;
+    }
+
+    .stats-row {
+      width: 100%;
+    }
+
+    .stats {
+      display: flex;
+      gap: 10px;
+
+      .stat {
+        width: 160px;
+        height: 78px;
+        border: 1px solid #d8e3f6;
+        border-radius: 4px;
+        padding: 10px 14px;
+        background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
+        box-shadow: 0 1px 6px rgba(23, 119, 255, 0.06);
+
+        .label {
+          font-size: 16px;
+          line-height: 1;
+          font-weight: 500;
+          margin-bottom: 8px;
+        }
+
+        .value {
+          font-size: 30px;
+          line-height: 1;
+          font-weight: 600;
+        }
+      }
+
+      .stat-blue {
+        color: #4b84ff;
+      }
+
+      .stat-orange {
+        color: #f4a13e;
+      }
+
+      .stat-green {
+        color: #1fb45a;
+      }
+
+      .stat-red {
+        color: #ff5f5f;
+      }
+    }
   }
-  :deep(.el-tooltip__trigger) {
-    outline: none;
-    border: none;
+
+  @media screen and (max-width: 1380px) {
+    .toolbar {
+      .stats {
+        flex-wrap: wrap;
+      }
+
+      .control-row {
+        flex-wrap: wrap;
+        height: auto;
+        padding: 8px 10px;
+        gap: 8px;
+      }
+    }
   }
 </style>

+ 164 - 2
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/VideosGridBase.vue

@@ -1,14 +1,176 @@
 <template>
-  <ScreenToolbar />
-  <CamerasGrid :cameraInPlay="props.cameraInPlay" />
+  <ScreenToolbar
+    v-model:currentGrid="currentGrid"
+    v-model:playIntervalTime="playIntervalTime"
+    :isPlaying="isPlaying"
+    :isPaused="isPaused"
+    :currentRound="currentRound"
+    :totalRound="totalRound"
+    :realtimeGroupStats="groupStats"
+    @prev-round="playPreviousRound"
+    @next-round="playNextRound"
+    @toggle-pause="togglePause"
+  />
+  <CamerasGrid :cameraInPlay="cameraInPlayOfCurrentRound" :currentGrid="currentGrid" :sensorData="sensorData" />
 </template>
 
 <script setup lang="ts">
+  import { computed, onBeforeUnmount, ref, watch } from 'vue';
+  import { storeToRefs } from 'pinia';
   import ScreenToolbar from './ScreenToolbar.vue';
   import CamerasGrid from './CamerasGrid.vue';
   import { type CameraInPlay } from '../type';
+  import type { LocalGridType } from './ScreenToolbar.vue';
+  import { useSensorRealtime } from './useSensorRealtime';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import type { SensorGroupView } from '@/api/sensor-group/type';
 
   const props = defineProps<{ cameraInPlay: CameraInPlay[] }>();
+
+  const currentGrid = ref<LocalGridType>(9);
+  const playIntervalTime = ref<number>(60);
+  const isPaused = ref<boolean>(false);
+  const currentRound = ref<number>(1);
+  let autoplayTimer: ReturnType<typeof setInterval> | null = null;
+
+  const { sensorData, groupStats, connect, close } = useSensorRealtime();
+  const { playingGroup } = storeToRefs(useCameraGroupList());
+
+  watch(
+    () => playingGroup.value as unknown as SensorGroupView | undefined,
+    (group) => {
+      if (!group) {
+        playIntervalTime.value = 60;
+        isPaused.value = false;
+        return;
+      }
+
+      // refreshIntervalSec 直接映射到播放间隔,接口缺省时默认 60 秒
+      playIntervalTime.value = normalizePlayInterval(group.refreshIntervalSec ?? 60);
+      // :1=轮播,0=不轮播;前端 isPaused 语义:true=已暂停
+      isPaused.value = !(group.isPaused ?? 1);
+    },
+    { immediate: true, deep: true },
+  );
+
+  // playingGroup.details 取 sensorId
+  watch(
+    () => (playingGroup.value as unknown as SensorGroupView)?.details,
+    (details) => {
+      const ids = (details || []).map((d) => d.id).filter((id) => id > 0);
+      if (ids.length > 0) {
+        connect(ids);
+      } else {
+        close();
+      }
+    },
+    { immediate: true, deep: true },
+  );
+
+  const totalRound = computed(() => {
+    if (props.cameraInPlay.length === 0) return 1;
+    return Math.max(1, Math.ceil(props.cameraInPlay.length / currentGrid.value));
+  });
+
+  const isPlaying = computed(() => totalRound.value > 1);
+
+  const cameraInPlayOfCurrentRound = computed(() => {
+    const startIndex = (currentRound.value - 1) * currentGrid.value;
+    const endIndex = startIndex + currentGrid.value;
+    return props.cameraInPlay.slice(startIndex, endIndex);
+  });
+
+  function normalizePlayInterval(value: number) {
+    const integerValue = Math.floor(Number(value));
+    if (Number.isNaN(integerValue) || integerValue <= 0) return 60;
+    return integerValue;
+  }
+
+  function stopAutoplay() {
+    if (!autoplayTimer) return;
+    clearInterval(autoplayTimer);
+    autoplayTimer = null;
+  }
+
+  function playNextRound(withLoop = false) {
+    if (totalRound.value <= 1) {
+      currentRound.value = 1;
+      return;
+    }
+
+    const nextRound = currentRound.value + 1;
+    if (nextRound > totalRound.value) {
+      currentRound.value = withLoop ? 1 : totalRound.value;
+      return;
+    }
+
+    currentRound.value = nextRound;
+  }
+
+  function playPreviousRound() {
+    if (totalRound.value <= 1) {
+      currentRound.value = 1;
+      return;
+    }
+
+    currentRound.value = Math.max(1, currentRound.value - 1);
+  }
+
+  function startAutoplayIfNeeded() {
+    stopAutoplay();
+
+    if (!isPlaying.value || isPaused.value) {
+      return;
+    }
+
+    autoplayTimer = setInterval(() => {
+      playNextRound(true);
+    }, normalizePlayInterval(playIntervalTime.value) * 1000);
+  }
+
+  function togglePause() {
+    if (!isPlaying.value) return;
+    isPaused.value = !isPaused.value;
+  }
+
+  watch(
+    () => currentGrid.value,
+    () => {
+      currentRound.value = 1;
+    },
+  );
+
+  watch(
+    () => props.cameraInPlay.length,
+    () => {
+      if (currentRound.value > totalRound.value) {
+        currentRound.value = totalRound.value;
+      }
+      if (currentRound.value <= 0) {
+        currentRound.value = 1;
+      }
+    },
+  );
+
+  watch(
+    () => playIntervalTime.value,
+    (value) => {
+      playIntervalTime.value = normalizePlayInterval(value);
+      startAutoplayIfNeeded();
+    },
+  );
+
+  watch([isPlaying, isPaused, totalRound], () => {
+    if (currentRound.value > totalRound.value) {
+      currentRound.value = totalRound.value;
+    }
+    startAutoplayIfNeeded();
+  });
+
+  onBeforeUnmount(() => {
+    stopAutoplay();
+    close();
+  });
 </script>
 
 <style lang="scss" scoped></style>

+ 130 - 0
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/VideosGridBase/useSensorRealtime.ts

@@ -0,0 +1,130 @@
+import { ref, onUnmounted } from 'vue';
+
+export interface RealtimePropertyValue {
+  /** 中文名,如"温度" */
+  name: string;
+  /** 当前值 */
+  value: string | number;
+  /** 单位,如"°C" */
+  unit: string;
+}
+
+export type SensorRealtimeData = Record<number, Record<string, RealtimePropertyValue>>;
+
+export interface SensorGroupStats {
+  onlineRate: string | number;
+  exceptionCount: string | number;
+  outlineCount: string | number;
+  failureCount: string | number;
+}
+
+interface WsMessage {
+  deviceId: number;
+  properties: Record<string, RealtimePropertyValue>;
+}
+
+type WsGroupStatsMessage = Partial<SensorGroupStats>;
+
+function isWsSensorMessage(payload: unknown): payload is WsMessage {
+  if (!payload || typeof payload !== 'object') return false;
+  const msg = payload as Partial<WsMessage>;
+  return typeof msg.deviceId === 'number' && !!msg.properties && typeof msg.properties === 'object';
+}
+
+function isWsGroupStatsMessage(payload: unknown): payload is WsGroupStatsMessage {
+  if (!payload || typeof payload !== 'object') return false;
+  const msg = payload as Record<string, unknown>;
+  return 'onlineRate' in msg || 'exceptionCount' in msg || 'outlineCount' in msg || 'failureCount' in msg;
+}
+
+function unwrapWsPayload(payload: unknown): unknown {
+  if (!payload || typeof payload !== 'object') return payload;
+  const maybeWrapped = payload as Record<string, unknown>;
+  if (maybeWrapped.data && typeof maybeWrapped.data === 'object') {
+    return maybeWrapped.data;
+  }
+  return payload;
+}
+
+export function useSensorRealtime() {
+  const sensorData = ref<SensorRealtimeData>({});
+  const groupStats = ref<SensorGroupStats>({
+    onlineRate: '--',
+    exceptionCount: '--',
+    outlineCount: '--',
+    failureCount: '--',
+  });
+  let ws: WebSocket | null = null;
+
+  function connect(deviceIds: number[]) {
+    close();
+    if (!deviceIds.length) return;
+
+    const protocol = window.location.protocol.includes('https') ? 'wss' : 'ws';
+    const url = `${protocol}://${window.location.host}/ws_api_bak/ws/properties/realtime`;
+    ws = new WebSocket(url);
+
+    ws.onopen = () => {
+      // 发送认证信息
+      ws?.send(
+        JSON.stringify({
+          action: 'auth',
+          authorization:
+            'Basic ODM2NDIxOGUxNDE0NDQ3NTllNjY5Yjc5YmU2ZDQyNTI6YWJjZWNhZjg1N2I4NDljNDg3N2ZkNzRmZThkZDZkZTM=',
+        }),
+      );
+
+      deviceIds.forEach((deviceId) => {
+        ws?.send(JSON.stringify({ action: 'subscribe', deviceId, keys: [] }));
+      });
+    };
+
+    ws.onmessage = (e: MessageEvent) => {
+      try {
+        const raw = JSON.parse(e.data as string);
+        const payload = unwrapWsPayload(raw);
+
+        if (isWsSensorMessage(payload)) {
+          sensorData.value = {
+            ...sensorData.value,
+            [payload.deviceId]: {
+              ...(sensorData.value[payload.deviceId] ?? {}),
+              ...payload.properties,
+            },
+          };
+          return;
+        }
+
+        if (isWsGroupStatsMessage(payload)) {
+          groupStats.value = {
+            onlineRate: payload.onlineRate ?? groupStats.value.onlineRate,
+            exceptionCount: payload.exceptionCount ?? groupStats.value.exceptionCount,
+            outlineCount: payload.outlineCount ?? groupStats.value.outlineCount,
+            failureCount: payload.failureCount ?? groupStats.value.failureCount,
+          };
+        }
+      } catch {}
+    };
+
+    ws.onerror = () => {
+      console.error('[SensorWS] connection error');
+      close();
+    };
+
+    ws.onclose = (event) => {
+      console.log('[SensorWS] closed:', event.code, event.reason);
+      close();
+    };
+  }
+
+  function close() {
+    ws?.close();
+    ws = null;
+  }
+
+  onUnmounted(() => {
+    close();
+  });
+
+  return { sensorData, groupStats, connect, close };
+}

+ 1 - 39
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/hooks/useCameraStatus.ts

@@ -1,43 +1,5 @@
 // 查询相机的在线或者离线状态
 
-import { getCameraState } from '@/api/camera/camera';
-import useCameraGroupList from '@/store/modules/useCameraGroupList';
-import { onMounted, onUnmounted, watch } from 'vue';
-
 export const useCameraStatus = () => {
-  const cameraGroupStore = useCameraGroupList();
-  let timer = 0;
-
-  const queryCameraState = () => {
-    const cameraList = cameraGroupStore.playingGroup?.children || [];
-    if (cameraList.length === 0) return;
-    const cameraCodes = cameraList.map((x) => x.code);
-    getCameraState({ cameraCodeList: cameraCodes }).then((res) => {
-      if (res?.length !== cameraCodes.length) return;
-      cameraGroupStore.updateCameraStatus(res);
-    });
-  };
-
-  watch(
-    () => cameraGroupStore.playingGroup?.children || [],
-    (cameraList) => {
-      if (cameraList.length > 0) {
-        queryCameraState();
-      }
-    },
-    {
-      immediate: true,
-      deep: true,
-    },
-  );
-
-  onMounted(() => {
-    timer = window.setInterval(() => {
-      queryCameraState();
-    }, 1000 * 10);
-  });
-
-  onUnmounted(() => {
-    window.clearInterval(timer);
-  });
+  return;
 };

+ 17 - 20
src/views/production-safety/risk-identification-and-control/key-site-sensor-manage/index.vue

@@ -8,33 +8,30 @@
 <template>
   <div class="nine-square-grid">
     <div class="leftSideBar">
-        传感器模块(开发中)
-      <!-- <CameraGroupListAndTree /> -->
+      <CameraGroupListAndTree />
     </div>
 
-    <div class="toolbarAndCamerasGrid">
-      <!-- <VideosGridBase :cameraInPlay="cameraInPlay" /> -->
+    <div v-if="isPlaying" class="toolbarAndCamerasGrid">
+      <VideosGridBase :cameraInPlay="cameraInPlay" />
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-//   import { onMounted } from 'vue';
-//   import CameraGroupListAndTree from './CameraGroupListAndTree/CameraGroupListAndTree.vue';
-//   import VideosGridBase from './VideosGridBase/VideosGridBase.vue';
-//   import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
-//   import { storeToRefs } from 'pinia';
-//   import { useTargetTenantIdStore } from '@/store/modules/useTargetTenantIdStore';
-//   import { useCameraStatus } from './hooks/useCameraStatus';
-
-//   const { cameraInPlay } = storeToRefs(useCameraGroupList());
-
-//   const { clear } = useTargetTenantIdStore();
-//   useCameraStatus();
-
-//   onMounted(() => {
-//     clear();
-//   });
+  import { onMounted } from 'vue';
+  import CameraGroupListAndTree from './CameraGroupListAndTree/CameraGroupListAndTree.vue';
+  import VideosGridBase from './VideosGridBase/VideosGridBase.vue';
+  import { useCameraGroupList } from '@/store/modules/useCameraGroupList';
+  import { storeToRefs } from 'pinia';
+  import { useTargetTenantIdStore } from '@/store/modules/useTargetTenantIdStore';
+
+  const { cameraInPlay, isPlaying } = storeToRefs(useCameraGroupList());
+
+  const { clear } = useTargetTenantIdStore();
+
+  onMounted(() => {
+    clear();
+  });
 </script>
 
 <style lang="scss" scoped>

+ 1 - 8
src/views/production-safety/risk-identification-and-control/labor-products-purchase-apply-manage/components/detail.vue

@@ -870,17 +870,10 @@
   };
 
   const handleApprovalImageExceed: UploadProps['onExceed'] = (files) => {
-    const uploadInstance = approvalUploadRef.value;
-    if (!uploadInstance) return;
-
-    uploadInstance.clearFiles();
-    const file = files[0] as UploadRawFile;
-    file.uid = genFileId();
-    uploadInstance.handleStart(file);
+    ElMessage.warning('请先删除当前图片再上传新图片');
   };
 
   const handleApprovalPictureCardPreview: UploadProps['onPreview'] = (fileUrl) => {
-    
     // dialogImageUrl.value = uploadFile.url || '';
     // dialogVisible.value = true;
   };

+ 1 - 1
src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/Item.vue

@@ -2,7 +2,7 @@
   <div class="safety-platform-container">
     <header class="safety-platform-container__header">
       <BreadcrumbBack />
-      <span class="breadcrumb-title">{{ headerTitle }} 1212121</span>
+      <span class="breadcrumb-title">{{ headerTitle }}</span>
     </header>
     <Detail />
   </div>

+ 2 - 2
src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/components/detail.vue

@@ -487,7 +487,7 @@
       if (res) {
         // 映射接口字段到表单字段
         ruleFormData.itemName = res.applicantName || ''; // 申请人姓名
-      ruleFormData.applicantCode = res.applicantCode || ''; // 工号
+      ruleFormData.employeeId = res.employeeId || ''; // 工号
       ruleFormData.warehouseDate = res.injuryTime ? res.injuryTime.split('T')[0] : ''; // 受伤时间
       ruleFormData.status = res.status ? 'ENABLE' : 'DISABLE'; // 状态
       ruleFormData.remarks = res.injuryReason || ''; // 受伤原因
@@ -516,7 +516,7 @@
     try {
       const basePayload = {
         applicantName: ruleFormData.itemName,
-        applicantCode: ruleFormData.applicantCode,
+        employeeId: ruleFormData.employeeId,
         injuryTime: ruleFormData.warehouseDate
           ? new Date(ruleFormData.warehouseDate).toISOString()
           : '',

+ 3 - 2
src/views/production-safety/risk-identification-and-control/work-injury-apply-manage/configs/form.ts

@@ -10,7 +10,7 @@ export const INVENTORY_FORM_CONFIG: FormConfig[] = [
     },
   },
    {
-    prop: 'applicantCode',
+    prop: 'employeeId',
     label: '工号:',
     component: 'ElInput',
     componentProps: {
@@ -108,13 +108,14 @@ export const INVENTORY_FORM_DATA = {
   departmentName: '',
   approvalTemplateId: '',
   approvalOrder: 0,
+  employeeId: '',
 };
 
 export const INVENTORY_FORM_RULES = {
   status: [{ required: true, message: '请选择状态', trigger: 'change' }],
   itemName: [{ required: true, message: '请输入物品名称', trigger: 'blur' }],
   warehouseDate: [{ required: true, message: '请选择入库日期', trigger: 'change' }],
-  applicantCode: [{ required: true, message: '请输入工号', trigger: 'blur' }],
+  employeeId: [{ required: true, message: '请输入工号', trigger: 'blur' }],
   departmentCode: [{ required: true, message: '请选择所属部门', trigger: 'change' }],
   remarks: [{ required: true, message: '请输入受伤原因', trigger: 'blur' }],
   departmentName: [{ required: true, message: '请选择所属部门', trigger: 'change' }],

+ 2 - 1
src/views/production-safety/safety-culture/safetyCultureMaterialManagement/configs/form.ts

@@ -87,6 +87,7 @@ export const ACADEMY_FILE_FORM_DATA = {
   status: 1, // 默认启用
   imageUrls:  '' as any,
   categoryName: '',
+  imageFileUrl: '',
 };
 
 export const ACADEMY_FILE_FORM_RULES = {
@@ -99,5 +100,5 @@ export const ACADEMY_FILE_FORM_RULES = {
   status: [{ required: true, message: '请选择状态', trigger: 'change' }],
   fileUrl: [{ required: true, message: '请选择文档上传', trigger: 'change' }],
   content: [{ required: true, message: '请输入文档内容', trigger: 'blur' }],
-  // imageFileUrl: [{ required: true, message: '请选择图片上传', trigger: 'change' }],
+  imageUrls: [{ required: true, message: '请选择图片上传', trigger: 'change' }],
 };