浏览代码

feat: enhance layout navigation with submenu support and new icons

- Refactor navigation structure to support expandable submenus for items with children.
- Add new icons for Cloudflare Stream, WebRTC, video testing, and stream management.
- Update CSS variables to set border-radius to zero for a sharper design.
- Clean up unused components in TypeScript definitions and streamline the main CSS file.
yb 2 周之前
父节点
当前提交
56e1e8c089
共有 4 个文件被更改,包括 244 次插入40 次删除
  1. 3 3
      Prototype/admin-template/src/styles/main.css
  2. 12 7
      src/assets/styles/theme/css-variables.scss
  3. 0 15
      src/components.d.ts
  4. 229 15
      src/layout/index.vue

+ 3 - 3
Prototype/admin-template/src/styles/main.css

@@ -1,10 +1,10 @@
+/* Google Fonts - Inter */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
+
 @tailwind base;
 @tailwind components;
 @tailwind utilities;
 
-/* Google Fonts - Inter */
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
-
 @layer base {
   html {
     font-family: 'Inter', system-ui, -apple-system, sans-serif;

+ 12 - 7
src/assets/styles/theme/css-variables.scss

@@ -62,15 +62,20 @@
   --content-padding: 20px;
 
   // ========== 圆角 ==========
-  --radius-sm: 4px;
-  --radius-base: 8px;
-  --radius-lg: 12px;
-  --radius-xl: 16px;
-  --radius-full: 9999px;
+  // --radius-sm: 4px;
+  // --radius-base: 8px;
+  // --radius-lg: 12px;
+  // --radius-xl: 16px;
+  // --radius-full: 9999px;
+  --radius-sm: 0;
+  --radius-base: 0;
+  --radius-lg: 0;
+  --radius-xl: 0;
+  --radius-full: 0;
 
   // ========== 字体 ==========
-  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
-    'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
+  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
+    'Apple Color Emoji', 'Segoe UI Emoji';
   --font-size-xs: 12px;
   --font-size-sm: 13px;
   --font-size-base: 14px;

+ 0 - 15
src/components.d.ts

@@ -8,51 +8,37 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     ElAlert: typeof import('element-plus/es')['ElAlert']
-    ElAside: typeof import('element-plus/es')['ElAside']
-    ElAvatar: typeof import('element-plus/es')['ElAvatar']
-    ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
-    ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
-    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
-    ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
-    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
     ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
-    ElHeader: typeof import('element-plus/es')['ElHeader']
     ElIcon: typeof import('element-plus/es')['ElIcon']
     ElImage: typeof import('element-plus/es')['ElImage']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElLink: typeof import('element-plus/es')['ElLink']
-    ElMain: typeof import('element-plus/es')['ElMain']
-    ElMenu: typeof import('element-plus/es')['ElMenu']
-    ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElProgress: typeof import('element-plus/es')['ElProgress']
-    ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
-    ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
     ElResult: typeof import('element-plus/es')['ElResult']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSlider: typeof import('element-plus/es')['ElSlider']
     ElSpace: typeof import('element-plus/es')['ElSpace']
     ElStatistic: typeof import('element-plus/es')['ElStatistic']
-    ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
@@ -60,7 +46,6 @@ declare module 'vue' {
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
     ElText: typeof import('element-plus/es')['ElText']
-    ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     HelloWorld: typeof import('./components/HelloWorld.vue')['default']
     IEpArrowDown: typeof import('~icons/ep/arrow-down')['default']

+ 229 - 15
src/layout/index.vue

@@ -34,17 +34,53 @@
 
       <!-- Navigation -->
       <nav class="layout__nav">
-        <router-link
-          v-for="item in menuItems"
-          :key="item.path"
-          :to="item.path"
-          class="layout__nav-item"
-          :class="{ 'layout__nav-item--active': isActive(item.path) }"
-          @click="isMobile && closeSidebar()"
-        >
-          <component :is="item.icon" class="layout__nav-icon" />
-          <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
-        </router-link>
+        <template v-for="item in menuItems" :key="item.path">
+          <!-- 有子菜单的项目 -->
+          <div v-if="item.children" class="layout__nav-group">
+            <div
+              class="layout__nav-item layout__nav-item--parent"
+              :class="{ 'layout__nav-item--active': isGroupActive(item) }"
+              @click="toggleSubMenu(item.path)"
+            >
+              <component :is="item.icon" class="layout__nav-icon" />
+              <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
+              <svg
+                v-show="sidebarOpened || isMobile"
+                class="layout__nav-arrow"
+                :class="{ 'layout__nav-arrow--open': expandedMenus.includes(item.path) }"
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+              </svg>
+            </div>
+            <div v-show="expandedMenus.includes(item.path)" class="layout__nav-children">
+              <router-link
+                v-for="child in item.children"
+                :key="child.path"
+                :to="child.path"
+                class="layout__nav-item layout__nav-item--child"
+                :class="{ 'layout__nav-item--active': isActive(child.path) }"
+                @click="isMobile && closeSidebar()"
+              >
+                <component :is="child.icon" class="layout__nav-icon" />
+                <span v-show="sidebarOpened || isMobile">{{ t(child.title) }}</span>
+              </router-link>
+            </div>
+          </div>
+          <!-- 无子菜单的项目 -->
+          <router-link
+            v-else
+            :to="item.path"
+            class="layout__nav-item"
+            :class="{ 'layout__nav-item--active': isActive(item.path) }"
+            @click="isMobile && closeSidebar()"
+          >
+            <component :is="item.icon" class="layout__nav-icon" />
+            <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
+          </router-link>
+        </template>
       </nav>
 
       <!-- Footer -->
@@ -165,6 +201,7 @@ const sidebarOpened = computed(() => appStore.sidebarOpened)
 const userInfo = computed(() => userStore.userInfo)
 const userMenuOpen = ref(false)
 const isMobile = ref(false)
+const expandedMenus = ref<string[]>([])
 
 // Icon components
 const DashboardIcon = {
@@ -257,10 +294,119 @@ const StreamIcon = {
     ])
 }
 
+const CloudIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
+      })
+    ])
+}
+
+const ConnectionIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0'
+      })
+    ])
+}
+
+const VideoTestIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z'
+      }),
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
+      })
+    ])
+}
+
+const LinkIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
+      })
+    ])
+}
+
+const FilmIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z'
+      })
+    ])
+}
+
+const LiveIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
+      })
+    ])
+}
+
+const SettingIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'
+      }),
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
+      })
+    ])
+}
+
+const TestIcon = {
+  render: () =>
+    h('svg', { class: 'layout__nav-icon', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
+      h('path', {
+        'stroke-linecap': 'round',
+        'stroke-linejoin': 'round',
+        'stroke-width': '2',
+        d: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
+      })
+    ])
+}
+
 interface MenuItem {
   path: string
   title: string
   icon: Component
+  children?: MenuItem[]
 }
 
 const menuItems: MenuItem[] = [
@@ -268,9 +414,31 @@ const menuItems: MenuItem[] = [
   { path: '/machine', title: '机器管理', icon: MachineIcon },
   { path: '/camera', title: '摄像头管理', icon: CameraIcon },
   { path: '/user', title: '用户管理', icon: UserIcon },
+  { path: '/cc', title: 'Cloudflare Stream', icon: CloudIcon },
+  { path: '/webrtc', title: 'WebRTC 流', icon: ConnectionIcon },
+  {
+    path: '/demo',
+    title: '视频测试',
+    icon: VideoTestIcon,
+    children: [
+      { path: '/demo/directurl', title: '直接 URL', icon: LinkIcon },
+      { path: '/demo/rtsp', title: 'RTSP 流', icon: ConnectionIcon },
+      { path: '/demo/samples', title: '测试视频', icon: FilmIcon }
+    ]
+  },
+  {
+    path: '/stream',
+    title: 'Stream 管理',
+    icon: StreamIcon,
+    children: [
+      { path: '/stream/videos', title: '视频管理', icon: FilmIcon },
+      { path: '/stream/live', title: '直播管理', icon: LiveIcon },
+      { path: '/stream/config', title: 'Stream 配置', icon: SettingIcon },
+      { path: '/streamtest', title: '快速测试', icon: TestIcon }
+    ]
+  },
   { path: '/stats', title: '观看统计', icon: StatsIcon },
-  { path: '/audit', title: '审计日志', icon: AuditIcon },
-  { path: '/stream-test', title: 'Stream 测试', icon: StreamIcon }
+  { path: '/audit', title: '审计日志', icon: AuditIcon }
 ]
 
 const userInitial = computed(() => {
@@ -286,7 +454,21 @@ function isActive(path: string) {
   if (path === '/') {
     return route.path === '/' || route.path === '/dashboard'
   }
-  return route.path.startsWith(path)
+  return route.path === path
+}
+
+function isGroupActive(item: MenuItem) {
+  if (!item.children) return false
+  return item.children.some((child) => route.path === child.path || route.path.startsWith(child.path))
+}
+
+function toggleSubMenu(path: string) {
+  const index = expandedMenus.value.indexOf(path)
+  if (index > -1) {
+    expandedMenus.value.splice(index, 1)
+  } else {
+    expandedMenus.value.push(path)
+  }
 }
 
 function toggleSidebar() {
@@ -542,7 +724,7 @@ onUnmounted(() => {
     color: #9ca3af;
     text-decoration: none;
     font-size: 0.875rem;
-    border-radius: 0.25rem;
+    // border-radius: 0.25rem;
     transition: all 150ms ease-in-out;
     cursor: pointer;
 
@@ -563,6 +745,38 @@ onUnmounted(() => {
     flex-shrink: 0;
   }
 
+  &__nav-group {
+    display: flex;
+    flex-direction: column;
+  }
+
+  &__nav-item--parent {
+    justify-content: flex-start;
+  }
+
+  &__nav-arrow {
+    width: 1rem;
+    height: 1rem;
+    margin-left: auto;
+    transition: transform 200ms ease-in-out;
+
+    &--open {
+      transform: rotate(180deg);
+    }
+  }
+
+  &__nav-children {
+    display: flex;
+    flex-direction: column;
+    gap: 0.125rem;
+    margin-top: 0.25rem;
+  }
+
+  &__nav-item--child {
+    padding-left: 2.5rem;
+    font-size: 0.8125rem;
+  }
+
   // Sidebar Footer
   &__sidebar-footer {
     padding: 1rem 1rem 1.5rem;