| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800 |
- <template>
- <div class="layout">
- <!-- Sidebar Overlay (Mobile) -->
- <div v-if="sidebarOpened && isMobile" class="layout__overlay" @click="closeSidebar"></div>
- <!-- Sidebar -->
- <aside
- class="layout__sidebar"
- :class="{
- 'layout__sidebar--open': sidebarOpened,
- 'layout__sidebar--collapsed': !sidebarOpened && !isMobile
- }"
- >
- <!-- Logo -->
- <div class="layout__logo">
- <div class="layout__logo-icon">
- <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <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"
- />
- </svg>
- </div>
- <span v-show="sidebarOpened || isMobile" class="layout__logo-text">{{ t('摄像头管理系统') }}</span>
- <!-- Close button (Mobile) -->
- <button v-if="isMobile" class="layout__close" @click="closeSidebar">
- <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
- <!-- Navigation -->
- <nav class="layout__nav">
- <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)"
- >
- <Icon :icon="item.icon" width="20" height="20" 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()"
- >
- <Icon :icon="child.icon" width="20" height="20" 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()"
- >
- <Icon :icon="item.icon" width="20" height="20" class="layout__nav-icon" />
- <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
- </router-link>
- </template>
- </nav>
- <!-- Footer -->
- <div class="layout__sidebar-footer">
- <button class="layout__logout" @click="handleLogout">
- <svg class="layout__nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
- />
- </svg>
- <span v-show="sidebarOpened || isMobile">{{ t('退出登录') }}</span>
- </button>
- </div>
- </aside>
- <!-- Main Content -->
- <div class="layout__main">
- <!-- Header -->
- <header class="layout__header">
- <!-- Mobile menu button -->
- <button class="layout__menu-btn" @click="toggleSidebar">
- <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
- </svg>
- </button>
- <!-- Page Title / Breadcrumb -->
- <div class="layout__breadcrumb">
- <span v-for="(item, index) in breadcrumbs" :key="item.path" class="layout__breadcrumb-item">
- <span v-if="index > 0" class="layout__breadcrumb-separator">/</span>
- {{ t(item.meta?.title as string) }}
- </span>
- </div>
- <!-- Header Right -->
- <div class="layout__header-right">
- <LangDropdown />
- <!-- User Menu -->
- <div class="layout__user" @click="toggleUserMenu">
- <div class="layout__avatar">
- <span>{{ userInitial }}</span>
- </div>
- <span class="layout__username">{{ userInfo?.username }}</span>
- <svg class="layout__icon layout__icon--sm" 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>
- <!-- User Dropdown -->
- <div v-show="userMenuOpen" class="layout__dropdown">
- <button class="layout__dropdown-item" @click="openChangePassword">
- {{ t('修改密码') }}
- </button>
- <button class="layout__dropdown-item" @click="handleLogout">
- {{ t('退出登录') }}
- </button>
- </div>
- </div>
- </div>
- </header>
- <!-- Page Content -->
- <main class="layout__content">
- <router-view v-slot="{ Component }">
- <transition name="fade" mode="out-in">
- <component :is="Component" />
- </transition>
- </router-view>
- </main>
- </div>
- <!-- 修改密码弹窗 -->
- <el-dialog v-model="passwordDialogVisible" :title="t('修改密码')" width="400px" :close-on-click-modal="false">
- <el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="80px">
- <el-form-item :label="t('原密码')" prop="oldPassword">
- <el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
- </el-form-item>
- <el-form-item :label="t('新密码')" prop="newPassword">
- <el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="请输入新密码" />
- </el-form-item>
- <el-form-item :label="t('确认密码')" prop="confirmPassword">
- <el-input
- v-model="passwordForm.confirmPassword"
- type="password"
- show-password
- :placeholder="t('请再次输入新密码')"
- />
- </el-form-item>
- </el-form>
- <template #footer>
- <el-button @click="passwordDialogVisible = false">{{ t('取消') }}</el-button>
- <el-button type="primary" :loading="passwordLoading" @click="handleChangePassword">{{ t('确定') }}</el-button>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, onMounted, onUnmounted, ref, reactive } from 'vue'
- import { Icon } from '@iconify/vue'
- import { useRoute, useRouter } from 'vue-router'
- import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
- import { useI18n } from 'vue-i18n'
- import LangDropdown from '@/components/LangDropdown.vue'
- import { useAppStore } from '@/store/app'
- import { useUserStore } from '@/store/user'
- import { changePassword } from '@/api/login'
- const route = useRoute()
- const router = useRouter()
- const appStore = useAppStore()
- const userStore = useUserStore()
- const { t } = useI18n()
- const sidebarOpened = computed(() => appStore.sidebarOpened)
- const userInfo = computed(() => userStore.userInfo)
- const userMenuOpen = ref(false)
- const isMobile = ref(false)
- const expandedMenus = ref<string[]>([])
- // Menu item interface
- interface MenuItem {
- path: string
- title: string
- icon: string
- children?: MenuItem[]
- }
- // Menu configuration with Iconify icon names
- const menuItems: MenuItem[] = [
- { path: '/', title: '仪表盘', icon: 'mdi:view-dashboard' },
- {
- path: '/lss-manage',
- title: 'LSS 管理',
- icon: 'mdi:connection',
- children: [{ path: '/lss-manage/list', title: 'LSS 列表', icon: 'pixelarticons:list' }]
- },
- {
- path: '/live-stream-manage',
- title: 'LiveStream 管理',
- icon: 'mdi:video-wireless',
- children: [{ path: '/live-stream-manage/list', title: 'LiveStream 列表', icon: 'pixelarticons:list' }]
- },
- { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },
- { path: '/camera-vendor', title: '摄像头配置', icon: 'mdi:cctv' },
- { path: '/webrtc', title: 'WebRTC 流', icon: 'mdi:wifi' },
- { path: '/monitor', title: '多视频监控', icon: 'mdi:video' },
- { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
- { path: '/camera', title: '摄像头管理', icon: 'mdi:video' }
- // {
- // path: '/demo',
- // title: '视频测试',
- // icon: 'mdi:play-circle-outline',
- // children: [
- // { path: '/demo/directurl', title: '直接 URL', icon: 'mdi:link' },
- // { path: '/demo/rtsp', title: 'RTSP 流', icon: 'mdi:wifi' },
- // { path: '/demo/samples', title: '测试视频', icon: 'mdi:filmstrip' },
- // { path: '/demo/hls', title: 'M3U8/HLS', icon: 'mdi:play-circle' }
- // ]
- // },
- // {
- // path: '/stream',
- // title: 'Stream 管理',
- // icon: 'mdi:play-circle',
- // children: [
- // { path: '/stream/videos', title: '视频管理', icon: 'mdi:filmstrip' },
- // { path: '/stream/live', title: '直播管理', icon: 'mdi:video-wireless' },
- // { path: '/stream/config', title: 'Stream 配置', icon: 'mdi:cog' },
- // { path: '/streamtest', title: '快速测试', icon: 'mdi:test-tube' }
- // ]
- // },
- // { path: '/stats', title: '观看统计', icon: 'mdi:chart-bar' },
- // { path: '/audit', title: '审计日志', icon: 'mdi:file-document' }
- ]
- const userInitial = computed(() => {
- const name = userInfo.value?.username || 'A'
- return name.charAt(0).toUpperCase()
- })
- const breadcrumbs = computed(() => {
- return route.matched.filter((item) => item.meta && item.meta.title && !item.meta.hidden)
- })
- function isActive(path: string) {
- if (path === '/') {
- return route.path === '/' || route.path === '/dashboard'
- }
- 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() {
- appStore.toggleSidebar()
- }
- function closeSidebar() {
- if (isMobile.value) {
- appStore.closeSidebar()
- }
- }
- function toggleUserMenu() {
- userMenuOpen.value = !userMenuOpen.value
- }
- function closeUserMenu(e: MouseEvent) {
- const target = e.target as HTMLElement
- if (!target.closest('.layout__user')) {
- userMenuOpen.value = false
- }
- }
- function checkMobile() {
- isMobile.value = window.innerWidth < 1024
- if (isMobile.value) {
- appStore.closeSidebar()
- }
- }
- // 修改密码相关
- const passwordDialogVisible = ref(false)
- const passwordLoading = ref(false)
- const passwordFormRef = ref<FormInstance>()
- const passwordForm = reactive({
- oldPassword: '',
- newPassword: '',
- confirmPassword: ''
- })
- const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
- if (value !== passwordForm.newPassword) {
- callback(new Error(t('两次输入的密码不一致')))
- } else {
- callback()
- }
- }
- const passwordRules: FormRules = {
- oldPassword: [{ required: true, message: t('请输入原密码'), trigger: 'blur' }],
- newPassword: [
- { required: true, message: t('请输入新密码'), trigger: 'blur' },
- { min: 6, message: t('密码长度不能少于6位'), trigger: 'blur' }
- ],
- confirmPassword: [
- { required: true, message: t('请再次输入新密码'), trigger: 'blur' },
- { validator: validateConfirmPassword, trigger: 'blur' }
- ]
- }
- function resetPasswordForm() {
- passwordForm.oldPassword = ''
- passwordForm.newPassword = ''
- passwordForm.confirmPassword = ''
- passwordFormRef.value?.clearValidate()
- }
- function openChangePassword() {
- userMenuOpen.value = false
- resetPasswordForm()
- passwordDialogVisible.value = true
- }
- async function handleChangePassword() {
- if (!passwordFormRef.value) return
- await passwordFormRef.value.validate(async (valid) => {
- if (valid) {
- passwordLoading.value = true
- try {
- const res = await changePassword({
- oldPassword: passwordForm.oldPassword,
- newPassword: passwordForm.newPassword
- })
- if (res.success) {
- ElMessage.success('密码修改成功,请重新登录')
- passwordDialogVisible.value = false
- resetPasswordForm()
- await userStore.logoutAction()
- router.push('/login')
- } else {
- ElMessage.error(res.errMessage || '密码修改失败')
- }
- } catch (error: any) {
- ElMessage.error(error.message || '密码修改失败')
- } finally {
- passwordLoading.value = false
- }
- }
- })
- }
- async function handleLogout() {
- userMenuOpen.value = false
- await userStore.logoutAction()
- router.push('/login')
- }
- // 根据当前路由自动展开菜单
- function initExpandedMenus() {
- menuItems.forEach((item) => {
- if (item.children && item.children.some((child) => route.path.startsWith(child.path))) {
- if (!expandedMenus.value.includes(item.path)) {
- expandedMenus.value.push(item.path)
- }
- }
- })
- }
- onMounted(() => {
- userStore.getUserInfo()
- checkMobile()
- initExpandedMenus()
- window.addEventListener('resize', checkMobile)
- document.addEventListener('click', closeUserMenu)
- })
- onUnmounted(() => {
- window.removeEventListener('resize', checkMobile)
- document.removeEventListener('click', closeUserMenu)
- })
- </script>
- <style lang="scss" scoped>
- .layout {
- display: flex;
- height: 100vh;
- overflow: hidden;
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
- // Overlay
- &__overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 40;
- @media (min-width: 1024px) {
- display: none;
- }
- }
- // Sidebar
- &__sidebar {
- position: fixed;
- top: 0;
- left: 0;
- bottom: 0;
- width: 16rem;
- background: linear-gradient(to bottom, #111827, #1f2937, #000000);
- display: flex;
- flex-direction: column;
- z-index: 50;
- transform: translateX(-100%);
- transition: transform 200ms ease-in-out, width 200ms ease-in-out;
- @media (min-width: 1024px) {
- position: static;
- transform: translateX(0);
- }
- &--open {
- transform: translateX(0);
- }
- &--collapsed {
- width: 4rem;
- .layout__logo-text,
- .layout__nav-item span,
- .layout__logout span {
- display: none;
- }
- .layout__logo {
- justify-content: center;
- padding: 0;
- }
- .layout__nav {
- padding: 0;
- }
- .layout__nav-item,
- .layout__logout {
- justify-content: center;
- padding: 0.75rem;
- }
- }
- }
- // Logo
- &__logo {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: 4rem;
- padding: 0 1.5rem;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- }
- &__logo-icon {
- width: 2rem;
- height: 2rem;
- background: #ffffff;
- border-radius: var(--radius-base);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- .layout__icon {
- width: 1rem;
- height: 1rem;
- color: #000000;
- }
- }
- &__logo-text {
- flex: 1;
- margin-left: 0.75rem;
- color: #ffffff;
- font-weight: 600;
- font-size: 0.875rem;
- white-space: nowrap;
- }
- &__close {
- background: none;
- border: none;
- color: #9ca3af;
- cursor: pointer;
- padding: 0.25rem;
- transition: color 150ms ease-in-out;
- &:hover {
- color: #ffffff;
- }
- @media (min-width: 1024px) {
- display: none;
- }
- }
- // Navigation
- &__nav {
- flex: 1;
- padding: 1.5rem 1rem;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- }
- &__nav-item {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.75rem 1rem;
- color: #9ca3af;
- text-decoration: none;
- font-size: 0.875rem;
- // border-radius: 0.25rem;
- transition: all 150ms ease-in-out;
- cursor: pointer;
- &:hover {
- color: #ffffff;
- background: rgba(255, 255, 255, 0.05);
- }
- &--active {
- color: #ffffff;
- background: rgba(255, 255, 255, 0.1);
- }
- }
- &__nav-icon {
- width: 1.25rem;
- height: 1.25rem;
- 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;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- }
- &__logout {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- width: 100%;
- padding: 0.75rem 1rem;
- color: #9ca3af;
- background: none;
- border: none;
- font-size: 0.875rem;
- cursor: pointer;
- transition: color 150ms ease-in-out;
- &:hover {
- color: #ffffff;
- }
- }
- // Main
- &__main {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- max-height: 100vh;
- overflow: hidden;
- background: #f9fafb;
- }
- // Header
- &__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: 4rem;
- padding: 0 1rem;
- background: #ffffff;
- border-bottom: 1px solid #e5e7eb;
- @media (min-width: 1024px) {
- padding: 0 2rem;
- }
- }
- &__menu-btn {
- padding: 0.5rem;
- margin-left: -0.5rem;
- background: none;
- border: none;
- color: #6b7280;
- cursor: pointer;
- transition: color 150ms ease-in-out;
- &:hover {
- color: #000000;
- }
- }
- &__breadcrumb {
- display: none;
- font-size: 1rem;
- font-weight: 600;
- color: #000000;
- @media (min-width: 1024px) {
- display: block;
- }
- }
- &__breadcrumb-item {
- color: #000000;
- }
- &__breadcrumb-separator {
- color: #9ca3af;
- margin: 0 0.5rem;
- }
- &__header-right {
- display: flex;
- align-items: center;
- gap: 1rem;
- }
- // User Menu
- &__user {
- position: relative;
- display: flex;
- align-items: center;
- gap: 0.75rem;
- cursor: pointer;
- }
- &__avatar {
- width: 2rem;
- height: 2rem;
- background: #111827;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- span {
- color: #ffffff;
- font-size: 0.875rem;
- font-weight: 500;
- }
- }
- &__username {
- display: none;
- font-size: 0.875rem;
- font-weight: 500;
- color: #374151;
- @media (min-width: 640px) {
- display: block;
- }
- }
- &__dropdown {
- position: absolute;
- top: 100%;
- right: 0;
- margin-top: 0.5rem;
- min-width: 12rem;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
- z-index: 50;
- }
- &__dropdown-item {
- display: block;
- width: 100%;
- padding: 0.5rem 1rem;
- text-align: left;
- font-size: 0.875rem;
- color: #374151;
- background: none;
- border: none;
- cursor: pointer;
- transition: background-color 150ms ease-in-out;
- &:hover {
- background: #f9fafb;
- }
- }
- // Content
- &__content {
- flex: 1;
- padding: 0;
- overflow: hidden;
- }
- // Icons
- &__icon {
- width: 1.5rem;
- height: 1.5rem;
- &--sm {
- width: 1rem;
- height: 1rem;
- color: #9ca3af;
- }
- }
- }
- // Transitions
- .fade-enter-active,
- .fade-leave-active {
- transition: opacity 150ms ease;
- }
- .fade-enter-from,
- .fade-leave-to {
- opacity: 0;
- }
- </style>
|