index.vue 20 KB

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