Selaa lähdekoodia

Merge branch 'all-v4-chauncey' into 'all-v4'

相机云台功能

See merge request skyeye/skyeye_frontend/skyeye-admin!409
陈昶 1 vuosi sitten
vanhempi
commit
a7305ad801
31 muutettua tiedostoa jossa 752 lisäystä ja 119 poistoa
  1. 66 21
      src/api/camera/camera-preview.ts
  2. 11 0
      src/assets/icons/delete-preset-position.svg
  3. 14 0
      src/assets/icons/edit-preset-position-focus.svg
  4. 14 0
      src/assets/icons/edit-preset-position.svg
  5. 8 0
      src/assets/icons/fullscreen.svg
  6. 35 0
      src/assets/icons/preset-placeholder-img.svg
  7. 26 7
      src/components/LiveVideo/LiveVideo.vue
  8. 7 3
      src/components/Table/src/components/TableActionIcons.vue
  9. 6 1
      src/modules/algo-params-setting-base/components/CameraDirectionControl/CameraDirectionControl.vue
  10. 17 2
      src/modules/algo-params-setting-base/components/CameraLiveVideo/CameraLiveVideo.vue
  11. 4 1
      src/modules/algo-params-setting-base/components/CameraViewSetting/CameraViewScale.vue
  12. 2 1
      src/modules/algo-params-setting-base/components/CameraViewSetting/CameraViewSetting.vue
  13. 3 3
      src/modules/algo-params-setting-base/components/PresetSelect/PresetSelect.vue
  14. 1 1
      src/views/alarm-push/PushAlarm.vue
  15. 1 1
      src/views/cameras/nvrlist/NvrList.vue
  16. 27 8
      src/views/cameras/overview/CamerasOverview.vue
  17. 20 15
      src/views/cameras/overview/components/BatchEditCamera.vue
  18. 24 22
      src/views/cameras/overview/components/BatchImportCamera.vue
  19. 15 2
      src/views/cameras/overview/components/CameraAddPopover.vue
  20. 15 5
      src/views/cameras/overview/components/CameraEditNVRPopover.vue
  21. 15 5
      src/views/cameras/overview/components/CameraEditPopover.vue
  22. 15 2
      src/views/cameras/overview/components/CameraEditSRSPopover.vue
  23. 367 0
      src/views/cameras/overview/components/CameraOverviewPopover.vue
  24. 15 2
      src/views/cameras/overview/components/CameraSharePopover.vue
  25. 18 11
      src/views/cameras/overview/components/CameraSharedEdit.vue
  26. 1 1
      src/views/cameras/overview/components/SearchCamerasAction.vue
  27. 1 1
      src/views/cameras/overview/components/ShareCameraDetail.vue
  28. 1 1
      src/views/cameras/overview/components/SharedTable.vue
  29. 1 1
      src/views/message/persongroup/UserGroup.vue
  30. 1 1
      src/views/message/reportmessage/components/Form.vue
  31. 1 1
      src/views/system-config/business-scene/PageBusinessScene.vue

+ 66 - 21
src/api/camera/camera-preview.ts

@@ -326,12 +326,21 @@ export const editFenceApi = (data: UpdateFenceParams) => {
 interface CreatePresetParam {
   presetName: string;
   cameraId: number;
+  imageUrl: string;
 }
 
+/** 创建预置位 deprecated*/
+// export const createPresetApi = (data: CreatePresetParam) => {
+//   return http.request({
+//     url: `/admin/onvif/createPreset`,
+//     method: 'post',
+//     data,
+//   });
+// };
 /** 创建预置位 */
 export const createPresetApi = (data: CreatePresetParam) => {
   return http.request({
-    url: `/onvif/createPreset`,
+    url: `/admin/onvif/saveCameraPreset`,
     method: 'post',
     data,
   });
@@ -345,22 +354,29 @@ interface UpdatePresetNameParam {
 /** 修改预置位名称 */
 export const changePresetNameApi = (data: UpdatePresetNameParam) => {
   return http.request({
-    url: `/onvif/changePresetName`,
+    url: `/admin/onvif/changePresetName`,
     method: 'post',
     data,
   });
 };
 
+/** 删除预置位 deprecated*/
+// export const deletePresetApi = (data: { presetToken: string; cameraId: number }) => {
+//   return http.request({
+//     url: `/admin/onvif/deletePreset`,
+//     method: 'post',
+//     data,
+//   });
+// };
 /** 删除预置位 */
-export const deletePresetApi = (data: { presetToken: string; cameraId: number }) => {
+export const deletePresetApi = (presetToken: string, cameraId: string) => {
   return http.request({
-    url: `/onvif/deletePreset`,
-    method: 'post',
-    data,
+    url: `/admin/onvif/deleteCameraPreset`,
+    method: 'delete',
+    data: { presetToken, cameraId },
   });
 };
-
-interface PresetDetailItem {
+export interface PresetDetailItem {
   name: string;
   token: string;
   ptzposition: {
@@ -376,30 +392,59 @@ interface PresetDetailItem {
   };
 }
 
+export interface PresetListResp {
+  cameraId: number;
+  id: number;
+  imageUrl: string;
+  presetName: string;
+  presetToken: string;
+}
+
+/** 获取预置位列表  depracted*/
+// export const getPresetListApi = (cameraId: number) => {
+//   return http.request<PresetDetailItem[]>(
+//     {
+//       url: `/admin/onvif/getPresets`,
+//       // url: `/admin/algo/queryCameraPreset`,
+//       method: 'get',
+//       params: { cameraId },
+//     },
+//     {
+//       isShowErrorMessage: false,
+//     },
+//   );
+// };
 /** 获取预置位列表 */
 export const getPresetListApi = (cameraId: number) => {
-  return http.request<PresetDetailItem[]>(
-    {
-      url: `/onvif/getPresets`,
-      // url: `/admin/algo/queryCameraPreset`,
-      method: 'get',
-      params: { cameraId },
-    },
-    {
-      isShowErrorMessage: false,
-    },
-  );
+  return http.request<PresetListResp[]>({
+    url: `/admin/onvif/queryCameraPresetList`,
+    method: 'get',
+    params: { cameraId },
+  });
 };
 
 /** 跳转到对应的预置位 */
 export const goToPresetApi = (data: { presetToken: string; cameraId: number }) => {
   return http.request({
-    url: `/onvif/gotoPreset`,
+    url: `/admin/onvif/gotoPreset`,
     method: 'post',
     data,
   });
 };
 
+/** 上传预置位图片 */
+export const uploadPresetImageApi = (file: Blob, bizType: string) => {
+  const formData = new FormData();
+  formData.append('bizType', bizType);
+  formData.append('file', file);
+  return http.request({
+    url: `/admin/minio/uploadFile`,
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' },
+  });
+};
+
 export interface CameraAlgoPresetResp {
   algoId: string;
   algoCode: string;
@@ -428,7 +473,7 @@ interface CameraMoveParam {
 /** 移动相机 */
 export const cameraMoveApi = (data: CameraMoveParam) => {
   return http.request({
-    url: `/onvif/move`,
+    url: `/admin/onvif/move`,
     method: 'post',
     data,
   });

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 11 - 0
src/assets/icons/delete-preset-position.svg


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 14 - 0
src/assets/icons/edit-preset-position-focus.svg


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 14 - 0
src/assets/icons/edit-preset-position.svg


+ 8 - 0
src/assets/icons/fullscreen.svg

@@ -0,0 +1,8 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1745213618167"
+    class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3201" width="24"
+    height="24" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <path
+        d="M598.016 214.016l212.010667 0 0 212.010667-84.010667 0 0-128-128 0 0-84.010667zM726.016 726.016l0-128 84.010667 0 0 212.010667-212.010667 0 0-84.010667 128 0zM214.016 425.984l0-212.010667 212.010667 0 0 84.010667-128 0 0 128-84.010667 0zM297.984 598.016l0 128 128 0 0 84.010667-212.010667 0 0-212.010667 84.010667 0z"
+        fill="#000000" p-id="3202"></path>
+</svg>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 35 - 0
src/assets/icons/preset-placeholder-img.svg


+ 26 - 7
src/components/LiveVideo/LiveVideo.vue

@@ -9,20 +9,14 @@
 </template>
 
 <script setup lang="ts">
-  import { onMounted, onBeforeUnmount, watch, ref, computed } from 'vue';
+  import { onMounted, onBeforeUnmount, watch, ref } from 'vue';
   import mpegts from 'mpegts.js';
   import { useUserStore } from '@/store/modules/user';
   import { storeToRefs } from 'pinia';
-  import useAuthStore from '@/store/modules/useAuth';
 
   const userStore = useUserStore();
   const { token } = storeToRefs(userStore);
 
-  const authStore = useAuthStore();
-  const { checkAuthValid } = authStore;
-
-  const restartNum = ref(0);
-
   let isVideoLoadingFailed = ref(false);
   const props = defineProps<{
     url: string;
@@ -40,6 +34,26 @@
     emit('timeUpdate', event.target.currentTime);
   };
 
+  // 添加截图方法
+  const captureImage = (): string => {
+    if (!videoRef.value) {
+      return '';
+    }
+    
+    const video = videoRef.value as HTMLVideoElement;
+    const canvas = document.createElement('canvas');
+    canvas.width = video.videoWidth;
+    canvas.height = video.videoHeight;
+    
+    const ctx = canvas.getContext('2d');
+    if (!ctx) {
+      return '';
+    }
+    
+    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+    return canvas.toDataURL('image/jpeg', 1.0);
+  };
+
   const initPlay = () => {
     if (!props.url || !videoRef.value) {
       return;
@@ -144,6 +158,11 @@
     destroyPlayer();
     clearInterval(interval);
   });
+
+  // 暴露截图方法供父组件使用
+  defineExpose({
+    captureImage
+  });
 </script>
 
 <style scoped>

+ 7 - 3
src/components/Table/src/components/TableActionIcons.vue

@@ -4,10 +4,10 @@
       <div v-for="item in props.actionIcons" :key="item.label" @click="handleClick(item)"
         :class="[item.disabled ? 'table__action--disabled' : 'table__action--enabled']">
         <el-tooltip :content="item.label" effect="light">
-          <el-icon v-if="props.style === 'icon'" :color="props.color" :size="props.size" >
+          <el-icon v-if="props.iconStyle === 'icon'" :color="props.color" :size="props.size" >
             <component :is="item.icon" />
           </el-icon>
-          <img v-if="props.style === 'img' && item.ifShow !== false" :src="item.icon" :style="{ width: `${props.size}px` }" />
+          <img v-if="props.iconStyle === 'img' && item.ifShow !== false" :src="item.icon" :style="{ width: `${props.size}px` }" />
         </el-tooltip>
       </div>
     </el-space>
@@ -21,7 +21,11 @@ const props = defineProps<{
   space: number;
   size: number;
   color: string;
-  style: 'img' | 'icon';
+  /**
+   * @description style有歧义,需要修改
+   */
+  // style: 'img' | 'icon';
+  iconStyle: 'img' | 'icon';
   actionIcons: ActionItem[];
   class?: string
 }>();

+ 6 - 1
src/modules/algo-params-setting-base/components/CameraDirectionControl/CameraDirectionControl.vue

@@ -19,22 +19,27 @@
   const cameraDetailStore = useCameraDetailStore();
   const { cameraId } = storeToRefs(cameraDetailStore);
   const presetListStore = usePresetListStore();
+  const emits = defineEmits(['update:ControlPerspective']);
 
-  const STEP = 0.05;
+  const STEP = 0.025;
 
   const handleMoveTop = () => {
+    emits('update:ControlPerspective');
     cameraMoveApi({ cameraId: cameraId.value, zoom: 0, x: 0, y: STEP });
     presetListStore.currentPresetToken = '';
   };
   const handleMoveRight = () => {
+    emits('update:ControlPerspective');
     cameraMoveApi({ cameraId: cameraId.value, zoom: 0, x: STEP, y: 0 });
     presetListStore.currentPresetToken = '';
   };
   const handleMoveBottom = () => {
+    emits('update:ControlPerspective');
     cameraMoveApi({ cameraId: cameraId.value, zoom: 0, x: 0, y: -STEP });
     presetListStore.currentPresetToken = '';
   };
   const handleMoveLeft = () => {
+    emits('update:ControlPerspective');
     cameraMoveApi({ cameraId: cameraId.value, zoom: 0, x: -STEP, y: 0 });
     presetListStore.currentPresetToken = '';
   };

+ 17 - 2
src/modules/algo-params-setting-base/components/CameraLiveVideo/CameraLiveVideo.vue

@@ -1,16 +1,18 @@
 <template>
-  <LiveVideo :url="videoUrl" v-if="videoUrl" />
+  <LiveVideo :url="videoUrl" v-if="videoUrl" ref="liveVideoRef" />
   <div v-if="!videoUrl" class="noPushStreamIpTip">暂无相机视频流地址</div>
 </template>
 <script lang="ts" setup>
   // import bg from '@/assets/images/camera/video-live.png';
   import LiveVideo from '@/components/LiveVideo/LiveVideo.vue';
   import { storeToRefs } from 'pinia';
-  import { computed } from 'vue';
+  import { computed, ref } from 'vue';
   import useCameraDetailStore from '../../store/useCameraDetailStore';
   const cameraDetailStore = useCameraDetailStore();
   const skyeyeVideoPath = localStorage.getItem('skyeyeVideoPath');
   const { detail } = storeToRefs(cameraDetailStore);
+  
+  const liveVideoRef = ref<InstanceType<typeof LiveVideo> | null>(null);
 
   const videoUrl = computed(() => {
     if (detail.value?.render) {
@@ -26,6 +28,19 @@
       return detail?.value?.pushStreamDTO?.videoUrls?.pushstreamIp;
     }
   });
+  
+  // 暴露截图方法
+  const captureImage = (): string => {
+    if (!liveVideoRef.value) {
+      return '';
+    }
+    return liveVideoRef.value.captureImage();
+  };
+  
+  // 暴露方法供父组件使用
+  defineExpose({
+    captureImage
+  });
 </script>
 <style>
   .noPushStreamIpTip {

+ 4 - 1
src/modules/algo-params-setting-base/components/CameraViewSetting/CameraViewScale.vue

@@ -21,9 +21,12 @@
   const cameraDetailStore = useCameraDetailStore();
   const { cameraId } = storeToRefs(cameraDetailStore);
 
-  const zoomStep = 0.05;
+  const zoomStep = 0.025;
+
+  const emits = defineEmits(['update:ControlPerspective']);
 
   const handleEnlarge = () => {
+    emits('update:ControlPerspective');
     cameraMoveApi({ cameraId: cameraId.value, zoom: zoomStep, x: 0, y: 0 });
     // presetListStore.currentPresetToken = '';
   };

+ 2 - 1
src/modules/algo-params-setting-base/components/CameraViewSetting/CameraViewSetting.vue

@@ -31,10 +31,11 @@
       <div> <AlgoCanSelect :selected-ids="cameraAlgoIds" @select="handleApplyAlgo" /></div>
     </div>
 
+    <!-- 取消这个功能 全部移植到设备管理-相机设备-相机预览功能里 -->
     <div
       class="presetAddWrapper"
       :class="{ hidePresetControlCls: showFenceTool }"
-      v-if="!!cameraDetailStore.detail?.isPtz"
+      v-if="!!cameraDetailStore.detail?.isPtz && false"
     >
       <CameraViewScale />
       <CameraDirectionControl />

+ 3 - 3
src/modules/algo-params-setting-base/components/PresetSelect/PresetSelect.vue

@@ -12,11 +12,11 @@
         :disabled="Boolean(!cameraDetailStore.detail?.isPtz)"
         placeholder="请选择预置位"
       >
-        <ElOption v-for="item in presetOptions" :key="item.token" :label="item.name" :value="item.token">
-          <span style="float: left">{{ item.name }}</span>
+        <ElOption v-for="item in presetOptions" :key="item.presetToken" :label="item.presetToken" :value="item.presetToken">
+          <span style="float: left">{{ item.presetName }}</span>
           <span style="float: right; color: var(--el-text-color-secondary); font-size: 13px">
             <!-- 点击删除的时候,阻止选中菜单 -->
-            <el-icon @click.stop="handleDeletePreset(item.token)">
+            <el-icon @click.stop="handleDeletePreset(item.presetToken)" v-if="false">
               <CircleCloseFilled />
             </el-icon>
           </span>

+ 1 - 1
src/views/alarm-push/PushAlarm.vue

@@ -96,7 +96,7 @@
       return h(TableActionIcons as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {

+ 1 - 1
src/views/cameras/nvrlist/NvrList.vue

@@ -136,7 +136,7 @@ const actionColumn: BasicColumn = reactive({
     return h(TableActionIcons as any, {
       space: 20,
       color: '#629bf9',
-      style: 'img',
+      iconStyle: 'img',
       size: 16,
       actionIcons: [
         {

+ 27 - 8
src/views/cameras/overview/CamerasOverview.vue

@@ -131,6 +131,12 @@
     <EditNVRCamera class="add-popover" v-model="showEditNVRPopover" :edit-data="editCameraData" />
     <ShareCamera class="add-popover" v-model="addSharedPopover" :share-data="shareCameraData" />
     <EditSharedCamera class="add-popover" v-model="showSharedPopover" @update-unadd="updateUnaddAmount" />
+    <CameraOverviewPopover
+      v-if="showCameraOverviewPopover"
+      :dialog-visible="showCameraOverviewPopover"
+      :overview-camera-data="overviewCameraData!"
+      @update:dialogVisible="showCameraOverviewPopover = false"
+    />
   </div>
 </template>
 
@@ -148,6 +154,7 @@
   import EditSRSCamera from './components/CameraEditSRSPopover.vue';
   import EditNVRCamera from './components/CameraEditNVRPopover.vue';
   import EditSharedCamera from './components/CameraSharedEdit.vue';
+  import CameraOverviewPopover from './components/CameraOverviewPopover.vue';
   import emptyImg from '@/assets/images/table/table-empty.png';
   import { Plus, DocumentAdd, Edit, Tickets } from '@element-plus/icons-vue';
   import shareIcon from '@/assets/images/table/table-share.png';
@@ -158,9 +165,9 @@
   import { storeToRefs } from 'pinia';
   import { CameraDetailServer } from '@/types/camera/type';
   import { deleteCameraItem, deleteCameraItems } from '@/api/camera/camera-overview';
+  import { getCameraDeatilById } from '@/api/camera/camera-preview';
   import { ElMessage, ElMessageBox } from 'element-plus';
   import useCameraShare from './hooks/useCameraShare';
-  import router from '@/router';
   import { AddType } from '@/types/camera/constant';
   import axios, { AxiosRequestConfig } from 'axios';
   import { getHeaders } from '@/utils/http/axios';
@@ -168,6 +175,8 @@
   import { useSceneTemplateList } from './stores/useSceneTemplateList';
   import { useUserStore } from '@/store/modules/user';
   import { PERM_DEVICE } from '@/types/permission/constants';
+  import useCameraDetailStore from '@/modules/algo-params-setting-base/store/useCameraDetailStore';
+  const cameraDetailStore = useCameraDetailStore();
 
   const userStore = useUserStore();
   const hasCameraAddPermission = () => {
@@ -190,6 +199,7 @@
 
   const useShare = useCameraShare();
   const { totalRow, queryToTenantId, isAddState, conditionSearch } = useShare;
+  const showCameraOverviewPopover = ref(false);
 
   onMounted(() => {
     isAddState.value = false;
@@ -215,6 +225,7 @@
   const showEditSRSPopover = ref(false);
   const showEditNVRPopover = ref(false);
   const editCameraData = ref<CameraDetailServer>();
+  const overviewCameraData = ref<CameraDetailServer>();
   const shareCameraData = ref<CameraDetailServer | null>();
   // 多选操作
   const showActionBar = ref(false);
@@ -241,18 +252,14 @@
       return h(TableActionIcons as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {
             label: '分享',
             icon: shareIcon,
             onClick: handleShare.bind(null, record.row),
-          },
-          {
-            label: '预览',
-            icon: previewIcon,
-            onClick: handlePreview.bind(null, record.row),
+            ifShow: false,
           },
           {
             label: '编辑',
@@ -260,6 +267,12 @@
             onClick: handleEdit.bind(null, record.row),
             auth: hasCameraEditPermission,
           },
+          {
+            label: '预览',
+            icon: previewIcon,
+            onClick: handlePreview.bind(null, record.row),
+            disabled: record.row.networkingState !== 0 || record.row.integrationState !== 0,
+          },
           {
             label: '删除',
             icon: deleteIcon,
@@ -395,7 +408,13 @@
   };
 
   const handlePreview = (_row) => {
-    router.push(`/algorithm/config?cameraId=${_row.id}`);
+    console.log('handlePreview', _row);
+    showCameraOverviewPopover.value = true;
+    overviewCameraData.value = _row;
+    cameraDetailStore.cameraId = _row.id;
+    getCameraDeatilById(_row.id).then((res) => {
+      cameraDetailStore.setDetail(res);
+    });
   };
 
   const handleDelete = (row) => {

+ 20 - 15
src/views/cameras/overview/components/BatchEditCamera.vue

@@ -1,6 +1,7 @@
 <template>
   <div>
-    <el-card v-if="cardVisible">
+    <div class="overlay" v-if="cardVisible"></div>
+    <el-card v-if="cardVisible" class="pop-card">
       <template #header>
         <div class="flex justify-between items-center pop-head">
           <div style="font-size: 16px; font-weight: 600">批量修改</div>
@@ -49,9 +50,7 @@
           </div>
         </el-upload>
         <div style="margin-top: 72px; margin-left: 380px; display: flex">
-          <el-button type="primary" @click="handleImport" :disabled="isImportEnable"
-            >导入</el-button
-          >
+          <el-button type="primary" @click="handleImport" :disabled="isImportEnable">导入</el-button>
         </div>
       </div>
     </el-card>
@@ -147,15 +146,9 @@
       if (errDetail.value.length > 0) {
         errDetail.value.forEach((item, index) => {
           if (item.indexOf('【修改失败】') >= 0) {
-            errDetail.value[index] = item.replace(
-              '【修改失败】',
-              '<span style="color: #ff4d4f">【修改失败】</span>',
-            );
+            errDetail.value[index] = item.replace('【修改失败】', '<span style="color: #ff4d4f">【修改失败】</span>');
           } else if (item.indexOf('【修改成功】') >= 0) {
-            errDetail.value[index] = item.replace(
-              '【修改成功】',
-              '<span style="color: #52c41a">【修改成功】</span>',
-            );
+            errDetail.value[index] = item.replace('【修改成功】', '<span style="color: #52c41a">【修改成功】</span>');
           }
         });
       }
@@ -202,9 +195,21 @@
 </script>
 
 <style scoped>
-  .upload-content {
-    margin-left: 90px;
-    margin-top: 36px;
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
+  .pop-card {
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   :deep(.el-dialog) {

+ 24 - 22
src/views/cameras/overview/components/BatchImportCamera.vue

@@ -1,6 +1,7 @@
 <template>
   <div>
-    <el-card v-if="cardVisible">
+    <div class="overlay" v-if="cardVisible"></div>
+    <el-card v-if="cardVisible" class="pop-card">
       <template #header>
         <div class="flex justify-between items-center pop-head">
           <div style="font-size: 16px; font-weight: 600">批量导入</div>
@@ -39,9 +40,7 @@
             <Document />
           </el-icon>
           <div class="el-upload__text">
-            <div style="font-size: 12px; color: red; margin-bottom: 5px"
-              >请下载模板并按要求填写后上传</div
-            >
+            <div style="font-size: 12px; color: red; margin-bottom: 5px">请下载模板并按要求填写后上传</div>
             <div style="font-size: 16px">点击或将文件拖拽到这里上传</div>
             <div style="font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 5px"
               >文件支持.xlsx .xls格式,仅支持上传一个文件</div
@@ -60,9 +59,7 @@
             >
           </el-tooltip>
           <el-button @click="handleDownloadTemplate">下载模板</el-button>
-          <el-button type="primary" @click="handleImport" :disabled="isImportEnable"
-            >导入</el-button
-          >
+          <el-button type="primary" @click="handleImport" :disabled="isImportEnable">导入</el-button>
         </div>
       </div>
     </el-card>
@@ -85,8 +82,7 @@
         <div class="header-text">添加提示</div>
       </template>
       <div class="sum-count">
-        成功上传 <span class="succ-sum">{{ sucCount }}</span> 条, 失败
-        <span class="err-sum">{{ errCount }}</span> 条
+        成功上传 <span class="succ-sum">{{ sucCount }}</span> 条, 失败 <span class="err-sum">{{ errCount }}</span> 条
       </div>
       <div class="err-info">
         <ul v-for="(item, index) in errDetail" :key="index">
@@ -133,11 +129,7 @@
         headers: getHeaders(),
         responseType: 'blob',
       };
-      const response = await axios.post(
-        urlPrefix + '/admin/cameraConfig/exportSceneCodeInfo',
-        null,
-        config,
-      );
+      const response = await axios.post(urlPrefix + '/admin/cameraConfig/exportSceneCodeInfo', null, config);
       const blob = new Blob([response.data], {
         type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
       });
@@ -212,15 +204,9 @@
       if (errDetail.value.length > 0) {
         errDetail.value.forEach((item, index) => {
           if (item.indexOf('【添加失败】') >= 0) {
-            errDetail.value[index] = item.replace(
-              '【添加失败】',
-              '<span style="color: #ff4d4f">【添加失败】</span>',
-            );
+            errDetail.value[index] = item.replace('【添加失败】', '<span style="color: #ff4d4f">【添加失败】</span>');
           } else if (item.indexOf('【添加成功】') >= 0) {
-            errDetail.value[index] = item.replace(
-              '【添加成功】',
-              '<span style="color: #52c41a">【添加成功】</span>',
-            );
+            errDetail.value[index] = item.replace('【添加成功】', '<span style="color: #52c41a">【添加成功】</span>');
           }
         });
       }
@@ -267,6 +253,22 @@
 </script>
 
 <style scoped>
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
+  .pop-card {
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
+  }
   .upload-content {
     margin-left: 90px;
     margin-top: 36px;

+ 15 - 2
src/views/cameras/overview/components/CameraAddPopover.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -74,9 +75,21 @@
 </script>
 
 <style scoped lang="scss">
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
   .pop-card {
-    position: relative;
-    margin-left: 21px !important;
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   .pop-head {

+ 15 - 5
src/views/cameras/overview/components/CameraEditNVRPopover.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -74,12 +75,21 @@
 </script>
 
 <style scoped lang="scss">
-  .pop-card {
-    position: absolute;
-    width: 100%;
-    height: 100%;
-    left: 0;
+  .overlay {
+    position: fixed;
     top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
+  .pop-card {
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   .pop-head {

+ 15 - 5
src/views/cameras/overview/components/CameraEditPopover.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -77,12 +78,21 @@
 </script>
 
 <style scoped lang="scss">
-  .pop-card {
-    position: absolute;
-    width: 100%;
-    height: 100%;
-    left: 0;
+  .overlay {
+    position: fixed;
     top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
+  .pop-card {
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   .pop-head {

+ 15 - 2
src/views/cameras/overview/components/CameraEditSRSPopover.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -71,9 +72,21 @@
 </script>
 
 <style scoped lang="scss">
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
   .pop-card {
-    position: relative;
-    margin-left: 21px !important;
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   .pop-head {

+ 367 - 0
src/views/cameras/overview/components/CameraOverviewPopover.vue

@@ -0,0 +1,367 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    width="719"
+    :title="`预览-${overviewCameraData.name}`"
+    center
+    align-center
+    class="camera-overview-popover--custom"
+    :close-on-click-modal="false"
+    @close="emits('update:dialogVisible', false)"
+  >
+    <div class="camera-overview-popover--custom__content">
+      <main class="main">
+        <div class="cameraVideo">
+          <CameraLiveVideo ref="cameraLiveVideoRef" />
+        </div>
+        <div class="presetAddWrapper" v-if="!!overviewCameraData.isPtz">
+          <CameraViewScale @update:ControlPerspective="activePresetToken = ''" />
+          <CameraDirectionControl @update:ControlPerspective="activePresetToken = ''" />
+          <ElButton type="primary" style="margin-top: 20px; width: 100px" @click="handleAddPreset" v-show="displayPresetList.length < 10">添加预置位</ElButton>
+        </div>
+      </main>
+      <footer class="footer" v-if="!!overviewCameraData.isPtz && presetList.length > 0">
+        <div class="footer-header">
+          <div class="edit-preset-position-icon-wrapper" @click="toggleEditMode">
+            <img :src="isEditMode ? EditPresetPositionFocusIcon : EditPresetPositionIcon" alt="编辑预置位" />
+            <span v-show="isEditMode">完成编辑</span>
+          </div>
+          <div class="pagination-control" v-if="displayPresetList.length > 0">
+            <el-button type="text" :disabled="currentPage === 1" @click="prevPage" :icon="ArrowLeft" />
+            <span>{{ currentPage }}/{{ totalPages }}</span>
+            <el-button type="text" :disabled="currentPage === totalPages" @click="nextPage" :icon="ArrowRight" />
+          </div>
+        </div>
+        <div class="preset-position-list">
+          <div
+            class="preset-position-item"
+            v-for="item in currentPageItems"
+            :key="item.presetToken"
+            :class="{ 'active-preset': activePresetToken === item.presetToken }"
+          >
+            <img
+              :src="item.imageUrl || PresetPositionItem"
+              alt="预置位"
+              style="cursor: pointer"
+              @click="handleGoToPreset(item)"
+            />
+            <img
+              v-if="isEditMode"
+              :src="DeletePresetPositionIcon"
+              alt="删除预置位"
+              class="delete-preset-position-icon"
+              @click="handleDeletePreset(item)"
+            />
+            <span class="preset-position-name">{{ item.presetName }}</span>
+          </div>
+        </div>
+      </footer>
+    </div>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+  import { ref, watch, computed, onMounted } from 'vue';
+  import { CameraDetailServer } from '@/types/camera/type';
+  import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
+  import EditPresetPositionIcon from '@/assets/icons/edit-preset-position.svg';
+  import DeletePresetPositionIcon from '@/assets/icons/delete-preset-position.svg';
+  import PresetPositionItem from '@/assets/icons/preset-placeholder-img.svg';
+  import EditPresetPositionFocusIcon from '@/assets/icons/edit-preset-position-focus.svg';
+  import { dataURLtoBlob } from '@/utils/file/base64Conver';
+  import {
+    getPresetListApi,
+    PresetListResp,
+    deletePresetApi,
+    createPresetApi,
+    goToPresetApi,
+    uploadPresetImageApi,
+  } from '@/api/camera/camera-preview';
+  import CameraLiveVideo from '@/modules/algo-params-setting-base/components/CameraLiveVideo/CameraLiveVideo.vue';
+  import CameraViewScale from '@/modules/algo-params-setting-base/components/CameraViewSetting/CameraViewScale.vue';
+  import CameraDirectionControl from '@/modules/algo-params-setting-base/components/CameraDirectionControl/CameraDirectionControl.vue';
+  import { ElMessage, ElMessageBox } from 'element-plus';
+
+  const emits = defineEmits(['update:dialogVisible']);
+  const dialogVisible = ref(false);
+  const presetList = ref<PresetListResp[]>([]);
+  const displayPresetList = ref<PresetListResp[]>([]);
+  const isEditMode = ref(false);
+  const activePresetToken = ref<string>(''); // 当前激活的预置位token
+  const cameraLiveVideoRef = ref<InstanceType<typeof CameraLiveVideo> | null>(null); // 添加对CameraLiveVideo的引用并指定正确类型
+
+  const props = defineProps<{
+    dialogVisible: boolean;
+    overviewCameraData: CameraDetailServer;
+  }>();
+  const currentPage = ref(1);
+  const pageSize = 5;
+  const presetPositionCount = computed(() => displayPresetList.value.length); // 总预置位数量
+
+  const totalPages = computed(() => Math.ceil(presetPositionCount.value / pageSize));
+
+  const currentPageItems = computed(() => {
+    const startIdx = (currentPage.value - 1) * pageSize;
+    const endIdx = Math.min(startIdx + pageSize, presetPositionCount.value);
+    return displayPresetList.value.slice(startIdx, endIdx);
+  });
+
+  const prevPage = () => {
+    if (currentPage.value > 1) {
+      currentPage.value--;
+    }
+  };
+
+  const nextPage = () => {
+    if (currentPage.value < totalPages.value) {
+      currentPage.value++;
+    }
+  };
+
+  const toggleEditMode = () => {
+    isEditMode.value = !isEditMode.value;
+  };
+
+  const handleGoToPreset = (item: PresetListResp) => {
+    const cameraId = props.overviewCameraData.id;
+    if (!cameraId) return;
+
+    // 设置当前激活的预置位
+    activePresetToken.value = item.presetToken;
+
+    // 调用前往预置位的API
+    goToPresetApi({ presetToken: item.presetToken, cameraId });
+  };
+
+  const handleDeletePreset = (item: PresetListResp) => {
+    const cameraId = props.overviewCameraData.id;
+    if (!cameraId) return;
+    const index = displayPresetList.value.findIndex((preset) => preset.presetToken === item.presetToken);
+    if (index !== -1) {
+      ElMessageBox.confirm(
+        '该预置位可能存在关联的电子围栏。删除该预置位将会删除对应的电子围栏信息,请确认是否删除?',
+        '删除确认',
+        {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+        },
+      ).then(async () => {
+        await deletePresetApi(item.presetToken, String(cameraId));
+        ElMessage.success('删除成功');
+        await getPresetList();
+        const currentPageStartIdx = (currentPage.value - 1) * pageSize;
+        if (currentPageStartIdx >= displayPresetList.value.length && currentPage.value > 1) {
+          currentPage.value--;
+        }
+      });
+    }
+  };
+
+  const handleAddPreset = () => {
+    ElMessageBox.prompt('', '添加预置位', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      inputPlaceholder: '请输入预置位名称',
+      inputValidator: (value) => {
+        if (!value) {
+          return '预置位名称不能为空';
+        }
+        const isExist = presetList.value.find((item) => item.presetName === value);
+        if (isExist) {
+          return '预置位名称已存在';
+        }
+        return true;
+      },
+    }).then(async ({ value }) => {
+      const cameraId = props.overviewCameraData.id;
+      if (!cameraId) return;
+
+      let imageBase64 = '';
+      if (cameraLiveVideoRef.value) {
+        imageBase64 = cameraLiveVideoRef.value.captureImage();
+      }
+
+      // 未来可以在这里把imageBase64传给后端
+      const blob = dataURLtoBlob(imageBase64);
+      const url = await uploadPresetImageApi(blob, 'CAMERA_IMAGE');
+      if (!url) {
+        ElMessage.error('上传预置位图片失败');
+        return;
+      }
+      const res = await createPresetApi({ presetName: value, cameraId, imageUrl: url.url });
+      if (res) {
+        ElMessage.success('添加预置位成功');
+        await getPresetList();
+      }
+    });
+  };
+
+  const getPresetList = async () => {
+    const cameraId = props.overviewCameraData.id;
+    if (!cameraId) return;
+    presetList.value = await getPresetListApi(cameraId);
+    displayPresetList.value = [...presetList.value];
+  };
+
+  onMounted(async () => {
+    await getPresetList();
+  });
+
+  watch(
+    () => props.dialogVisible,
+    (newVal) => {
+      dialogVisible.value = newVal;
+      if (newVal) {
+        // 每次对话框打开时,重置编辑状态
+        isEditMode.value = false;
+      }
+    },
+    { immediate: true },
+  );
+</script>
+
+<style lang="scss">
+  .camera-overview-popover--custom {
+    padding: 20px 24px;
+    border-radius: 8px;
+    box-shadow: 0px 9px 28px 8px rgba(0, 0, 0, 0.05), 0px 6px 16px 0px rgba(0, 0, 0, 0.08),
+      0px 3px 6px -4px rgba(0, 0, 0, 0.12);
+    .el-dialog__header {
+      text-align: left;
+      color: rgba(0, 0, 0, 0.88);
+      font-weight: bold;
+      font-size: 16px;
+    }
+    &__content {
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      width: 100%;
+      height: 100%;
+
+      .main {
+        position: relative;
+        flex-shrink: 0;
+        flex-grow: 0;
+        width: 100%;
+        height: 377px;
+      }
+      .footer {
+        display: flex;
+        flex-direction: column;
+        gap: 9px;
+
+        .footer-header {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          cursor: pointer;
+          .edit-preset-position-icon-wrapper {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            span {
+              font-size: 12px;
+              color: #1777ff;
+              font-weight: 500;
+            }
+          }
+
+          .pagination-control {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+            font-size: 14px;
+            color: rgba(0, 0, 0, 0.88);
+          }
+        }
+
+        .preset-position-list {
+          display: flex;
+          width: 100%;
+          gap: 12px;
+
+          .preset-position-item {
+            text-align: center;
+            width: 120px;
+            position: relative;
+            transition: all 0.3s ease;
+
+            &.active-preset {
+              transform: scale(1.05);
+              box-shadow: 0 0 8px 2px rgba(24, 144, 255, 0.6);
+              border-radius: 4px;
+
+              &::after {
+                content: '';
+                position: absolute;
+                top: 0;
+                left: 0;
+                width: 100%;
+                height: 100%;
+                border: 2px solid #1890ff;
+                border-radius: 4px;
+                box-sizing: border-box;
+                pointer-events: none;
+              }
+
+              .preset-position-name {
+                background: rgba(24, 144, 255, 0.8);
+              }
+            }
+
+            .delete-preset-position-icon {
+              position: absolute;
+              top: -4px;
+              right: -4px;
+              width: 16px;
+              height: 16px;
+              cursor: pointer;
+              transition: all 0.3s ease;
+              &:hover {
+                scale: 1.2;
+              }
+            }
+
+            img {
+              width: 120px;
+              height: 68px;
+              object-fit: cover;
+            }
+
+            .preset-position-name {
+              color: #fff;
+              font-size: 12px;
+              background: rgba(0, 0, 0, 0.5);
+              padding: 2px 4px;
+              border-radius: 2px;
+              transition: all 0.3s ease;
+            }
+          }
+        }
+      }
+      .cameraVideo {
+        position: absolute;
+        top: 0;
+        left: 0;
+        z-index: 8;
+        background: #ccc;
+        width: 100%;
+        height: 100%;
+      }
+      .presetAddWrapper {
+        position: absolute;
+        bottom: -50px;
+        right: -30px;
+        flex-direction: column;
+        display: flex;
+        align-items: center;
+        z-index: 10;
+        transform: scale(0.6);
+        .el-button {
+          transform: scale(1.5);
+        }
+      }
+    }
+  }
+</style>

+ 15 - 2
src/views/cameras/overview/components/CameraSharePopover.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -32,9 +33,21 @@
 </script>
 
 <style scoped lang="scss">
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
   .pop-card {
-    position: relative;
-    margin-left: 21px !important;
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    z-index: 1000;
   }
 
   .pop-head {

+ 18 - 11
src/views/cameras/overview/components/CameraSharedEdit.vue

@@ -1,4 +1,5 @@
 <template>
+  <div class="overlay" v-if="props.modelValue"></div>
   <el-card v-if="props.modelValue" class="pop-card">
     <template #header>
       <div class="flex justify-between items-center pop-head">
@@ -43,10 +44,24 @@
   };
 </script>
 
-<style scoped lang="scss">
+<style lang="scss" scoped>
+  .overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background-color: rgba(0, 0, 0, 0.5);
+    z-index: 999;
+  }
   .pop-card {
-    position: relative;
-    margin-left: 21px !important;
+    position: fixed;
+    top: 50%;
+    left: 55%;
+    transform: translate(-50%, -50%);
+    width: calc(100% - 300px);
+    height: 622px;
+    z-index: 1000;
   }
 
   .pop-head {
@@ -85,14 +100,6 @@
     }
   }
 
-  .pop-content {
-    height: 480px;
-    display: flex;
-    justify-content: center;
-    padding: 35px;
-    overflow: auto;
-  }
-
   :deep(.el-card__header) {
     padding: 0;
   }

+ 1 - 1
src/views/cameras/overview/components/SearchCamerasAction.vue

@@ -30,7 +30,7 @@
     space: number;
     size: number;
     color: string;
-    style: 'img' | 'icon';
+    iconStyle: 'img' | 'icon';
     actionIcons: ActionItem[];
   };
 

+ 1 - 1
src/views/cameras/overview/components/ShareCameraDetail.vue

@@ -165,7 +165,7 @@
       return h(TableActionIcons as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {

+ 1 - 1
src/views/cameras/overview/components/SharedTable.vue

@@ -134,7 +134,7 @@
       return h(TableActionIconsWords as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {

+ 1 - 1
src/views/message/persongroup/UserGroup.vue

@@ -119,7 +119,7 @@
       return h(TableActionIcons as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {

+ 1 - 1
src/views/message/reportmessage/components/Form.vue

@@ -69,7 +69,7 @@
       return h(TableActionIcons as any, {
         space: 20,
         color: '#629bf9',
-        style: 'img',
+        iconStyle: 'img',
         size: 16,
         actionIcons: [
           {

+ 1 - 1
src/views/system-config/business-scene/PageBusinessScene.vue

@@ -110,7 +110,7 @@ const actionColumn: BasicColumn = reactive({
     render(record) {
         return h(TableActionIcons as any, {
             space: 20,
-            style: 'img',
+            iconStyle: 'img',
             size: 16,
             actionIcons: [
                 {