index.vue 21 KB

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