Procházet zdrojové kódy

九宫格 侧边栏完成

chauncey před 1 rokem
rodič
revize
6de64d7461

+ 27 - 0
mock/camera-list/index.ts

@@ -0,0 +1,27 @@
+import { resultSuccess } from '../_util';
+
+const cameraList = [
+  {
+    id: 1,
+    name: '一级危险点',
+    children: [
+      { id: 1, name: '子项一' },
+      { id: 2, name: '子项二' },
+      { id: 3, name: '子项三' },
+    ],
+  },
+  { id: 2, name: '二级危险点' },
+  { id: 3, name: '三级危险点', children: [{ id: 1, name: '子项三' }] },
+  { id: 4, name: '四级危险点' },
+];
+
+export default [
+  {
+    url: '/eye_api_bak/api/admin/camera/getCameraList',
+    timeout: 1000,
+    method: 'get',
+    response: () => {
+      return resultSuccess(cameraList);
+    },
+  },
+];

+ 1 - 1
mock/login/routers.ts

@@ -24,7 +24,7 @@ const list = [
         redirect: '',
       },
       {
-        component: '/todo/todo',
+        component: '/disaster/monitor/PageMonitor',
         id: 1026,
         meta: {
           activeMenu: null,

+ 16 - 0
src/api/monitor/index.ts

@@ -0,0 +1,16 @@
+import { http } from '@/utils/http/axios';
+import type { CameraListResponse } from '@/types/monitor';
+/**
+ * 获取摄像头列表
+ */
+export function getCameraList() {
+  return http.request<CameraListResponse[]>(
+    {
+      url: 'admin/camera/getCameraList',
+      method: 'get',
+    },
+    {
+      ignoreTargetTenantId: true,
+    },
+  );
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 24 - 0
src/assets/svg/placeholder-add-item.svg


+ 115 - 0
src/components/AddItem.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="add-item" :class="{ active }">
+    <div class="add-item__prefix">
+      <slot name="prefix"></slot>
+    </div>
+    <img :src="icon || PlaeceHolderAddIcon" alt="" />
+    <div v-if="isEditing">
+      <input
+        ref="inputRef"
+        v-model="editingContent"
+        @blur="saveEdit"
+        @keyup.enter="saveEdit"
+        @keyup.esc="cancelEdit"
+        class="add-item__input"
+      />
+    </div>
+    <span v-else @dblclick="icon ? startEdit() : null" class="add-item__content">
+      {{ content }}
+    </span>
+    <div class="add-item__suffix">
+      <slot name="suffix"></slot>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { ref } from 'vue';
+  import PlaeceHolderAddIcon from 'assets/svg/placeholder-add-item.svg';
+
+  const props = defineProps<{
+    icon?: string;
+    active?: boolean;
+    content: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:content', value: string): void;
+  }>();
+
+  const isEditing = ref(false);
+  const editingContent = ref(props.content);
+  const inputRef = ref<HTMLInputElement | null>(null);
+
+  function startEdit() {
+    if (!props.icon) return;
+
+    isEditing.value = true;
+    editingContent.value = props.content;
+    setTimeout(() => {
+      inputRef.value?.focus();
+    });
+  }
+
+  function saveEdit() {
+    if (editingContent.value.trim() !== '') {
+      emit('update:content', editingContent.value);
+    } else {
+      editingContent.value = props.content;
+    }
+    isEditing.value = false;
+  }
+
+  function cancelEdit() {
+    editingContent.value = props.content;
+    isEditing.value = false;
+  }
+</script>
+
+<style lang="scss" scoped>
+  .add-item {
+    display: flex;
+    align-items: center;
+    gap: 12cpx;
+    width: 100%;
+    height: 24cpx;
+    font-size: 14cpx;
+    color: $text-color;
+
+    
+    img {
+      height: 100%;
+    }
+    
+    &.active {
+      color: $white-color;
+    }
+    
+    &__content {
+      flex-grow: 1;
+    }
+    
+    &__prefix,
+    &__suffix {
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+    }
+    
+    &__suffix {
+      margin-left: auto;
+    }
+    
+    &__input {
+      width: 100%;
+      height: 100%;
+      font-size: 14cpx;
+      border: 1px solid $text-color;
+      border-radius: 4px;
+      color: $text-color;
+      padding: 2cpx 10cpx;
+      outline: none;
+      border: none;
+    }
+  }
+</style>

+ 5 - 0
src/types/monitor/index.ts

@@ -0,0 +1,5 @@
+export interface CameraListResponse {
+  id: number;
+  name: string;
+  children: CameraListResponse[];
+}

+ 18 - 0
src/views/disaster/monitor/PageMonitor.vue

@@ -0,0 +1,18 @@
+<template>
+  <div class="monitor-container">
+    <Menu />
+    <div class="video-layout"></div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import Menu from './src/components/Menu.vue';
+</script>
+
+<style lang="scss" scoped>
+  .monitor-container {
+    width: 100%;
+    height: 100%;
+    padding: 14cpx 10cpx;
+  }
+</style>

+ 453 - 0
src/views/disaster/monitor/src/components/Menu.vue

@@ -0,0 +1,453 @@
+<template>
+  <div class="menu-wrapper">
+    <div class="menu-container" :class="{ collapsed: isCollapse }">
+      <header class="menu-header" :class="{ collapsed: isCollapse }" />
+      <div class="custom-menu" :class="{ collapsed: isCollapse }">
+        <div class="add-group">
+          <AddItem content="新建分组" style="cursor: pointer" />
+        </div>
+        <ul class="menu-list">
+          <!-- 父菜单项 -->
+          <template v-for="menu in cameraList" :key="menu.id">
+            <!-- 父级菜单 -->
+            <li
+              class="menu-item parent"
+              draggable="true"
+              @dragstart="dragStart($event, menu.id, null)"
+              @dragover.prevent
+              @drop="onDrop($event, menu.id, null)"
+            >
+              <div class="menu-content">
+                <AddItem :content="menu.name" :icon="ParentNotActiveIcon">
+                  <template #prefix>
+                    <el-icon @click.stop="toggleSubmenu(menu.id)"
+                      ><component :is="openSubmenu === menu.id ? ArrowDown : ArrowRight"
+                    /></el-icon>
+                  </template>
+                  <template #suffix>
+                    <el-popover
+                      trigger="click"
+                      placement="right-start"
+                      :show-arrow="false"
+                      :hide-after="0"
+                      popper-class="parent-operation-popover"
+                    >
+                      <template #reference>
+                        <img :src="OperationIcon" class="parent-operation-icon" />
+                      </template>
+                      <div class="menu-operations">
+                        <span>停止播放</span>
+                        <span>删除分组</span>
+                      </div>
+                    </el-popover>
+                  </template>
+                </AddItem>
+              </div>
+            </li>
+
+            <!-- 子菜单项(平铺) -->
+            <div class="submenu-wrapper" :class="{ expanded: openSubmenu === menu.id }">
+              <li class="menu-item">
+                <div class="menu-content">
+                  <AddItem content="添加相机" style="cursor: pointer" />
+                </div>
+                <div
+                  class="menu-content"
+                  v-for="submenu in menu.children"
+                  :key="`${menu.id}-${submenu.id}`"
+                  :class="{
+                    active: activeMenu === `${menu.id}-${submenu.id}`,
+                    dragging: isDragging && draggedItem === `${menu.id}-${submenu.id}`,
+                  }"
+                  @click.stop="setActiveMenu(`${menu.id}-${submenu.id}`)"
+                  draggable="true"
+                  @dragstart="dragStart($event, menu.id, submenu.id)"
+                  @dragend="dragEnd"
+                  @dragover.prevent
+                  @drop="onDrop($event, menu.id, submenu.id)"
+                >
+                  <AddItem
+                    v-if="menu.children"
+                    :content="submenu.name"
+                    :active="activeMenu === `${menu.id}-${submenu.id}`"
+                    :icon="activeMenu === `${menu.id}-${submenu.id}` ? ChildActiveIcon : ChildNotActiveIcon"
+                  >
+                    <template #suffix>
+                      <el-icon class="operation-icon"><Delete /></el-icon>
+                    </template>
+                  </AddItem>
+                </div>
+              </li>
+            </div>
+          </template>
+        </ul>
+      </div>
+    </div>
+    <!-- 将按钮放在外部容器中 -->
+    <div class="toggle-button-wrapper" @click="toggleCollapse">
+      <div class="collapse-button" :class="{ collapsed: isCollapse }" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { onMounted, ref } from 'vue';
+  import { getCameraList } from '@/api/monitor';
+  import type { CameraListResponse } from '@/types/monitor';
+  import AddItem from '@/components/AddItem.vue';
+  import ChildActiveIcon from '../svg/camera-active.svg';
+  import ParentNotActiveIcon from '../svg/folder-not-active.svg';
+  import ChildNotActiveIcon from '../svg/camera-not-active.svg';
+  import OperationIcon from '../svg/operate.svg';
+  import { ArrowRight, ArrowDown, Delete } from '@element-plus/icons-vue';
+  const cameraList = ref<CameraListResponse[]>([]);
+  //获取摄像头列表
+  const getCameraListData = async () => {
+    const res = await getCameraList();
+    cameraList.value = res;
+  };
+
+  const isCollapse = ref(false);
+  const activeMenu = ref<string | number>('');
+  // 改为单个字符串,实现手风琴效果
+  const openSubmenu = ref<string | number>('');
+  // 拖拽状态
+  const isDragging = ref(false);
+  const draggedItem = ref<string | null>(null);
+
+  const toggleCollapse = () => {
+    isCollapse.value = !isCollapse.value;
+  };
+
+  const toggleSubmenu = (menuId: string | number) => {
+    // 手风琴效果:如果点击当前已打开的菜单,则关闭;否则打开点击的菜单
+    if (openSubmenu.value === menuId) {
+      openSubmenu.value = '';
+    } else {
+      openSubmenu.value = menuId;
+    }
+  };
+
+  const setActiveMenu = (menuId: string | number) => {
+    activeMenu.value = menuId;
+  };
+
+  // 拖拽开始
+  const dragStart = (event: DragEvent, parentId: string | number, childId: string | number | null) => {
+    isDragging.value = true;
+    const itemId = childId ? `${parentId}-${childId}` : `${parentId}`;
+    draggedItem.value = itemId;
+    if (event.dataTransfer) {
+      event.dataTransfer.effectAllowed = 'move';
+      event.dataTransfer.setData('text/plain', itemId);
+    }
+  };
+
+  // 拖拽结束
+  const dragEnd = () => {
+    isDragging.value = false;
+    draggedItem.value = null;
+  };
+
+  // 放置处理
+  const onDrop = (event: DragEvent, targetParentId: string | number, targetChildId: string | number | null) => {
+    event.preventDefault();
+
+    if (!event.dataTransfer) return;
+
+    const draggedItemId = event.dataTransfer.getData('text/plain');
+    const targetItemId = targetChildId ? `${targetParentId}-${targetChildId}` : `${targetParentId}`;
+
+    if (draggedItemId === targetItemId) return;
+
+    // 解析拖拽项的父子ID
+    const [draggedParentId, draggedChildId] = draggedItemId.includes('-')
+      ? draggedItemId.split('-')
+      : [draggedItemId, null];
+
+    // 这里实现重新排序逻辑
+    reorderItems(draggedParentId, draggedChildId, targetParentId, targetChildId);
+
+    isDragging.value = false;
+    draggedItem.value = null;
+  };
+
+  // 重新排序项目
+  const reorderItems = (
+    draggedParentId: string | number,
+    draggedChildId: string | number | null,
+    targetParentId: string | number,
+    targetChildId: string | number | null,
+  ) => {
+    // 如果是组间拖拽
+    if (draggedParentId !== targetParentId) {
+      // 找到源组和目标组
+      const sourceParentIndex = cameraList.value.findIndex((item) => item.id === draggedParentId);
+      const targetParentIndex = cameraList.value.findIndex((item) => item.id === targetParentId);
+
+      if (sourceParentIndex === -1 || targetParentIndex === -1) return;
+
+      // 如果是拖拽子项到另一个组
+      if (draggedChildId !== null) {
+        const sourceParent = cameraList.value[sourceParentIndex];
+        if (!sourceParent.children) return;
+
+        // 找到要移动的子项
+        const childIndex = sourceParent.children.findIndex((item) => item.id === draggedChildId);
+        if (childIndex === -1) return;
+
+        const movedChild = sourceParent.children[childIndex];
+
+        // 从源组中移除
+        sourceParent.children.splice(childIndex, 1);
+
+        // 添加到目标组
+        const targetParent = cameraList.value[targetParentIndex];
+        if (!targetParent.children) {
+          targetParent.children = [];
+        }
+
+        // 如果有目标子项,则插入到目标子项之前
+        if (targetChildId !== null) {
+          const targetChildIndex = targetParent.children.findIndex((item) => item.id === targetChildId);
+          if (targetChildIndex !== -1) {
+            targetParent.children.splice(targetChildIndex, 0, movedChild);
+          } else {
+            targetParent.children.push(movedChild);
+          }
+        } else {
+          // 否则添加到目标组的末尾
+          targetParent.children.push(movedChild);
+        }
+      } else {
+        // 组之间的拖拽,重新排序组
+        const movedGroup = { ...cameraList.value[sourceParentIndex] };
+        cameraList.value.splice(sourceParentIndex, 1);
+
+        // 找到目标位置并插入
+        const newTargetIndex = cameraList.value.findIndex((item) => item.id === targetParentId);
+        cameraList.value.splice(newTargetIndex, 0, movedGroup);
+      }
+    } else {
+      // 同一组内的拖拽
+      const parentIndex = cameraList.value.findIndex((item) => item.id === draggedParentId);
+      if (parentIndex === -1) return;
+
+      const parent = cameraList.value[parentIndex];
+
+      // 如果是子项之间的拖拽
+      if (draggedChildId !== null && targetChildId !== null && parent.children) {
+        const draggedIndex = parent.children.findIndex((item) => item.id === draggedChildId);
+        const targetIndex = parent.children.findIndex((item) => item.id === targetChildId);
+
+        if (draggedIndex === -1 || targetIndex === -1) return;
+
+        // 移动项
+        const movedItem = parent.children[draggedIndex];
+        parent.children.splice(draggedIndex, 1);
+        parent.children.splice(targetIndex, 0, movedItem);
+      }
+    }
+  };
+
+  onMounted(() => {
+    getCameraListData();
+  });
+</script>
+
+<style lang="scss" scoped>
+  $menu-width: 240cpx;
+  $transition-duration: 0.5s;
+  .menu-wrapper {
+    position: relative;
+    width: $menu-width;
+    height: 100%;
+  }
+
+  .menu-container {
+    position: relative;
+    width: $menu-width;
+    height: 100%;
+  }
+
+  .menu-header {
+    width: $menu-width;
+    height: 20cpx;
+    background-color: $primary-color;
+    border-radius: 8cpx 8cpx 0 0;
+    transition: width $transition-duration ease-in-out;
+    &.collapsed {
+      width: 0;
+    }
+  }
+
+  .custom-menu {
+    width: $menu-width;
+    height: calc(100% - 5cpx);
+    padding-top: 20cpx;
+    background-color: #f4f7ff;
+    border: none;
+    border-radius: 4cpx;
+    position: absolute;
+    top: 5cpx;
+    z-index: 1;
+    transition: width $transition-duration ease-in-out;
+    overflow: hidden;
+
+    &.collapsed {
+      width: 0;
+    }
+  }
+
+  .add-group {
+    width: $menu-width;
+    padding: 12cpx 20cpx;
+    &:hover {
+      background-color: rgba($primary-color, 0.2);
+    }
+  }
+
+  .menu-list {
+    list-style: none;
+    width: $menu-width;
+  }
+
+  .submenu-wrapper {
+    max-height: 0;
+    overflow: hidden;
+    transition: max-height 0.3s ease-in-out;
+
+    &.expanded {
+      max-height: 300cpx;
+    }
+  }
+
+  .menu-item {
+    transition: background-color 0.3s;
+    width: 100%;
+
+    /* 父菜单和没有子节点的菜单项hover效果 */
+    &:hover {
+      background-color: rgba($primary-color, 0.1);
+    }
+
+    .menu-content {
+      display: flex;
+      align-items: center;
+      padding: 12cpx 20cpx;
+      width: $menu-width;
+      box-sizing: border-box;
+
+      &.dragging {
+        opacity: 0.5;
+        border: 1px dashed $primary-color;
+      }
+    }
+
+    &.submenu-item {
+      cursor: pointer;
+      &.active .menu-content {
+        background-color: $primary-color;
+        color: $white-color;
+      }
+    }
+  }
+
+  /* 单独设置子菜单中每个menu-content的hover效果 */
+  .submenu-wrapper .menu-content {
+    padding-left: 50cpx;
+    &:hover {
+      background-color: rgba($primary-color, 0.1);
+    }
+
+    &.active {
+      background-color: $primary-color;
+    }
+
+    &.dragging {
+      opacity: 0.5;
+      border: 1px dashed $primary-color;
+    }
+  }
+
+  /* 移除子菜单项的容器hover效果,保留内部元素hover效果 */
+  .submenu-wrapper .menu-item:hover {
+    background-color: transparent;
+  }
+
+  .toggle-button-wrapper {
+    @include flex-center;
+    position: absolute;
+    left: 240cpx;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 15cpx;
+    height: 75cpx;
+    z-index: 10;
+    transition: left 0.5s ease-in-out;
+    cursor: pointer;
+    clip-path: polygon(0 0, 100% 10cpx, 100% 65cpx, 0 75cpx);
+    background-color: $primary-color;
+
+    .menu-container.collapsed + & {
+      left: 0;
+    }
+  }
+
+  .collapse-button {
+    width: 0;
+    height: 0;
+    border-style: solid;
+    border-width: 5cpx 0 5cpx 8cpx;
+    border-color: transparent transparent transparent $white-color;
+    transform: rotate(180deg);
+    transition: transform 0.5s ease-in-out;
+
+    &.collapsed {
+      transform: rotate(0deg);
+    }
+  }
+
+  /* 子菜单操作图标样式 */
+  .operation-icon {
+    font-size: 16cpx;
+    color: $text-color;
+    cursor: pointer;
+    opacity: 0;
+
+    &:hover {
+      color: $primary-color;
+    }
+  }
+
+  /* 鼠标悬停时显示子菜单操作按钮 */
+  .menu-content:hover .operation-icon {
+    opacity: 1;
+  }
+
+  /* 当菜单项处于激活状态时,操作图标颜色应为白色 */
+  .menu-content.active .operation-icon {
+    color: $white-color;
+    opacity: 1;
+  }
+  .menu-operations {
+    display: flex;
+    flex-direction: column;
+    text-align: center;
+    gap: 10cpx;
+    span {
+      width: 100%;
+      padding: 5cpx;
+      cursor: pointer;
+      &:hover {
+        background-color: $primary-color;
+        color: $white-color;
+      }
+    }
+  }
+</style>
+<style lang="scss">
+  .parent-operation-popover {
+    padding: 0 !important;
+  }
+</style>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 18 - 0
src/views/disaster/monitor/src/svg/camera-active.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 18 - 0
src/views/disaster/monitor/src/svg/camera-not-active.svg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 22 - 0
src/views/disaster/monitor/src/svg/folder-not-active.svg


+ 16 - 0
src/views/disaster/monitor/src/svg/operate.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>1.通用/1.图标/2.操作/清空备份 2</title>
+    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="左侧栏交互" transform="translate(-261, -261)">
+            <g id="1.通用/1.图标/2.操作/清空备份-2" transform="translate(261, 261)">
+                <rect id="close-circle-filled-(Background)" opacity="0" x="0" y="0" width="20" height="20"></rect>
+                <g id="编组" transform="translate(8.75, 2.5)" fill="#000">
+                    <path d="M1.59914712,0 L1.59914712,0 C2.6652452,0 3.19829424,0.533049041 3.19829424,1.59914712 L3.19829424,1.59914712 C3.19829424,2.6652452 2.6652452,3.19829424 1.59914712,3.19829424 L1.59914712,3.19829424 C0.533049041,3.19829424 0,2.6652452 0,1.59914712 L0,1.59914712 C0,0.533049041 0.533049041,0 1.59914712,0 Z" id="路径"></path>
+                    <path d="M1.59914712,5.90085288 L1.59914712,5.90085288 C2.6652452,5.90085288 3.19829424,6.43390192 3.19829424,7.5 L3.19829424,7.5 C3.19829424,8.56609808 2.6652452,9.09914712 1.59914712,9.09914712 L1.59914712,9.09914712 C0.533049041,9.09914712 0,8.56609808 0,7.5 L0,7.5 C0,6.43390192 0.533049041,5.90085288 1.59914712,5.90085288 Z" id="路径"></path>
+                    <path d="M1.59914712,11.8017058 L1.59914712,11.8017058 C2.6652452,11.8017058 3.19829424,12.3347548 3.19829424,13.4008529 L3.19829424,13.4008529 C3.19829424,14.466951 2.6652452,15 1.59914712,15 L1.59914712,15 C0.533049041,15 0,14.466951 0,13.4008529 L0,13.4008529 C0,12.3347548 0.533049041,11.8017058 1.59914712,11.8017058 Z" id="路径"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>