AdminLayout.vue 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. <script setup lang="ts">
  2. import type { MenuOption } from 'naive-ui'
  3. import { NIcon, useMessage } from 'naive-ui'
  4. import { BookOutline, LogoUsd, MenuOutline, PeopleOutline } from '@vicons/ionicons5'
  5. import RouteTabBar from '@/components/RouteTabBar.vue'
  6. import { setToken } from '@/api/request'
  7. import { useAppStore } from '@/stores/app'
  8. import { useTabStore } from '@/stores/tabs'
  9. const app = useAppStore()
  10. const tabStore = useTabStore()
  11. const { sidebarCollapsed } = storeToRefs(app)
  12. const route = useRoute()
  13. const router = useRouter()
  14. const message = useMessage()
  15. watch(
  16. () => route.fullPath,
  17. () => {
  18. tabStore.syncFromRoute(route)
  19. },
  20. { immediate: true },
  21. )
  22. function renderIcon(icon: typeof BookOutline) {
  23. return () => h(NIcon, { size: 18 }, { default: () => h(icon) })
  24. }
  25. const SUBMENU_COURSES = 'submenu-courses'
  26. const SUBMENU_CUSTOMERS = 'submenu-customers'
  27. const SUBMENU_FINANCE = 'submenu-finance'
  28. const menuOptions: MenuOption[] = [
  29. {
  30. label: '课程管理',
  31. key: SUBMENU_COURSES,
  32. icon: renderIcon(BookOutline),
  33. children: [
  34. { label: '常见问题', key: '/courses/common-questions' },
  35. { label: '有奖问答', key: '/courses/reward-questions' },
  36. { label: '教学内容', key: '/courses/goods' },
  37. ],
  38. },
  39. {
  40. label: '客户管理',
  41. key: SUBMENU_CUSTOMERS,
  42. icon: renderIcon(PeopleOutline),
  43. children: [{ label: '客户列表', key: '/customers/list' }],
  44. },
  45. {
  46. label: '财务管理',
  47. key: SUBMENU_FINANCE,
  48. icon: renderIcon(LogoUsd),
  49. children: [
  50. { label: '取款申请', key: '/finance/withdraw-apply' },
  51. { label: '订单列表', key: '/finance/order-list' },
  52. ],
  53. },
  54. ]
  55. const expandedKeys = ref<string[]>([])
  56. watch(
  57. () => route.path,
  58. (p) => {
  59. if (p.startsWith('/courses')) {
  60. expandedKeys.value = [SUBMENU_COURSES]
  61. return
  62. }
  63. if (p.startsWith('/customers')) {
  64. expandedKeys.value = [SUBMENU_CUSTOMERS]
  65. return
  66. }
  67. if (p.startsWith('/finance')) {
  68. expandedKeys.value = [SUBMENU_FINANCE]
  69. return
  70. }
  71. expandedKeys.value = []
  72. },
  73. { immediate: true },
  74. )
  75. const activeKey = computed(() => route.path)
  76. const pageTitle = computed(() => (route.meta.title as string) ?? '后台')
  77. const userOptions = [
  78. { label: '修改密码', key: 'account' },
  79. { type: 'divider', key: 'd' },
  80. { label: '退出登录', key: 'logout' },
  81. ]
  82. function handleUserSelect(key: string) {
  83. if (key === 'logout') {
  84. setToken(null)
  85. message.success('已退出登录')
  86. router.replace({ path: '/login' })
  87. }
  88. }
  89. function goHome() {
  90. router.push('/courses/common-questions')
  91. }
  92. /** NMenu 无内置 router 集成;受控 :value 时需自行跳转,否则点击不会改变路由 */
  93. function handleMenuSelect(key: string | number) {
  94. const path = String(key)
  95. if (path.startsWith('/')) void router.push(path)
  96. }
  97. </script>
  98. <template>
  99. <NLayout has-sider class="admin-root" position="absolute">
  100. <NLayoutSider
  101. v-model:collapsed="sidebarCollapsed"
  102. bordered
  103. collapse-mode="width"
  104. :collapsed-width="72"
  105. :width="248"
  106. :native-scrollbar="false"
  107. show-trigger
  108. trigger-style="right: -14px; border-radius: 0 8px 8px 0;"
  109. class="admin-sider"
  110. >
  111. <div class="sider-inner" :class="{ 'is-collapsed': sidebarCollapsed }">
  112. <div class="brand" @click="goHome">
  113. <div class="brand-mark" aria-hidden="true" />
  114. <span v-if="!sidebarCollapsed" class="admin-logo">课程后台</span>
  115. </div>
  116. <NScrollbar class="sider-menu-scroll">
  117. <NMenu
  118. v-model:expanded-keys="expandedKeys"
  119. :value="activeKey"
  120. :options="menuOptions"
  121. :collapsed="sidebarCollapsed"
  122. :collapsed-width="72"
  123. :collapsed-icon-size="22"
  124. :indent="22"
  125. inverted
  126. @update:value="handleMenuSelect"
  127. />
  128. </NScrollbar>
  129. </div>
  130. </NLayoutSider>
  131. <!-- 使用独立列 + 内部滚动,避免顶栏与内容在同一 Layout 滚动容器内一起滚动 -->
  132. <div class="admin-right">
  133. <NLayoutHeader bordered class="admin-header">
  134. <div class="header-left">
  135. <NButton quaternary circle @click="app.toggleSidebar">
  136. <template #icon>
  137. <NIcon :component="MenuOutline" />
  138. </template>
  139. </NButton>
  140. <NBreadcrumb separator="/">
  141. <NBreadcrumbItem>首页</NBreadcrumbItem>
  142. <NBreadcrumbItem>{{ pageTitle }}</NBreadcrumbItem>
  143. </NBreadcrumb>
  144. </div>
  145. <div class="header-right">
  146. <NDropdown :options="userOptions" @select="handleUserSelect">
  147. <NAvatar
  148. round
  149. size="small"
  150. :style="{ cursor: 'pointer', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }"
  151. >
  152. </NAvatar>
  153. </NDropdown>
  154. </div>
  155. </NLayoutHeader>
  156. <div class="admin-route-tabs">
  157. <RouteTabBar />
  158. </div>
  159. <div class="admin-main">
  160. <div class="content-wrap page-enter">
  161. <RouterView />
  162. </div>
  163. </div>
  164. </div>
  165. </NLayout>
  166. </template>
  167. <style scoped>
  168. .admin-root {
  169. height: 100%;
  170. min-height: 100vh;
  171. }
  172. /* 与 NMenu 的 collapsedWidth 对齐:收起时去掉水平内边距,否则菜单按 72px 居中图标会偏右 */
  173. .sider-inner {
  174. padding: 20px 12px 24px;
  175. min-height: 100vh;
  176. box-sizing: border-box;
  177. }
  178. .sider-inner.is-collapsed {
  179. padding-left: 0;
  180. padding-right: 0;
  181. }
  182. .sider-menu-scroll {
  183. max-height: calc(100vh - 88px);
  184. }
  185. .brand {
  186. display: flex;
  187. align-items: center;
  188. gap: 12px;
  189. padding: 0 8px 24px;
  190. cursor: pointer;
  191. user-select: none;
  192. }
  193. .sider-inner.is-collapsed .brand {
  194. justify-content: center;
  195. padding-left: 0;
  196. padding-right: 0;
  197. }
  198. .brand-mark {
  199. width: 36px;
  200. height: 36px;
  201. border-radius: 10px;
  202. flex-shrink: 0;
  203. background: linear-gradient(135deg, #6366f1 0%, #a855f7 55%, #ec4899 100%);
  204. box-shadow: 0 8px 24px rgba(99, 102, 241, 0.35);
  205. }
  206. .admin-right {
  207. flex: 1;
  208. min-width: 0;
  209. min-height: 0;
  210. height: 100%;
  211. display: flex;
  212. flex-direction: column;
  213. overflow: hidden;
  214. background: var(--admin-bg, #f4f5f8);
  215. }
  216. .admin-header {
  217. flex-shrink: 0;
  218. position: sticky;
  219. top: 0;
  220. z-index: 200;
  221. display: flex;
  222. align-items: center;
  223. justify-content: space-between;
  224. height: 64px;
  225. padding: 0 24px;
  226. background: rgba(255, 255, 255, 0.92);
  227. backdrop-filter: blur(12px);
  228. box-shadow: 0 1px 0 var(--admin-border);
  229. }
  230. .admin-route-tabs {
  231. flex-shrink: 0;
  232. padding: 0 20px;
  233. background: rgba(255, 255, 255, 0.96);
  234. border-bottom: 1px solid var(--admin-border);
  235. box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
  236. }
  237. .admin-main {
  238. flex: 1;
  239. min-height: 0;
  240. overflow: hidden;
  241. display: flex;
  242. flex-direction: column;
  243. }
  244. .header-left,
  245. .header-right {
  246. display: flex;
  247. align-items: center;
  248. gap: 12px;
  249. }
  250. .content-wrap {
  251. box-sizing: border-box;
  252. width: 100%;
  253. flex: 1;
  254. min-height: 0;
  255. overflow: auto;
  256. padding: 20px;
  257. max-width: 100%;
  258. display: flex;
  259. flex-direction: column;
  260. }
  261. </style>