|
|
@@ -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;
|