Sfoglia il codice sorgente

-m'多相机配置初始化'

dao qin 1 anno fa
parent
commit
bee77b4a05

+ 80 - 0
src/api/camera/camera-preview-group.ts

@@ -0,0 +1,80 @@
+import { http } from "@/utils/http/axios";
+import { CameraGroupResponse, CameraPreviewRequest, CameraTreeRequest, GropAlgoProps, saveDetectionGroupRequest, updateGroupStatusRequest } from "@/types/camera/camera-preview";
+
+/**
+  *@description: V4: 查询联合检测相机分组列表
+*/
+export function queryDetectionGroupList(params: CameraPreviewRequest) {
+  return http.request<CameraGroupResponse>({
+    url: '/admin/cameraDetectionGroup/queryDetectionGroupList',
+    method: 'GET',
+    params
+  })
+}
+
+/**
+  *@description: V4: 删除联合相机分组
+*/
+export function deleteDetectionGroup(data: number[]) {
+  return http.request({
+    url: '/admin/cameraDetectionGroup/deleteDetectionGroup',
+    method: 'DELETE',
+    data
+  })
+}
+
+/**
+  *@description: V4: 开启-关闭联合相机分组
+*/
+export function updateGroupStatus(data: updateGroupStatusRequest) {
+  return http.request({
+    url: '/admin/cameraDetectionGroup/updateDetectionGroupStatus',
+    method: 'POST',
+    data
+  })
+}
+
+/**
+  *@description: V4: 条件查询相机树
+*/
+export function queryCameraTreeByCondition(data: CameraTreeRequest) {
+  return http.request({
+    url: 'admin/cameraPreview/queryCameraTreeByCondition',
+    method: 'POST',
+    data
+  })
+}
+
+/**
+  *@description: V4: 查询联合相机可选算法
+*/
+export function getDetectionGroupLAlgoList() {
+  return http.request<GropAlgoProps[]>({
+    url: '/admin/algo/getDetectionGroupLAlgoList',
+    method: 'GET',
+  })
+}
+
+/**
+  *@description: V4: 新建多相机联合分组
+*/
+export function saveDetectionGroup(data: saveDetectionGroupRequest) {
+  return http.request({
+    url: '/admin/cameraDetectionGroup/saveDetectionGroup',
+    method: 'POST',
+    data
+  })
+}
+
+/**
+  *@description: V4: 根据联合相机分组id查询相机树
+*/
+export function queryCameraTreeByGroupId(params: {groupId: number}) {
+  return http.request({
+    url: `/admin/cameraDetectionGroup/queryCameraTreeByGroupId?groupId=${params.groupId}`,
+    method: 'GET',
+  })
+}
+
+
+

+ 48 - 0
src/directives/preview.ts

@@ -0,0 +1,48 @@
+import type { Directive } from 'vue'
+
+interface PreviewElement extends HTMLElement {
+  _previewHandler?: EventListener
+  _previewImage?: HTMLImageElement
+}
+
+const vPreview: Directive<PreviewElement> = {
+  mounted(el, binding) {
+    const img = new Image()
+    img.src = binding.value
+    img.style.position = 'fixed'
+    img.style.zIndex = '9999'
+    img.style.pointerEvents = 'none'
+    img.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)'
+    img.style.maxWidth = '300px'
+    img.style.maxHeight = '200px'
+    img.style.display = 'none'
+    document.body.appendChild(img)
+
+    const showPreview = (e: MouseEvent) => {
+      img.style.display = 'block'
+      img.style.left = `${e.clientX + 15}px`
+      img.style.top = `${e.clientY + 15}px`
+    }
+
+    const hidePreview = () => {
+      img.style.display = 'none'
+    }
+
+    el._previewHandler = showPreview
+    el._previewImage = img
+
+    el.addEventListener('mouseenter', showPreview)
+    el.addEventListener('mousemove', showPreview)
+    el.addEventListener('mouseleave', hidePreview)
+  },
+  unmounted(el) {
+    if (el._previewImage) {
+      document.body.removeChild(el._previewImage)
+    }
+    el.removeEventListener('mouseenter', el._previewHandler!)
+    el.removeEventListener('mousemove', el._previewHandler!)
+    el.removeEventListener('mouseleave', el._previewHandler!)
+  }
+}
+
+export default vPreview

+ 167 - 0
src/modules/camera-tree/CameraTree.vue

@@ -0,0 +1,167 @@
+<template>
+  <el-tree
+    ref="treeRef"
+    node-key="tempCode"
+    :data="treeData"
+    :props="defaultProps"
+    default-expand-all
+    :show-checkbox="isShowCheckbox"
+    @node-click="handleNodeClick"
+    @check="handleTreeCheck"
+  >
+    <template #default="{ node, data }">
+      <span class="flexCenter" :class="{ integrationState: data.integrationState === IntegrationState.DISABLED, nodeSelect: isSelect(data) }" >
+        <span v-if="data.nodeType === CameraTreeNodeType.camera" class="iconWrapper flexCenter" >
+          <span
+            class="cameraCommon"
+            :class="{
+              cameraSelect: isSelect(data),
+            }"
+          ></span>
+
+          <el-icon
+            class="cameraIcon"
+            :class="{
+              iconSelect: isSelect(data),
+            }"
+          >
+            <VideoCamera />
+          </el-icon>
+          <el-icon class="invalidCamera" v-if="isInvalid(data)"><WarningFilled /></el-icon>
+        </span>
+        <span 
+          v-if="data.nodeType === CameraTreeNodeType.camera && enablePreview "
+          v-preview="previewImage(data)">
+          {{ node.label }}
+          </span>
+        <span v-else>{{ node.label }}</span>
+      </span>
+    </template>
+  </el-tree>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { CameraTree, CameraTreeNodeType } from '@/api/camera/camera-preview';
+  import { useRouteQuery } from '@vueuse/router';
+  import { VideoCamera, WarningFilled } from '@element-plus/icons-vue';
+  import { IntegrationState } from './types/constant';
+  import vPreview from '@/directives/preview'
+  // import { useGlobSetting } from '@/hooks/setting';
+  // import urlJoin from 'url-join';
+
+  // const { urlPrefix } = useGlobSetting();
+  const cameraId = useRouteQuery('cameraId');
+
+  interface CameraTreeTempType extends CameraTree {
+    tempCode?: string;
+  }
+
+  defineProps<{
+    treeData: CameraTreeTempType[];
+    isShowCheckbox: boolean;
+    enablePreview?: boolean; 
+  }>();
+
+  const defaultProps = {
+    children: 'children',
+    label: 'name',
+  };
+
+  // 事件定义
+  const emit = defineEmits(['node-click', 'check']);
+
+  // 事件处理
+  const handleNodeClick = (data: CameraTreeTempType) => {
+    emit('node-click', data);
+  };
+
+  const handleTreeCheck = (node: CameraTreeTempType, checked: { checkedNodes: CameraTreeTempType[] }) => {
+    emit('check', node, checked);
+  };
+
+  const isSelect = (data) => data.nodeType === CameraTreeNodeType.camera && data.id === Number(cameraId.value);
+
+  const isInvalid = (data) => {
+    return data.networkingState !== 0;
+  };
+
+  const previewImage = (data) => {
+    if (data.nodeType === CameraTreeNodeType.camera) {
+      return data.pushStreamDTO?.imageUrl ? data.pushStreamDTO?.imageUrl  : '';
+    }
+
+  }
+  // 暴露方法和引用
+  const treeRef = ref();
+  const setCheckedKeys = (keys: string[]) => treeRef.value?.setCheckedKeys(keys);
+  const getCheckedNodes = (e) => treeRef.value?.getNode(e);
+  const setChecked = (key: string, state: boolean, deep: boolean) => treeRef.value?.setChecked(key, state, deep);
+
+  defineExpose({
+    getCheckedNodes,
+    setChecked,
+    setCheckedKeys
+  });
+</script>
+
+<style scoped>
+  .cameraSelect {
+    width: 6px;
+    height: 6px;
+    background: #0052d9;
+    display: inline-block;
+    border-radius: 6px;
+    margin-right: 6px;
+  }
+  .cameraTreeTitle {
+    background: #f0f2f5;
+    padding: 12px;
+    display: flex;
+  }
+
+  .detail-num {
+    font-size: 10px;
+    margin-top: 4px;
+    margin-left: 6px;
+  }
+
+  .cameraTreeInputWrapper {
+    padding: 8px;
+  }
+  .filterTextInput {
+    margin: 8px 0;
+  }
+  .flexCenter {
+    display: flex;
+    align-items: center;
+  }
+
+  .integrationState {
+    cursor: not-allowed;
+    color: #ccc;
+  }
+  .cameraIcon {
+    margin-right: 5px;
+    font-size: 18px;
+  }
+  .iconSelect {
+    color: #0052d9;
+  }
+
+  .iconWrapper {
+    position: relative;
+  }
+
+  .invalidCamera {
+    color: #dd5869;
+    font-size: 12px;
+    position: absolute;
+    right: 2px;
+    top: -4px;
+  }
+
+  .nodeSelect {
+    color: #0052d9;
+  }
+</style>

+ 6 - 0
src/modules/camera-tree/types/constant.ts

@@ -0,0 +1,6 @@
+export enum IntegrationState {
+  /* 启用 */
+  ENABLE = 0,
+  /* 启用 */
+  DISABLED = 1,
+}

+ 103 - 0
src/types/camera/camera-preview.ts

@@ -0,0 +1,103 @@
+import { PaginationRequest, PaginationResponse } from "@/types/common/type";
+
+export enum CameraGroupStatus {
+  /*开启 */
+  OPEN = 0,
+  /*关闭 */
+  CLOSE = 1,
+}
+
+interface GroupDetailListItem {
+  /*相机id */
+  cameraId: number;
+  /*设备ID */
+  cameraCode: string;
+  /*位置 */
+  location: string;
+  /*联合检测算法id */
+  algoId: number;
+  /*是否为主相机 */
+  isMainCamera: number;
+}
+
+export type CameraGroupItem = {
+  cameraDetectionGroupId: number;
+  /*联合检测相机分组状态 */
+  status: number;
+  /*分组详情 */
+  groupDetailList: GroupDetailListItem[];
+}
+
+export type CameraPreviewRequest = PaginationRequest;
+
+export type CameraGroupResponse = PaginationResponse<CameraGroupItem>;
+
+export type CameraGroupTableItem = Pick<CameraGroupItem, 'cameraDetectionGroupId' | 'status'> & GroupDetailListItem
+
+export type CameraTreeRequest = {
+  queryString: string,
+  isEnableAlgo: boolean,
+  isEnableRender: boolean
+}
+
+export interface GropAlgoProps {
+  id: number;
+  /*算法名称 */
+  name: string;
+  /*算法提供编码 */
+  code: string;
+  /*前端显示名称 */
+  showName: string;
+  /*描述 */
+  remark: string;
+  /*展示视频的地址 */
+  url: string;
+  /*推送语句 */
+  pushStatement: string;
+  /*推送链接提示 */
+  pushLinkPrompt: string;
+  /*图标的地址 */
+  iconUrl: string;
+  /*状态: 0-启用, 1-禁用 */
+  status: number;
+  /*创建时间 */
+  createdAt: Record<string, unknown>;
+  /*更新时间 */
+  updatedAt: Record<string, unknown>;
+  /*0-未删除,大于0(时间戳)-已删除 */
+  isDeleted: number;
+  /*扩展数据 */
+  extra: string;
+  /*算法类型 0-单算法 1-组算法 */
+  type: number;
+}
+
+export interface saveDetectionGroupRequest {
+  cameraId?: number;
+  /*算法id */
+  algoId?: number;
+  /*是否为主相机 */
+  isMainCamera?: number;
+}
+
+export interface SelectOption {
+  code: number;
+  name: string;
+  isMainCamera: number;
+  algoName: string;
+  algoCode: string;
+  isActive: boolean;
+}
+
+export interface updateGroupStatusRequest {
+  cameraDetectionGroupId: number;
+  /*联合检测相机分组状态 */
+  status: number;
+}
+
+export enum IsMainCamera {
+  /*主相机 */
+  YES = 0,
+  /*副相机 */
+  NO = 1,
+}

+ 284 - 3
src/views/cameras/preview/components/CameraConfigGroup/CameraConfigGroup.vue

@@ -1,7 +1,288 @@
 <template>
-  <div> 组相机配置 </div>
+  <div class="cameraConfigGroup">
+     <el-card>
+      <template #header>
+        <el-button
+          type="primary"
+          @click="createGroupDialog = true"
+          style="margin-top: 24px; margin-bottom: 16px; width: 138px"
+        >
+          <img src="@/assets/images/create.png" style="margin-top: -1px; margin-right: 5px" />新建相机分组
+        </el-button>
+      </template>
+      <el-table
+        :data="cameraGroupList"
+        :span-method="objectSpanMethod"
+        height="calc(100vh - 380px)"
+        style="width: 100%; margin-top: 16px; --el-table-border-color: none"
+        v-loading="loading"
+      >
+        <el-table-column type="index" label="组序号"  width="70"/>
+        <el-table-column prop="cameraName" label="相机名称" align="center">
+          <template #default="scope">
+            <div class="cameraName-text" v-if="scope.row.isMainCamera === IsMainCamera.YES">
+              <el-icon><Star color="Gold" /></el-icon>
+              {{ scope.row.cameraName }}
+            </div>
+          </template>  
+        </el-table-column>
+        <el-table-column prop="cameraCode" label="设备ID"/>
+        <el-table-column prop="location" label="地点"/>
+        <el-table-column prop="algoName" label="算法">
+          <template #default="scope">
+            <div class="algoId-text" @click="handleView(scope.row)">
+              {{ scope.row.algoName }}
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" fixed="right" width="120px">
+          <template #default="scope">
+            <div class="operation">
+              <el-tooltip effect="light" content="关闭/开启" placement="bottom">
+                <el-switch 
+                  :model-value="scope.row.status" 
+                  :inactive-value="CameraGroupStatus.CLOSE"
+                  :active-value="CameraGroupStatus.OPEN"
+                  @update:model-value="(val) => handleSwitch(val, scope.row)"/>
+              </el-tooltip>
+              
+              <el-tooltip effect="light" content="删除" placement="bottom">
+                <img src="@/views/message/alarmMessages/img/delete.png" @click="handleDelete(scope.row)" />
+              </el-tooltip>
+            </div>
+          </template>
+        </el-table-column>
+
+        <template #empty>
+          <div class="emptyDiv">
+            <img src="@/assets/images/empty.png" class="emptyImg" />
+            <span class="emptySpan">暂无数据</span>
+          </div>
+        </template>
+      </el-table>
+      <section class="mt-4 flex justify-end">
+        <el-pagination
+          background
+          layout="total, sizes, prev, pager, next"
+          :page-sizes="[10, 30, 50]"
+          :total="total"
+          v-model:page-size="requestParams.pageSize"
+          v-model:current-page="requestParams.pageNumber"
+          @change="queryCameraGroupPage"
+        />
+      </section>
+    </el-card>
+
+    <!--删除弹窗 -->
+    <el-dialog v-model="deleteDialog" width="424px" top="20%" class="deleteDialog">
+      <template #header="">
+        <div class="deleteDialogHeader">
+          <img src="@/assets/images/deleteTip.png" class="deleteTip" />
+          <span class="titleSpan">请确认删除该相机分组吗?</span>
+        </div>
+      </template>
+      <span style="margin-left: 37px">删除之后,该组多相机检测将失效!</span>
+      <div class="dialogBottom">
+        <el-button class="dialogBtn" @click="deleteDialog = false">取消</el-button>
+        <el-button class="dialogBtn" type="primary" @click="confirmDelete">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 新增相机分组弹窗 -->
+    <el-dialog v-model="createGroupDialog" width="70%" top="15%" left="25%" class="deleteDialog" title="设置相机组">
+    <SettingCamera ref="settingCameraRef"/>
+      <div class="dialogBottom">
+        <el-button class="dialogBtn" @click="handleCancle">取消</el-button>
+        <el-button class="dialogBtn" type="primary" @click="handleCreateGroup">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
 </template>
 
-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
+import { Star } from '@element-plus/icons-vue';
+import useCameraGroupQuery from './hooks/useCameraGroupQuery';
+import { CameraGroupTableItem, CameraGroupStatus, IsMainCamera } from '@/types/camera/camera-preview';
+import { deleteDetectionGroup, updateGroupStatus, saveDetectionGroup } from '@/api/camera/camera-preview-group';
+import SettingCamera from './components/SettingCamera.vue'
+
+const router = useRouter();
+const { requestParams, total, queryCameraGroupPage, cameraGroupList, loading } = useCameraGroupQuery();
+const createGroupDialog = ref(false);
+const deleteDialog = ref(false);
+const cameraDetectionGroupId = ref();
+
+
+const objectSpanMethod = ({
+  row,
+  rowIndex,
+  columnIndex,
+}) => {
+  // 合并组序列和操作列
+  if (columnIndex === 0 || columnIndex === 5) {
+    const list = cameraGroupList.value;
+    const groupId = row.cameraDetectionGroupId;
+    const firstIndex = list.findIndex(item => item.cameraDetectionGroupId === groupId);
+    
+    if (rowIndex !== firstIndex) {
+      return { rowspan: 0, colspan: 0 };
+    }
+    
+    let rowCount = 0;
+    for (let i = firstIndex; i < list.length; i++) {
+      if (list[i].cameraDetectionGroupId === groupId) rowCount++;
+      else break;
+    }
+    
+    return {
+      rowspan: rowCount,
+      colspan: 1
+    };
+  }
+};
+
+const handleDelete = (row: CameraGroupTableItem) => {
+  if (row.status === CameraGroupStatus.OPEN) {
+    ElMessage({
+      message: '开启状态的分组不可删除',
+      type: 'warning',
+      plain: true,
+    });
+  } else {
+    deleteDialog.value = true;
+    cameraDetectionGroupId.value = row.cameraDetectionGroupId;
+  }
+}
+
+const handleSwitch = (newStatus: number, row: CameraGroupTableItem) => {
+  const data = {
+    cameraDetectionGroupId: row.cameraDetectionGroupId,
+    status: newStatus,
+  }
+  updateGroupStatus(data).then(res => {
+    ElMessage.success('操作成功')
+    queryCameraGroupPage();
+  })
+}
+
+const confirmDelete = () => {
+  deleteDetectionGroup([Number(cameraDetectionGroupId.value)]).then(() => {
+    ElMessage({
+      message: '删除成功',
+      type: 'success',
+      plain: true,
+    });
+    deleteDialog.value = false;
+    queryCameraGroupPage();
+  })
+  
+}
+
+const settingCameraRef = ref<InstanceType<typeof SettingCamera>>();
+const handleCreateGroup = () => {
+  const { valid, data } = settingCameraRef.value?.isValidate();
+ 
+  if(valid) {
+    // 执行提交逻辑
+    const saveData = data.map(item => {
+      return {
+        cameraId: Number(item.code),
+        algoId: Number(item.algoCode),
+        isMainCamera: item.isMainCamera,
+      }
+    })
+    
+    saveDetectionGroup(saveData).then(() => {
+      ElMessage({
+        message: '相机组添加成功',
+        type:'success',
+        plain: true,
+      });
+      createGroupDialog.value = false;
+      settingCameraRef.value?.clearForm();
+      queryCameraGroupPage();
+    })
+  }
+}
+
+const handleCancle = () => {
+  createGroupDialog.value = false
+  settingCameraRef.value?.clearForm();
+}
+
+const handleView = (row: CameraGroupTableItem) => {
+  router.push({
+    path: '/algorithm/config-multi',
+    query: {
+      groupId: row.cameraDetectionGroupId,
+    }
+  })
+}
+
+onMounted(() => {
+  queryCameraGroupPage();
+})
+</script>
+
+<style lang="scss" scoped>
+  .emptyDiv {
+    margin-top: 78px;
+    margin: auto;
+    width: 396px;
+    .emptyImg {
+      height: 257px;
+    }
+    .emptySpan {
+      font-family: PingFangSC, PingFang SC;
+      font-weight: 400;
+      font-size: 18px;
+      color: rgba(0, 0, 0, 0.45);
+      text-align: left;
+      font-style: normal;
+    }
+  }
+
+  .algoId-text {
+    cursor: pointer;
+    color: #409eff;
+  }
+
+    .deleteDialog {
+    .deleteDialogHeader {
+      display: flex;
+      .deleteTip {
+        height: 24px;
+        width: 24px;
+      }
+      
+      .titleSpan {
+        height: 24px;
+        font-size: 16px;
+        color: rgba(0, 0, 0, 0.88);
+        line-height: 24px;
+        text-align: center;
+        margin-left: 12px;
+      }
+    }
+    .dialogBottom {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 12px;
+    }
+  }
+
+  .operation {
+    display: flex;
+    align-items: center;
+
+    img {
+      margin-left: 20px;
+    }
+    
+  }
+
 
-<style lang="scss" scoped></style>
+</style>

+ 53 - 0
src/views/cameras/preview/components/CameraConfigGroup/components/CameraTreeList.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="camera-tree-list">
+    <div class="camera-tree-list__title">
+      相机列表
+    </div>
+    <div class="camera-tree-list__content">
+      <CameraTreeCom ref="cameraTreeRef" :tree-data="treeData" :is-show-checkbox="false" @node-click="handleNodeClick" />
+    </div>
+  </div>  
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';  
+import { useRoute } from 'vue-router';
+import CameraTreeCom from '@/modules/camera-tree/CameraTree.vue';
+import { CameraTree, CameraTreeNodeType, CameraQueryForm } from '@/api/camera/camera-preview';
+import { queryCameraTreeByGroupId } from '@/api/camera/camera-preview-group';
+  
+interface CameraTreeTempType extends CameraTree {
+  tempCode?: string;
+}
+
+const route = useRoute();
+const cameraTreeRef = ref<InstanceType<typeof CameraTreeCom>>();
+const treeData = ref<CameraTreeTempType[]>([]);
+const handleNodeClick = () => {
+
+}
+
+onMounted(() => {
+  if (!route.query.groupId) return;
+  queryCameraTreeByGroupId(Number(route.query.groupId)).then(res => {
+    treeData.value = res;
+  })
+})
+  
+</script>
+
+<style scoped lang="scss">
+  .camera-tree-list{
+    min-width: 270px;
+    max-width: 600px;
+    flex-shrink: 0;
+    border: 1px solid #f0f2f5;
+    margin: 5px;
+
+    .camera-tree-list__title{
+      background: #f0f2f5;
+      padding: 12px;
+      display: flex;
+    }
+  } 
+</style>

+ 442 - 0
src/views/cameras/preview/components/CameraConfigGroup/components/SettingCamera.vue

@@ -0,0 +1,442 @@
+<template>
+  <div class="setting-camera-page">
+    <div class="camera-item">
+      <p class="camera-header">
+        <el-input
+          placeholder="请输入相机名称或设备ID"
+          :prefix-icon="Search"
+          v-model="queryForm.queryString"
+          @keyup.enter="getCameraData"
+          clearable
+          @clear="getCameraData"
+        />
+      </p>
+      <div class="camera-content">
+        <el-scrollbar class="tree-scroll">
+          <CameraTreeCom
+            ref="treeRef"
+            :treeData="cameraTreeTemp"
+            :isShowCheckbox="true"
+            :enablePreview="true"
+            @node-click="handleNodeClick"
+            @check="handleTreeCheck"
+          />
+        </el-scrollbar>
+      </div>
+    </div>
+    <div class="camera-item">
+      <p class="camera-header"> 已选相机 </p>
+      <div class="camera-content">
+        <div class="select-item" v-for="item in selectedCameraList" :key="item.code">
+          <div class="select-botton">
+            <el-icon @click="handleSelectIcon(item)"
+              ><Star :color="item.isMainCamera === IsMainCamera.YES ? 'Gold' : 'gray'"
+            /></el-icon>
+          </div>
+          <div class="select-content">
+            <div class="content-main" @click="handleSelect(item)">
+              <p class="name" :class="item.isActive ? 'active' : ''">{{ item.name }}</p>
+              <p class="message">{{ item.algoName }}</p>
+            </div>
+            <el-icon class="delete-icon" @click="handleDelete(item)"><Close color="#fff" /></el-icon>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="camera-item">
+      <p class="camera-header"> 联合算法列表</p>
+      <div class="camera-content">
+        <div class="mb-2 ml-4">
+          <el-radio-group :model-value="selectedAlgo" @change="handleRiadoChange">
+            <el-radio :value="item.code" size="large" v-for="item in groupAlgoData" :key="item.id">
+              {{ item.name }}</el-radio
+            >
+          </el-radio-group>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import { Search, Star, Close } from '@element-plus/icons-vue';
+  import { ElMessage } from 'element-plus';
+  import { uid } from 'uid';
+  import { CameraTree, CameraTreeNodeType, CameraQueryForm } from '@/api/camera/camera-preview';
+  import { queryCameraTreeByCondition, getDetectionGroupLAlgoList } from '@/api/camera/camera-preview-group';
+  import { GropAlgoProps, SelectOption, IsMainCamera } from '@/types/camera/camera-preview';
+  import CameraTreeCom from '@/modules/camera-tree/CameraTree.vue';
+  import { IntegrationState } from '@/modules/camera-tree/types/constant';
+
+  interface CameraTreeTempType extends CameraTree {
+    tempCode?: string;
+  }
+
+  const queryForm = ref<CameraQueryForm>({
+    isEnableAlgo: false,
+    isEnableRender: false,
+    queryString: '',
+  });
+
+  const cameraTreeTemp = ref<CameraTreeTempType[]>([]); // 保存修改name之后的树
+  const selectedCameraList = ref<SelectOption[]>([]); // 保存选中的算法列表
+  const handleNodeClick = (e: CameraTreeTempType) => {
+    selectedAlgo.value = '';
+    if (e.nodeType === CameraTreeNodeType.camera) {
+      if (selectedCameraList.value.some((item) => item.code === e.id)) {
+        ElMessage({
+          message: '该相机已被选中',
+          type: 'warning',
+          plain: true,
+        });
+        return;
+      }
+
+      if (e.integrationState === IntegrationState.DISABLED) {
+        ElMessage({
+          message: '该相机未开启',
+          type: 'warning',
+          plain: true,
+        });
+        return;
+      }
+
+      selectedCameraList.value.push({
+        code: e.id,
+        name: e.name,
+        isMainCamera: selectedCameraList.value.length === 0 ? IsMainCamera.YES : IsMainCamera.NO,
+        algoName: '',
+        algoCode: '',
+        isActive: false,
+      });
+
+      // 获取当前节点在树中的完整节点对象
+      const treeNode = treeRef.value?.getCheckedNodes(e.tempCode);
+      // 勾选当前节点
+      treeRef.value?.setChecked(treeNode, true, false);
+      // 递归勾选所有父节点
+      const checkParentNodes = (node: Node) => {
+        if (node.parent) {
+          treeRef.value?.setChecked(node.parent, true, false);
+          checkParentNodes(node.parent);
+        }
+      };
+
+      if (treeNode.parent) {
+        checkParentNodes(treeNode.parent);
+      }
+    }
+  };
+
+  const handleTreeCheck = (node: CameraTreeTempType, checked: { checkedNodes: CameraTreeTempType[] }) => {
+    // 获取当前所有选中节点中的相机节点
+    const currentCameraNodes = checked.checkedNodes.filter(
+      (n) => n.nodeType === CameraTreeNodeType.camera && n.integrationState !== IntegrationState.DISABLED,
+    );
+
+    // 检查并过滤禁用节点
+    const disabledNodes = checked.checkedNodes.filter(
+      (n) => n.nodeType === CameraTreeNodeType.camera && n.integrationState === IntegrationState.DISABLED,
+    );
+    disabledNodes.forEach((n) => {
+      // ElMessage.warning(`${n.name} 已禁用,不可选中`);
+      treeRef.value?.setChecked(n.tempCode as string, false, true);
+    });
+
+    // 生成当前选中的code集合
+    const currentCodes = new Set(currentCameraNodes.map((n) => n.code));
+
+    // 移除已取消选中的项
+    selectedCameraList.value = selectedCameraList.value.filter((item: SelectOption) =>
+      currentCodes.has(item.code + ''),
+    );
+    // 添加新选中的项
+    currentCameraNodes.forEach((camera) => {
+      if (!selectedCameraList.value.some((item) => item.code === camera.id)) {
+        selectedCameraList.value.push({
+          code: camera.id,
+          name: camera.name.replace(/ \[\w+\] $/, ''),
+          isMainCamera: selectedCameraList.value.length === 0 ? IsMainCamera.YES : IsMainCamera.NO,
+          algoName: '',
+          algoCode: '',
+          isActive: false,
+        });
+      }
+    });
+  };
+
+  const getCameraData = async () => {
+    await queryCameraTreeByCondition(queryForm.value).then((res) => {
+      cameraTreeTemp.value = getCameraNameCode(res);
+    });
+  };
+
+  // 把树节点中所有 nodeType = camera 的 name 替换成 name + code
+  function getCameraNameCode(data) {
+    const cameraNameCode = data;
+    for (let i = 0; i < data.length; i++) {
+      const node = cameraNameCode[i];
+      node.tempCode = uid(); // 为相机树节点创建唯一code
+      if (node.nodeType === 'camera') {
+        node.name = node.name + ` [${node.code}] `;
+      }
+      if (node.children && node.children.length > 0) {
+        getCameraNameCode(node.children);
+      }
+    }
+    return cameraNameCode;
+  }
+
+  const groupAlgoData = ref<GropAlgoProps[]>([]);
+  const getGroupLAlgoList = () => {
+    getDetectionGroupLAlgoList().then((res) => {
+      groupAlgoData.value = res;
+    });
+  };
+
+  const handleSelect = (row: SelectOption) => {
+    selectedCameraList.value.map((item) => {
+      if (item.code === row.code) {
+        item.isActive = true;
+      } else {
+        item.isActive = false;
+      }
+    });
+    selectedAlgo.value = '';
+  };
+
+  const handleSelectIcon = (row: SelectOption) => {
+    selectedCameraList.value.map((item) => {
+      if (item.code === row.code) {
+        item.isMainCamera = item.isMainCamera === IsMainCamera.YES ? IsMainCamera.NO : IsMainCamera.YES;
+      } else {
+        item.isMainCamera = IsMainCamera.NO;
+      }
+    });
+  };
+
+  const treeRef = ref<InstanceType<typeof CameraTreeCom>>();
+  const handleDelete = (row: SelectOption) => {
+    // 从已选列表删除
+    selectedCameraList.value = selectedCameraList.value.filter((item) => item.code !== row.code);
+
+    // 同步取消树节点的选中状态
+    const findNode = (nodes: CameraTreeTempType[]): CameraTreeTempType | undefined => {
+      for (const node of nodes) {
+        if (node.id === row.code && node.nodeType === CameraTreeNodeType.camera) return node;
+        if (node.children) {
+          const found = findNode(node.children);
+          if (found) return found;
+        }
+      }
+    };
+
+    const targetNode = findNode(cameraTreeTemp.value);
+    if (targetNode?.tempCode) {
+      treeRef.value?.setChecked(targetNode.tempCode, false, false);
+    }
+  };
+
+  const selectedAlgo = ref();
+  const handleRiadoChange = (value) => {
+    const selectedCameraItem = selectedCameraList.value.filter((item) => item.isActive);
+    if (selectedCameraItem.length === 0) {
+      ElMessage({
+        message: '请先取消选中的相机',
+        type: 'warning',
+        plain: true,
+      });
+      return;
+    }
+    const selectedAlgoItem = groupAlgoData.value.find((item) => item.code === value);
+
+    selectedCameraItem.forEach((item) => {
+      item.algoName = selectedAlgoItem?.name as unknown as string;
+      item.algoCode = selectedAlgoItem?.code as unknown as string;
+    });
+
+    selectedAlgo.value = value;
+  };
+
+  const isValidate = () => {
+    // 校验至少选择两个相机
+    if (selectedCameraList.value.length < 2) {
+      ElMessage.error('请至少选择两个相机');
+      return { valid: false, message: '相机数量不足' };
+    }
+
+    // 校验所有选中相机都已配置算法
+    const unsetAlgoCameras = selectedCameraList.value.filter((item) => !item.algoCode || item.algoCode === '');
+
+    if (unsetAlgoCameras.length > 0) {
+      ElMessage.error('存在未选择算法的相机');
+      return { valid: false, message: '算法未配置完整' };
+    }
+
+    return { valid: true, data: selectedCameraList.value };
+  };
+
+  // 重置设置相机组
+  const clearForm = () => {
+    selectedCameraList.value = [];
+    selectedAlgo.value = '';
+    queryForm.value = {
+      isEnableAlgo: false,
+      isEnableRender: false,
+      queryString: '',
+    };
+    //  清除树组件的所有选中状态
+    if (treeRef.value) {
+      treeRef.value.setCheckedKeys([]);
+    }
+  };
+
+  // 暴露校验方法给父组件
+  defineExpose({
+    isValidate,
+    clearForm,
+  });
+
+  onMounted(() => {
+    getCameraData();
+    getGroupLAlgoList();
+  });
+</script>
+
+<style scoped lang="scss">
+  .setting-camera-page {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    gap: 20px;
+
+    .camera-item {
+      flex: 1;
+
+      &:first-child {
+        .camera-header {
+          padding-left: 0;
+        }
+      }
+
+      .camera-header {
+        width: 100%;
+        height: 40px;
+        border-radius: 2px;
+        font-size: 15px;
+        line-height: 40px;
+        color: #333;
+        cursor: pointer;
+        background-color: #dcdfe6;
+        padding-left: 20px;
+        display: flex;
+        align-items: center;
+
+        :deep(.el-input--default) {
+          width: 95%;
+          margin: 0 auto;
+        }
+
+        :deep(.el-input__wrapper) {
+          border-radius: 50px;
+        }
+      }
+
+      .camera-content {
+        width: 100%;
+        height: 400px;
+        background-color: #fff;
+        overflow-y: auto;
+        border: 1px solid #dcdfe6;
+        padding: 10px 20px;
+
+        .select-item {
+          display: flex;
+          align-self: start;
+          justify-content: center;
+          margin-bottom: 10px;
+
+          &:last-child {
+            margin-bottom: 0;
+          }
+
+          .select-botton {
+            cursor: pointer;
+          }
+
+          .select-content {
+            position: relative; // 新增
+            margin-left: 5px;
+            width: 100%;
+
+            .name {
+              color: #333;
+              padding-left: 5px;
+            }
+
+            .message {
+              color: #dcdfe6;
+              padding-left: 5px;
+            }
+
+            &:hover {
+              background-color: #409eff;
+              .name {
+                color: #fff;
+              }
+              .delete-icon {
+                display: block;
+              }
+            }
+
+            .delete-icon {
+              // 新增删除图标样式
+              display: none;
+              position: absolute;
+              right: 10px;
+              top: 50%;
+              transform: translateY(-50%);
+              width: 16px;
+              height: 16px;
+              cursor: pointer;
+            }
+
+            .content-main {
+              // 新增内容容器
+              flex: 1;
+              cursor: pointer;
+            }
+
+            .active {
+              color: #0052d9;
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .el-input__icon {
+    cursor: pointer;
+  }
+
+  .el-input__icon:hover {
+    color: #0052d9;
+  }
+
+  .cameraTreeCheckboxWrapper {
+    display: flex;
+    justify-content: space-between;
+
+    .el-checkbox {
+      margin-right: 0;
+    }
+  }
+
+  :deep(.el-radio-group) {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+</style>

+ 49 - 0
src/views/cameras/preview/components/CameraConfigGroup/hooks/useCameraGroupQuery.ts

@@ -0,0 +1,49 @@
+import { reactive, ref, shallowRef } from 'vue';
+import { cloneDeep } from 'lodash-es';
+import { DEFAULT_PAGE_SIZE } from '@/types/common/constants';
+import { CameraPreviewRequest, CameraGroupTableItem, CameraGroupItem } from '@/types/camera/camera-preview';
+import { queryDetectionGroupList } from '@/api/camera/camera-preview-group';
+
+const defaultLoginLogRequest: CameraPreviewRequest = {
+  pageNumber: 1,
+  pageSize: DEFAULT_PAGE_SIZE,
+};
+
+export default function useLoginLogRequest() {
+  const requestParams = reactive<CameraPreviewRequest>(cloneDeep(defaultLoginLogRequest));
+  const cameraGroupList = shallowRef<CameraGroupTableItem[]>([]);
+  const total = ref(0);
+  const loading = ref(false);
+
+  const queryCameraGroupPage = async () => {
+    try {
+      loading.value = true;
+     
+      const data = await queryDetectionGroupList(requestParams);
+      cameraGroupList.value = transformCameraGroupList(data.records);
+      total.value = data.totalRow;
+    } catch (error) {
+      console.log(error);
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  const transformCameraGroupList = (data: CameraGroupItem[]) => {
+    return data.flatMap(group => 
+      group.groupDetailList.map(detail => ({
+        cameraDetectionGroupId: group.cameraDetectionGroupId,
+        status: group.status,
+        ...detail
+      }))
+    );
+  }
+
+  return {
+    requestParams,
+    total,
+    loading,
+    cameraGroupList,
+    queryCameraGroupPage,
+  };
+}