index.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. <template>
  2. <div class="layout">
  3. <!-- Sidebar Overlay (Mobile) -->
  4. <div v-if="sidebarOpened && isMobile" class="layout__overlay" @click="closeSidebar"></div>
  5. <!-- Sidebar -->
  6. <aside
  7. class="layout__sidebar"
  8. :class="{
  9. 'layout__sidebar--open': sidebarOpened,
  10. 'layout__sidebar--collapsed': !sidebarOpened && !isMobile
  11. }"
  12. >
  13. <!-- Logo -->
  14. <div class="layout__logo">
  15. <div class="layout__logo-icon">
  16. <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  17. <path
  18. stroke-linecap="round"
  19. stroke-linejoin="round"
  20. stroke-width="2"
  21. 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"
  22. />
  23. </svg>
  24. </div>
  25. <span v-show="sidebarOpened || isMobile" class="layout__logo-text">{{ t('摄像头管理系统') }}</span>
  26. <!-- Close button (Mobile) -->
  27. <button v-if="isMobile" class="layout__close" @click="closeSidebar">
  28. <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  29. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
  30. </svg>
  31. </button>
  32. </div>
  33. <!-- Navigation -->
  34. <nav class="layout__nav">
  35. <template v-for="item in menuItems" :key="item.path">
  36. <!-- 有子菜单的项目 -->
  37. <div v-if="item.children" class="layout__nav-group">
  38. <div
  39. class="layout__nav-item layout__nav-item--parent"
  40. :class="{ 'layout__nav-item--active': isGroupActive(item) }"
  41. @click="toggleSubMenu(item.path)"
  42. >
  43. <Icon :icon="item.icon" width="20" height="20" class="layout__nav-icon" />
  44. <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
  45. <svg
  46. v-show="sidebarOpened || isMobile"
  47. class="layout__nav-arrow"
  48. :class="{ 'layout__nav-arrow--open': expandedMenus.includes(item.path) }"
  49. fill="none"
  50. stroke="currentColor"
  51. viewBox="0 0 24 24"
  52. >
  53. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
  54. </svg>
  55. </div>
  56. <div v-show="expandedMenus.includes(item.path)" class="layout__nav-children">
  57. <router-link
  58. v-for="child in item.children"
  59. :key="child.path"
  60. :to="child.path"
  61. class="layout__nav-item layout__nav-item--child"
  62. :class="{ 'layout__nav-item--active': isActive(child.path) }"
  63. @click="isMobile && closeSidebar()"
  64. >
  65. <Icon :icon="child.icon" width="20" height="20" class="layout__nav-icon" />
  66. <span v-show="sidebarOpened || isMobile">{{ t(child.title) }}</span>
  67. </router-link>
  68. </div>
  69. </div>
  70. <!-- 无子菜单的项目 -->
  71. <router-link
  72. v-else
  73. :to="item.path"
  74. class="layout__nav-item"
  75. :class="{ 'layout__nav-item--active': isActive(item.path) }"
  76. @click="isMobile && closeSidebar()"
  77. >
  78. <Icon :icon="item.icon" width="20" height="20" class="layout__nav-icon" />
  79. <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
  80. </router-link>
  81. </template>
  82. </nav>
  83. <!-- Footer -->
  84. <div class="layout__sidebar-footer">
  85. <button class="layout__logout" @click="handleLogout">
  86. <svg class="layout__nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  87. <path
  88. stroke-linecap="round"
  89. stroke-linejoin="round"
  90. stroke-width="2"
  91. 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"
  92. />
  93. </svg>
  94. <span v-show="sidebarOpened || isMobile">{{ t('退出登录') }}</span>
  95. </button>
  96. </div>
  97. </aside>
  98. <!-- Main Content -->
  99. <div class="layout__main">
  100. <!-- Header -->
  101. <header class="layout__header">
  102. <!-- Mobile menu button -->
  103. <div class="layout__header-left">
  104. <!-- Mobile: Hamburger menu -->
  105. <button class="layout__menu-btn layout__menu-btn--mobile" @click="toggleSidebar">
  106. <svg class="layout__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  107. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
  108. </svg>
  109. </button>
  110. <!-- Desktop: Compact chevron toggle -->
  111. <button link class="layout__menu-btn layout__menu-btn--desktop" @click="toggleSidebar">
  112. <Icon
  113. icon="rivet-icons:chevron-left"
  114. width="20"
  115. height="20"
  116. class="layout__menu-icon"
  117. :class="{ 'layout__menu-icon--collapsed': !sidebarOpened }"
  118. />
  119. </button>
  120. <!-- Page Title / Breadcrumb -->
  121. <div class="layout__breadcrumb">
  122. <span v-for="(item, index) in breadcrumbs" :key="item.path" class="layout__breadcrumb-item">
  123. <span v-if="index > 0" class="layout__breadcrumb-separator">/</span>
  124. {{ t(item.meta?.title as string) }}
  125. </span>
  126. </div>
  127. </div>
  128. <!-- Header Right -->
  129. <div class="layout__header-right">
  130. <LangDropdown />
  131. <!-- User Menu -->
  132. <div class="layout__user" @click="toggleUserMenu">
  133. <div class="layout__avatar">
  134. <span>{{ userInitial }}</span>
  135. </div>
  136. <span class="layout__username">{{ userInfo?.username }}</span>
  137. <svg class="layout__icon layout__icon--sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  138. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
  139. </svg>
  140. <!-- User Dropdown -->
  141. <div v-show="userMenuOpen" class="layout__dropdown">
  142. <button class="layout__dropdown-item" @click="openChangePassword">
  143. {{ t('修改密码') }}
  144. </button>
  145. <button class="layout__dropdown-item" @click="handleLogout">
  146. {{ t('退出登录') }}
  147. </button>
  148. </div>
  149. </div>
  150. </div>
  151. </header>
  152. <!-- Page Content -->
  153. <main class="layout__content">
  154. <router-view v-slot="{ Component }">
  155. <transition name="fade" mode="out-in">
  156. <component :is="Component" />
  157. </transition>
  158. </router-view>
  159. </main>
  160. </div>
  161. <!-- 修改密码弹窗 -->
  162. <el-dialog v-model="passwordDialogVisible" :title="t('修改密码')" width="400px" :close-on-click-modal="false">
  163. <el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="80px">
  164. <el-form-item :label="t('原密码')" prop="oldPassword">
  165. <el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
  166. </el-form-item>
  167. <el-form-item :label="t('新密码')" prop="newPassword">
  168. <el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="请输入新密码" />
  169. </el-form-item>
  170. <el-form-item :label="t('确认密码')" prop="confirmPassword">
  171. <el-input
  172. v-model="passwordForm.confirmPassword"
  173. type="password"
  174. show-password
  175. :placeholder="t('请再次输入新密码')"
  176. />
  177. </el-form-item>
  178. </el-form>
  179. <template #footer>
  180. <el-button @click="passwordDialogVisible = false">{{ t('取消') }}</el-button>
  181. <el-button type="primary" :loading="passwordLoading" @click="handleChangePassword">{{ t('确定') }}</el-button>
  182. </template>
  183. </el-dialog>
  184. </div>
  185. </template>
  186. <script setup lang="ts">
  187. import { computed, onMounted, onUnmounted, ref, reactive } from 'vue'
  188. import { Icon } from '@iconify/vue'
  189. import { useRoute, useRouter } from 'vue-router'
  190. import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
  191. import { useI18n } from 'vue-i18n'
  192. import LangDropdown from '@/components/LangDropdown.vue'
  193. import { useAppStore } from '@/store/app'
  194. import { useUserStore } from '@/store/user'
  195. import { changePassword } from '@/api/login'
  196. const route = useRoute()
  197. const router = useRouter()
  198. const appStore = useAppStore()
  199. const userStore = useUserStore()
  200. const { t } = useI18n()
  201. const sidebarOpened = computed(() => appStore.sidebarOpened)
  202. const userInfo = computed(() => userStore.userInfo)
  203. const userMenuOpen = ref(false)
  204. const isMobile = ref(false)
  205. const expandedMenus = ref<string[]>([])
  206. // Menu item interface
  207. interface MenuItem {
  208. path: string
  209. title: string
  210. icon: string
  211. children?: MenuItem[]
  212. }
  213. // Menu configuration with Iconify icon names
  214. const menuItems: MenuItem[] = [
  215. { path: '/', title: '仪表盘', icon: 'mdi:view-dashboard' },
  216. {
  217. path: '/lss-manage',
  218. title: 'LSS 管理',
  219. icon: 'mdi:connection',
  220. children: [{ path: '/lss-manage/list', title: 'LSS 列表', icon: 'pixelarticons:list' }]
  221. },
  222. {
  223. path: '/live-stream-manage',
  224. title: 'LiveStream 管理',
  225. icon: 'mdi:video-wireless',
  226. children: [{ path: '/live-stream-manage/list', title: 'LiveStream 列表', icon: 'pixelarticons:list' }]
  227. },
  228. // { path: '/cc', title: 'Cloudflare Stream', icon: 'mdi:cloud' },
  229. { path: '/camera-vendor', title: '摄像头配置', icon: 'mdi:cctv' },
  230. // { path: '/webrtc', title: 'WebRTC 流', icon: 'mdi:wifi' },
  231. { path: '/monitor', title: '多视频监控', icon: 'mdi:video' },
  232. { path: '/machine', title: '机器管理', icon: 'mdi:monitor' },
  233. { path: '/camera', title: '摄像头管理', icon: 'mdi:video' },
  234. {
  235. path: '/system',
  236. title: '系统管理',
  237. icon: 'mdi:cog',
  238. children: [
  239. { path: '/system/user', title: '用户管理', icon: 'mdi:account' },
  240. { path: '/system/role', title: '角色管理', icon: 'mdi:shield-account' }
  241. ]
  242. }
  243. // {
  244. // path: '/demo',
  245. // title: '视频测试',
  246. // icon: 'mdi:play-circle-outline',
  247. // children: [
  248. // { path: '/demo/directurl', title: '直接 URL', icon: 'mdi:link' },
  249. // { path: '/demo/rtsp', title: 'RTSP 流', icon: 'mdi:wifi' },
  250. // { path: '/demo/samples', title: '测试视频', icon: 'mdi:filmstrip' },
  251. // { path: '/demo/hls', title: 'M3U8/HLS', icon: 'mdi:play-circle' }
  252. // ]
  253. // },
  254. // {
  255. // path: '/stream',
  256. // title: 'Stream 管理',
  257. // icon: 'mdi:play-circle',
  258. // children: [
  259. // { path: '/stream/videos', title: '视频管理', icon: 'mdi:filmstrip' },
  260. // { path: '/stream/live', title: '直播管理', icon: 'mdi:video-wireless' },
  261. // { path: '/stream/config', title: 'Stream 配置', icon: 'mdi:cog' },
  262. // { path: '/streamtest', title: '快速测试', icon: 'mdi:test-tube' }
  263. // ]
  264. // },
  265. // { path: '/stats', title: '观看统计', icon: 'mdi:chart-bar' },
  266. // { path: '/audit', title: '审计日志', icon: 'mdi:file-document' }
  267. ]
  268. const userInitial = computed(() => {
  269. const name = userInfo.value?.username || 'A'
  270. return name.charAt(0).toUpperCase()
  271. })
  272. const breadcrumbs = computed(() => {
  273. return route.matched.filter((item) => item.meta && item.meta.title && !item.meta.hidden)
  274. })
  275. function isActive(path: string) {
  276. if (path === '/') {
  277. return route.path === '/' || route.path === '/dashboard'
  278. }
  279. return route.path === path
  280. }
  281. function isGroupActive(item: MenuItem) {
  282. if (!item.children) return false
  283. return item.children.some((child) => route.path === child.path || route.path.startsWith(child.path))
  284. }
  285. function toggleSubMenu(path: string) {
  286. const index = expandedMenus.value.indexOf(path)
  287. if (index > -1) {
  288. expandedMenus.value.splice(index, 1)
  289. } else {
  290. expandedMenus.value.push(path)
  291. }
  292. }
  293. function toggleSidebar() {
  294. appStore.toggleSidebar()
  295. }
  296. function closeSidebar() {
  297. if (isMobile.value) {
  298. appStore.closeSidebar()
  299. }
  300. }
  301. function toggleUserMenu() {
  302. userMenuOpen.value = !userMenuOpen.value
  303. }
  304. function closeUserMenu(e: MouseEvent) {
  305. const target = e.target as HTMLElement
  306. if (!target.closest('.layout__user')) {
  307. userMenuOpen.value = false
  308. }
  309. }
  310. function checkMobile() {
  311. isMobile.value = window.innerWidth < 1024
  312. if (isMobile.value) {
  313. appStore.closeSidebar()
  314. }
  315. }
  316. // 修改密码相关
  317. const passwordDialogVisible = ref(false)
  318. const passwordLoading = ref(false)
  319. const passwordFormRef = ref<FormInstance>()
  320. const passwordForm = reactive({
  321. oldPassword: '',
  322. newPassword: '',
  323. confirmPassword: ''
  324. })
  325. const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
  326. if (value !== passwordForm.newPassword) {
  327. callback(new Error(t('两次输入的密码不一致')))
  328. } else {
  329. callback()
  330. }
  331. }
  332. const passwordRules: FormRules = {
  333. oldPassword: [{ required: true, message: t('请输入原密码'), trigger: 'blur' }],
  334. newPassword: [
  335. { required: true, message: t('请输入新密码'), trigger: 'blur' },
  336. { min: 6, message: t('密码长度不能少于6位'), trigger: 'blur' }
  337. ],
  338. confirmPassword: [
  339. { required: true, message: t('请再次输入新密码'), trigger: 'blur' },
  340. { validator: validateConfirmPassword, trigger: 'blur' }
  341. ]
  342. }
  343. function resetPasswordForm() {
  344. passwordForm.oldPassword = ''
  345. passwordForm.newPassword = ''
  346. passwordForm.confirmPassword = ''
  347. passwordFormRef.value?.clearValidate()
  348. }
  349. function openChangePassword() {
  350. userMenuOpen.value = false
  351. resetPasswordForm()
  352. passwordDialogVisible.value = true
  353. }
  354. async function handleChangePassword() {
  355. if (!passwordFormRef.value) return
  356. await passwordFormRef.value.validate(async (valid) => {
  357. if (valid) {
  358. passwordLoading.value = true
  359. try {
  360. const res = await changePassword({
  361. oldPassword: passwordForm.oldPassword,
  362. newPassword: passwordForm.newPassword
  363. })
  364. if (res.success) {
  365. ElMessage.success('密码修改成功,请重新登录')
  366. passwordDialogVisible.value = false
  367. resetPasswordForm()
  368. await userStore.logoutAction()
  369. router.push('/login')
  370. } else {
  371. ElMessage.error(res.errMessage || '密码修改失败')
  372. }
  373. } catch (error: any) {
  374. ElMessage.error(error.message || '密码修改失败')
  375. } finally {
  376. passwordLoading.value = false
  377. }
  378. }
  379. })
  380. }
  381. async function handleLogout() {
  382. userMenuOpen.value = false
  383. await userStore.logoutAction()
  384. router.push('/login')
  385. }
  386. // 根据当前路由自动展开菜单
  387. function initExpandedMenus() {
  388. menuItems.forEach((item) => {
  389. if (item.children && item.children.some((child) => route.path.startsWith(child.path))) {
  390. if (!expandedMenus.value.includes(item.path)) {
  391. expandedMenus.value.push(item.path)
  392. }
  393. }
  394. })
  395. }
  396. onMounted(() => {
  397. userStore.getUserInfo()
  398. checkMobile()
  399. initExpandedMenus()
  400. window.addEventListener('resize', checkMobile)
  401. document.addEventListener('click', closeUserMenu)
  402. })
  403. onUnmounted(() => {
  404. window.removeEventListener('resize', checkMobile)
  405. document.removeEventListener('click', closeUserMenu)
  406. })
  407. </script>
  408. <style lang="scss" scoped>
  409. .layout {
  410. display: flex;
  411. height: 100vh;
  412. overflow: hidden;
  413. font-family: 'Inter', system-ui, -apple-system, sans-serif;
  414. // Overlay
  415. &__overlay {
  416. position: fixed;
  417. inset: 0;
  418. background: rgba(0, 0, 0, 0.5);
  419. z-index: 40;
  420. @media (min-width: 1024px) {
  421. display: none;
  422. }
  423. }
  424. // Sidebar
  425. &__sidebar {
  426. position: fixed;
  427. top: 0;
  428. left: 0;
  429. bottom: 0;
  430. width: 16rem;
  431. background: linear-gradient(to bottom, #111827, #1f2937, #000000);
  432. display: flex;
  433. flex-direction: column;
  434. z-index: 50;
  435. transform: translateX(-100%);
  436. transition: transform 200ms ease-in-out, width 200ms ease-in-out;
  437. @media (min-width: 1024px) {
  438. position: static;
  439. transform: translateX(0);
  440. }
  441. &--open {
  442. transform: translateX(0);
  443. }
  444. &--collapsed {
  445. width: 4rem;
  446. .layout__logo-text,
  447. .layout__nav-item span,
  448. .layout__logout span {
  449. display: none;
  450. }
  451. .layout__logo {
  452. justify-content: center;
  453. padding: 0;
  454. }
  455. .layout__nav {
  456. padding: 0;
  457. }
  458. .layout__nav-item,
  459. .layout__logout {
  460. justify-content: center;
  461. padding: 0.75rem;
  462. }
  463. }
  464. }
  465. // Logo
  466. &__logo {
  467. display: flex;
  468. align-items: center;
  469. justify-content: space-between;
  470. height: 4rem;
  471. padding: 0 1.5rem;
  472. border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  473. }
  474. &__logo-icon {
  475. width: 2rem;
  476. height: 2rem;
  477. background: #ffffff;
  478. border-radius: var(--radius-base);
  479. display: flex;
  480. align-items: center;
  481. justify-content: center;
  482. flex-shrink: 0;
  483. .layout__icon {
  484. width: 1rem;
  485. height: 1rem;
  486. color: #000000;
  487. }
  488. }
  489. &__logo-text {
  490. flex: 1;
  491. margin-left: 0.75rem;
  492. color: #ffffff;
  493. font-weight: 600;
  494. font-size: 0.875rem;
  495. white-space: nowrap;
  496. }
  497. &__close {
  498. background: none;
  499. border: none;
  500. color: #9ca3af;
  501. cursor: pointer;
  502. padding: 0.25rem;
  503. transition: color 150ms ease-in-out;
  504. &:hover {
  505. color: #ffffff;
  506. }
  507. @media (min-width: 1024px) {
  508. display: none;
  509. }
  510. }
  511. // Navigation
  512. &__nav {
  513. flex: 1;
  514. padding: 1.5rem 1rem;
  515. overflow-y: auto;
  516. display: flex;
  517. flex-direction: column;
  518. gap: 0.25rem;
  519. }
  520. &__nav-item {
  521. display: flex;
  522. align-items: center;
  523. gap: 0.75rem;
  524. padding: 0.75rem 1rem;
  525. color: #9ca3af;
  526. text-decoration: none;
  527. font-size: 0.875rem;
  528. // border-radius: 0.25rem;
  529. transition: all 150ms ease-in-out;
  530. cursor: pointer;
  531. &:hover {
  532. color: #ffffff;
  533. background: rgba(255, 255, 255, 0.05);
  534. }
  535. &--active {
  536. color: #ffffff;
  537. background: rgba(255, 255, 255, 0.1);
  538. }
  539. }
  540. &__nav-icon {
  541. width: 1.25rem;
  542. height: 1.25rem;
  543. flex-shrink: 0;
  544. }
  545. &__nav-group {
  546. display: flex;
  547. flex-direction: column;
  548. }
  549. &__nav-item--parent {
  550. justify-content: flex-start;
  551. }
  552. &__nav-arrow {
  553. width: 1rem;
  554. height: 1rem;
  555. margin-left: auto;
  556. transition: transform 200ms ease-in-out;
  557. &--open {
  558. transform: rotate(180deg);
  559. }
  560. }
  561. &__nav-children {
  562. display: flex;
  563. flex-direction: column;
  564. gap: 0.125rem;
  565. margin-top: 0.25rem;
  566. }
  567. &__nav-item--child {
  568. padding-left: 2.5rem;
  569. font-size: 0.8125rem;
  570. }
  571. // Sidebar Footer
  572. &__sidebar-footer {
  573. padding: 1rem 1rem 1.5rem;
  574. border-top: 1px solid rgba(255, 255, 255, 0.1);
  575. }
  576. &__logout {
  577. display: flex;
  578. align-items: center;
  579. gap: 0.75rem;
  580. width: 100%;
  581. padding: 0.75rem 1rem;
  582. color: #9ca3af;
  583. background: none;
  584. border: none;
  585. font-size: 0.875rem;
  586. cursor: pointer;
  587. transition: color 150ms ease-in-out;
  588. &:hover {
  589. color: #ffffff;
  590. }
  591. }
  592. // Main
  593. &__main {
  594. flex: 1;
  595. display: flex;
  596. flex-direction: column;
  597. min-width: 0;
  598. max-height: 100vh;
  599. overflow: hidden;
  600. background: #f9fafb;
  601. }
  602. // Header
  603. &__header {
  604. display: flex;
  605. align-items: center;
  606. justify-content: space-between;
  607. height: 4rem;
  608. padding: 0 1rem;
  609. background: #ffffff;
  610. border-bottom: 1px solid #e5e7eb;
  611. @media (min-width: 1024px) {
  612. padding: 0 2rem 0 0;
  613. }
  614. }
  615. &__menu-btn {
  616. padding: 0.5rem;
  617. margin-left: -0.5rem;
  618. background: none;
  619. border: none;
  620. color: #6b7280;
  621. cursor: pointer;
  622. transition: all 150ms ease-in-out;
  623. &:hover {
  624. // color: #000000;
  625. }
  626. // Mobile: show hamburger, hide chevron
  627. &--mobile {
  628. display: block;
  629. @media (min-width: 1024px) {
  630. display: none;
  631. }
  632. }
  633. // Desktop: show chevron, hide hamburger
  634. &--desktop {
  635. display: none;
  636. height: 56px;
  637. padding: 0;
  638. margin-left: 0;
  639. margin-right: 0;
  640. background: #409eff;
  641. color: #ffffff;
  642. @media (min-width: 1024px) {
  643. display: flex;
  644. align-items: center;
  645. justify-content: center;
  646. }
  647. &:hover {
  648. background: #66b1ff;
  649. }
  650. }
  651. }
  652. // Menu icon rotation
  653. &__menu-icon {
  654. transition: transform 150ms ease-in-out;
  655. &--collapsed {
  656. transform: rotate(180deg);
  657. }
  658. }
  659. &__header-left {
  660. display: flex;
  661. align-items: center;
  662. gap: 1rem;
  663. }
  664. &__breadcrumb {
  665. display: none;
  666. font-size: 1rem;
  667. font-weight: 600;
  668. color: #000000;
  669. @media (min-width: 1024px) {
  670. display: block;
  671. }
  672. }
  673. &__breadcrumb-item {
  674. color: #000000;
  675. }
  676. &__breadcrumb-separator {
  677. color: #9ca3af;
  678. margin: 0 0.5rem;
  679. }
  680. &__header-right {
  681. display: flex;
  682. align-items: center;
  683. gap: 1rem;
  684. }
  685. // User Menu
  686. &__user {
  687. position: relative;
  688. display: flex;
  689. align-items: center;
  690. gap: 0.75rem;
  691. cursor: pointer;
  692. }
  693. &__avatar {
  694. width: 2rem;
  695. height: 2rem;
  696. background: #111827;
  697. border-radius: 50%;
  698. display: flex;
  699. align-items: center;
  700. justify-content: center;
  701. span {
  702. color: #ffffff;
  703. font-size: 0.875rem;
  704. font-weight: 500;
  705. }
  706. }
  707. &__username {
  708. display: none;
  709. font-size: 0.875rem;
  710. font-weight: 500;
  711. color: #374151;
  712. @media (min-width: 640px) {
  713. display: block;
  714. }
  715. }
  716. &__dropdown {
  717. position: absolute;
  718. top: 100%;
  719. right: 0;
  720. margin-top: 0.5rem;
  721. min-width: 12rem;
  722. background: #ffffff;
  723. border: 1px solid #e5e7eb;
  724. box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
  725. z-index: 50;
  726. }
  727. &__dropdown-item {
  728. display: block;
  729. width: 100%;
  730. padding: 0.5rem 1rem;
  731. text-align: left;
  732. font-size: 0.875rem;
  733. color: #374151;
  734. background: none;
  735. border: none;
  736. cursor: pointer;
  737. transition: background-color 150ms ease-in-out;
  738. &:hover {
  739. background: #f9fafb;
  740. }
  741. }
  742. // Content
  743. &__content {
  744. flex: 1;
  745. padding: 0;
  746. overflow: hidden;
  747. }
  748. // Icons
  749. &__icon {
  750. width: 1.5rem;
  751. height: 1.5rem;
  752. &--sm {
  753. width: 1rem;
  754. height: 1rem;
  755. color: #9ca3af;
  756. }
  757. }
  758. }
  759. // Transitions
  760. .fade-enter-active,
  761. .fade-leave-active {
  762. transition: opacity 150ms ease;
  763. }
  764. .fade-enter-from,
  765. .fade-leave-to {
  766. opacity: 0;
  767. }
  768. </style>