xto-fronted 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.env.development +7 -7
  2. package/.env.production +7 -7
  3. package/dist/assets/403-AFBQifUI.js +1 -0
  4. package/dist/assets/403-BHEXXbt2.css +1 -0
  5. package/dist/assets/404-Ct_A1n7S.css +1 -0
  6. package/dist/assets/404-WFvpcD2_.js +1 -0
  7. package/dist/assets/_plugin-vue_export-helper-DlAUqK2U.js +1 -0
  8. package/dist/assets/index-1juADvYN.js +2 -0
  9. package/dist/assets/index-B-sX4Ru0.js +1 -0
  10. package/dist/assets/index-BMcziU5a.css +1 -0
  11. package/dist/assets/index-BRR97dc6.js +1 -0
  12. package/dist/assets/index-BZA0ksjx.css +1 -0
  13. package/dist/assets/index-BpV_8nl0.js +1 -0
  14. package/dist/assets/index-BvzhR4zp.js +1 -0
  15. package/dist/assets/index-CUh_s55Z.css +1 -0
  16. package/dist/assets/index-CVjdnIgR.css +1 -0
  17. package/dist/assets/index-CYq57-zj.js +1 -0
  18. package/dist/assets/index-CkL3sVAQ.js +2 -0
  19. package/dist/assets/index-CtrKVYJb.css +1 -0
  20. package/dist/assets/index-Cz2P_bsS.js +1 -0
  21. package/dist/assets/index-D9wlAuR_.js +1 -0
  22. package/dist/assets/index-DawJb02s.css +1 -0
  23. package/dist/assets/index-DfFR6NLf.js +1 -0
  24. package/dist/assets/index-DwVgMO8e.js +1 -0
  25. package/dist/assets/index-GDP-IkXE.css +1 -0
  26. package/dist/assets/index-Iaz1ZzPC.js +2 -0
  27. package/dist/assets/index-PfV8pzQz.css +1 -0
  28. package/dist/assets/index-Swfu6yvD.css +1 -0
  29. package/dist/assets/index-Te8_PRgJ.js +1 -0
  30. package/dist/assets/index-WyZ91RLx.css +1 -0
  31. package/dist/assets/index-tFYRoFdE.js +1 -0
  32. package/dist/assets/vendor-42ANG6Sg.js +6 -0
  33. package/dist/assets/vite-Dw-pgLOX.js +1 -0
  34. package/dist/assets/vue-vendor-Br-l7wbK.js +29 -0
  35. package/dist/assets/xto-base-C-IBqjVs.js +1 -0
  36. package/dist/assets/xto-base-C6eqMPdO.css +1 -0
  37. package/dist/assets/xto-business--V1F5Gwb.css +1 -0
  38. package/dist/assets/xto-core-DZK7Cyg0.js +1 -0
  39. package/dist/assets/xto-data-BFpiDgJi.js +1 -0
  40. package/dist/assets/xto-data-CnAQAQH2.css +1 -0
  41. package/dist/assets/xto-feedback-B7ipsTfz.js +1 -0
  42. package/dist/assets/xto-feedback-DBwJzoTj.css +1 -0
  43. package/dist/assets/xto-form-CrsyAjyr.css +1 -0
  44. package/dist/assets/xto-form-NRjKKNcY.js +1 -0
  45. package/dist/assets/xto-layout-BqU8RuWL.css +1 -0
  46. package/dist/assets/xto-navigation-BiSaXPfr.js +1 -0
  47. package/dist/assets/xto-navigation-C1cnSL2E.css +1 -0
  48. package/dist/assets/xto-navigation-CBPg4dCc.css +1 -0
  49. package/dist/assets/xto-navigation-CKabFu9d.js +1 -0
  50. package/dist/index-54irhCHL.js +1830 -0
  51. package/dist/{index-15Bu0M8D.js → index-BzRf1eoJ.js} +1 -1
  52. package/dist/{index-BO2Zf9u6.js → index-DH4aoCZb.js} +1 -1
  53. package/dist/{index-BBqvHkzE.js → index-Kqa7iZ9E.js} +1 -1
  54. package/dist/{index-BQqo0ZIb.js → index-pxkZlvBw.js} +1 -1
  55. package/dist/index.es.js +1 -1
  56. package/dist/index.html +28 -0
  57. package/dist/index.umd.js +1 -1
  58. package/dist/style.css +1 -1
  59. package/package.json +85 -86
  60. package/src/api/auth.ts +25 -25
  61. package/src/api/system.ts +66 -66
  62. package/src/assets/styles/_dark.scss +406 -406
  63. package/src/components/Layout/Header.vue +973 -973
  64. package/src/components/Layout/Sidebar.vue +273 -212
  65. package/src/components/Layout/index.vue +443 -63
  66. package/src/composables/useApp.ts +61 -61
  67. package/src/composables/useAuth.ts +16 -16
  68. package/src/directives/permission.ts +27 -27
  69. package/src/env.d.ts +18 -18
  70. package/src/index.ts +47 -47
  71. package/src/router/dynamicRoutes.ts +162 -162
  72. package/src/router/guards.ts +128 -128
  73. package/src/router/index.ts +79 -79
  74. package/src/stores/auth.ts +65 -65
  75. package/src/stores/menu.ts +48 -48
  76. package/src/stores/user.ts +50 -50
  77. package/src/types/api.d.ts +80 -80
  78. package/src/utils/auth.ts +99 -99
  79. package/src/utils/config.ts +80 -80
  80. package/src/utils/permission.ts +32 -32
  81. package/src/utils/request.ts +124 -124
  82. package/src/views/login/index.vue +194 -188
  83. package/vite.config.ts +135 -135
  84. package/dist/index-B3PLzNB0.js +0 -345
  85. package/dist/index-B6DTsC6l.js +0 -1715
  86. package/dist/index-B7etKk33.js +0 -372
  87. package/dist/index-B7mpL6Zf.js +0 -475
  88. package/dist/index-BC2PGkkJ.js +0 -1644
  89. package/dist/index-BGgbfcmf.js +0 -475
  90. package/dist/index-BGmUwemj.js +0 -372
  91. package/dist/index-BQFfQj5Q.js +0 -142
  92. package/dist/index-BkRneTya.js +0 -142
  93. package/dist/index-BlRrngsc.js +0 -475
  94. package/dist/index-BmVvM7sm.js +0 -345
  95. package/dist/index-Bn4ThpX9.js +0 -142
  96. package/dist/index-BwfjwDKr.js +0 -1477
  97. package/dist/index-BxIL2hrt.js +0 -475
  98. package/dist/index-C-3fhbN2.js +0 -1644
  99. package/dist/index-C0VN9nFF.js +0 -142
  100. package/dist/index-C0xyGOsz.js +0 -475
  101. package/dist/index-C3c8NAZq.js +0 -1477
  102. package/dist/index-C42VtP71.js +0 -142
  103. package/dist/index-C6Nm0r9k.js +0 -475
  104. package/dist/index-C6w0-8xN.js +0 -1648
  105. package/dist/index-CD364XjV.js +0 -142
  106. package/dist/index-CHww99-i.js +0 -345
  107. package/dist/index-CIgWYERJ.js +0 -1644
  108. package/dist/index-CTs6DTuQ.js +0 -345
  109. package/dist/index-CWRs4WMN.js +0 -372
  110. package/dist/index-Cb-SxHJp.js +0 -345
  111. package/dist/index-CeCysOnl.js +0 -345
  112. package/dist/index-Cg1UpC8D.js +0 -1644
  113. package/dist/index-Cgkqpyx2.js +0 -345
  114. package/dist/index-CiuDEfo-.js +0 -142
  115. package/dist/index-CmQfZC8r.js +0 -372
  116. package/dist/index-CmkjhpX_.js +0 -475
  117. package/dist/index-CpxpXTQX.js +0 -1462
  118. package/dist/index-CqXFk_ET.js +0 -345
  119. package/dist/index-Cqix1YLE.js +0 -1697
  120. package/dist/index-CtvB5J9E.js +0 -372
  121. package/dist/index-Cu3Z2-PY.js +0 -345
  122. package/dist/index-CvDxK7Ab.js +0 -372
  123. package/dist/index-D-FER0vJ.js +0 -372
  124. package/dist/index-D2fQ8TK8.js +0 -475
  125. package/dist/index-D3xVcFvg.js +0 -372
  126. package/dist/index-D4crnrO6.js +0 -142
  127. package/dist/index-D7EzwTM5.js +0 -372
  128. package/dist/index-D7TZamyY.js +0 -1664
  129. package/dist/index-D88fiqXR.js +0 -475
  130. package/dist/index-DEbpF-M4.js +0 -1457
  131. package/dist/index-DFXuyPge.js +0 -1627
  132. package/dist/index-DLgimJYb.js +0 -1667
  133. package/dist/index-DPEVEyik.js +0 -475
  134. package/dist/index-DWy_UGhI.js +0 -345
  135. package/dist/index-DYVtddfw.js +0 -142
  136. package/dist/index-DYnXaqYf.js +0 -142
  137. package/dist/index-DcvRPHuy.js +0 -372
  138. package/dist/index-DdC1uV2v.js +0 -1700
  139. package/dist/index-Dga14ZN7.js +0 -1774
  140. package/dist/index-Dk2V44uP.js +0 -372
  141. package/dist/index-DnJ481u1.js +0 -475
  142. package/dist/index-Do1CBqg8.js +0 -345
  143. package/dist/index-DqQRSPeF.js +0 -345
  144. package/dist/index-Jb4VMHIS.js +0 -142
  145. package/dist/index-MC3wWjNt.js +0 -475
  146. package/dist/index-MG0JePmx.js +0 -142
  147. package/dist/index-PRFGBLWt.js +0 -475
  148. package/dist/index-QgkT42dc.js +0 -372
  149. package/dist/index-TrLCW5xL.js +0 -372
  150. package/dist/index-YDlNLFVk.js +0 -142
  151. package/dist/index-ZAJgA3XD.js +0 -475
  152. package/dist/index-a_ilWAvi.js +0 -345
  153. package/dist/index-bi1TMGid.js +0 -372
  154. package/dist/index-fyarVCog.js +0 -475
  155. package/dist/index-mnTZtPFa.js +0 -345
  156. package/dist/index-orZCyV6I.js +0 -345
  157. package/dist/index-p3TbK44c.js +0 -142
  158. package/dist/index-sRwZYbZ4.js +0 -372
  159. package/dist/index-wATqKEcF.js +0 -142
  160. package/dist/setup.d.ts +0 -17
@@ -1,974 +1,974 @@
1
- <script setup lang="ts">
2
- import { ref, computed, onMounted, onUnmounted } from 'vue'
3
- import { useRoute, useRouter } from 'vue-router'
4
- import { useAppStore } from '@/stores/app'
5
- import { useUserStore } from '@/stores/user'
6
- import { useAuthStore } from '@/stores/auth'
7
- import { useMenuStore } from '@/stores/menu'
8
- import { Icon } from '@xto/base'
9
- import { Drawer } from '@xto/feedback'
10
-
11
- type LayoutMode = 'sidebar' | 'top' | 'mix'
12
-
13
- const route = useRoute()
14
- const router = useRouter()
15
- const appStore = useAppStore()
16
- const userStore = useUserStore()
17
- const authStore = useAuthStore()
18
- const menuStore = useMenuStore()
19
-
20
- const dropdownVisible = ref(false)
21
- const drawerVisible = ref(false)
22
- const dropdownRef = ref<HTMLElement | null>(null)
23
- const isFullscreen = ref(false)
24
- const searchVisible = ref(false)
25
- const searchKeyword = ref('')
26
- const searchRef = ref<HTMLElement | null>(null)
27
-
28
- // 灰色模式状态
29
- const greyMode = ref(false)
30
-
31
- // 布局模式选项
32
- const layoutOptions: { value: LayoutMode; label: string; icon: string }[] = [
33
- { value: 'sidebar', label: '左侧菜单', icon: 'sidebar-left' },
34
- { value: 'top', label: '顶部菜单', icon: 'menu' },
35
- { value: 'mix', label: '混合菜单', icon: 'grid' }
36
- ]
37
-
38
- // 主题色选项
39
- const colorOptions = [
40
- { value: '#409eff', label: '默认蓝' },
41
- { value: '#1890ff', label: '科技蓝' },
42
- { value: '#52c41a', label: '极光绿' },
43
- { value: '#faad14', label: '日落橙' },
44
- { value: '#f5222d', label: '薄暮红' },
45
- { value: '#722ed1', label: '酱紫' }
46
- ]
47
-
48
- // 面包屑
49
- const breadcrumbs = computed(() => {
50
- const matched = route.matched.filter(item => item.meta && item.meta.title)
51
- return matched.map(item => ({
52
- title: item.meta.title as string,
53
- path: item.path
54
- }))
55
- })
56
-
57
- // 扁平化菜单用于搜索
58
- const flattenMenus = (menus: any[], parentTitle = ''): any[] => {
59
- const result: any[] = []
60
- menus.forEach(menu => {
61
- if (menu.children && menu.children.length > 0) {
62
- result.push(...flattenMenus(menu.children, menu.title))
63
- } else {
64
- result.push({ ...menu, parentTitle })
65
- }
66
- })
67
- return result
68
- }
69
-
70
- // 搜索结果
71
- const searchResults = computed(() => {
72
- if (!searchKeyword.value.trim()) return []
73
- const flatMenus = flattenMenus(menuStore.menuList)
74
- return flatMenus.filter(menu =>
75
- menu.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
76
- )
77
- })
78
-
79
- // 当前布局模式
80
- const currentLayout = computed(() => appStore.layout)
81
-
82
- // 切换折叠
83
- const toggleCollapse = () => {
84
- appStore.toggleCollapse()
85
- }
86
-
87
- // 切换主题
88
- const toggleTheme = () => {
89
- appStore.toggleTheme()
90
- }
91
-
92
- // 打开设置抽屉
93
- const openSettingsDrawer = () => {
94
- drawerVisible.value = true
95
- }
96
-
97
- // 切换布局模式
98
- const handleLayoutChange = (mode: LayoutMode) => {
99
- appStore.setLayout(mode)
100
- }
101
-
102
- // 切换灰色模式
103
- const handleGreyModeChange = (value: boolean) => {
104
- greyMode.value = value
105
- const html = document.documentElement
106
- if (value) {
107
- html.classList.add('grey-mode')
108
- } else {
109
- html.classList.remove('grey-mode')
110
- }
111
- }
112
-
113
- // 切换全屏
114
- const toggleFullscreen = () => {
115
- if (!document.fullscreenElement) {
116
- document.documentElement.requestFullscreen()
117
- } else {
118
- document.exitFullscreen()
119
- }
120
- }
121
-
122
- // 监听全屏变化
123
- const handleFullscreenChange = () => {
124
- isFullscreen.value = !!document.fullscreenElement
125
- }
126
-
127
- // 切换下拉菜单
128
- const toggleDropdown = () => {
129
- dropdownVisible.value = !dropdownVisible.value
130
- }
131
-
132
- // 关闭下拉菜单
133
- const closeDropdowns = () => {
134
- dropdownVisible.value = false
135
- }
136
-
137
- // 显示搜索
138
- const showSearch = () => {
139
- searchVisible.value = true
140
- }
141
-
142
- // 隐藏搜索
143
- const hideSearch = () => {
144
- searchVisible.value = false
145
- searchKeyword.value = ''
146
- }
147
-
148
- // 搜索结果点击
149
- const handleSearchItemClick = (path: string) => {
150
- router.push(path)
151
- hideSearch()
152
- }
153
-
154
- // 设置主题色
155
- const handleColorChange = (color: string) => {
156
- appStore.setPrimaryColor(color)
157
- }
158
-
159
- // 个人信息
160
- const handleProfile = () => {
161
- closeDropdowns()
162
- router.push('/profile')
163
- }
164
-
165
- // 修改密码
166
- const handleChangePassword = () => {
167
- closeDropdowns()
168
- router.push('/change-password')
169
- }
170
-
171
- // 退出登录
172
- const handleLogout = () => {
173
- closeDropdowns()
174
- authStore.logout()
175
- userStore.clearUserInfo()
176
- menuStore.clearMenu()
177
- router.push('/login')
178
- }
179
-
180
- // 点击外部关闭下拉菜单
181
- const handleClickOutside = (event: MouseEvent) => {
182
- if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
183
- closeDropdowns()
184
- }
185
- if (searchRef.value && !searchRef.value.contains(event.target as Node)) {
186
- hideSearch()
187
- }
188
- }
189
-
190
- // 键盘快捷键
191
- const handleKeydown = (event: KeyboardEvent) => {
192
- if (event.key === 'Escape') {
193
- hideSearch()
194
- closeDropdowns()
195
- }
196
- // Ctrl+K 打开搜索
197
- if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
198
- event.preventDefault()
199
- showSearch()
200
- }
201
- }
202
-
203
- onMounted(() => {
204
- document.addEventListener('click', handleClickOutside)
205
- document.addEventListener('fullscreenchange', handleFullscreenChange)
206
- document.addEventListener('keydown', handleKeydown)
207
- appStore.initTheme()
208
- // 初始化灰色模式状态
209
- greyMode.value = document.documentElement.classList.contains('grey-mode')
210
- })
211
-
212
- onUnmounted(() => {
213
- document.removeEventListener('click', handleClickOutside)
214
- document.removeEventListener('fullscreenchange', handleFullscreenChange)
215
- document.removeEventListener('keydown', handleKeydown)
216
- })
217
- </script>
218
-
219
- <template>
220
- <div class="header">
221
- <!-- 左侧 -->
222
- <div class="header__left">
223
- <!-- 折叠按钮 -->
224
- <div class="header__collapse" @click="toggleCollapse">
225
- <Icon :name="appStore.isCollapsed ? 'menu-unfold' : 'menu-fold'" :size="18" />
226
- </div>
227
-
228
- <!-- 面包屑 -->
229
- <div v-if="appStore.showBreadcrumb" class="header__breadcrumb">
230
- <span v-for="(item, index) in breadcrumbs" :key="item.path">
231
- <span v-if="index > 0" class="breadcrumb-separator">/</span>
232
- <span :class="{ 'is-current': index === breadcrumbs.length - 1 }">
233
- {{ item.title }}
234
- </span>
235
- </span>
236
- </div>
237
- </div>
238
-
239
- <!-- 右侧 -->
240
- <div class="header__right">
241
- <!-- 搜索按钮 -->
242
- <div class="header__action" @click="showSearch" title="搜索 (Ctrl+K)">
243
- <Icon name="search" :size="16" />
244
- </div>
245
-
246
- <!-- 全屏切换 -->
247
- <div class="header__action" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
248
- <Icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" :size="16" />
249
- </div>
250
-
251
- <!-- 换肤设置 -->
252
- <div class="header__action" @click="openSettingsDrawer" title="换肤设置">
253
- <Icon name="setting" :size="16" />
254
- </div>
255
-
256
- <!-- 主题切换 -->
257
- <div class="header__action" @click="toggleTheme" title="切换主题">
258
- <Icon :name="appStore.isDark ? 'sun' : 'moon'" :size="16" />
259
- </div>
260
-
261
- <!-- 用户头像 -->
262
- <div class="header__user" ref="dropdownRef">
263
- <div class="header__user-trigger" @click.stop="toggleDropdown">
264
- <div class="header__avatar">
265
- <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
266
- </div>
267
- <span class="header__user-name">{{ userStore.userName }}</span>
268
- <span class="header__user-arrow" :class="{ 'is-active': dropdownVisible }">▼</span>
269
- </div>
270
-
271
- <!-- 下拉菜单 -->
272
- <Transition name="dropdown">
273
- <div v-if="dropdownVisible" class="header__dropdown">
274
- <div class="header__dropdown-header">
275
- <div class="header__dropdown-avatar">
276
- <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
277
- </div>
278
- <div class="header__dropdown-info">
279
- <div class="header__dropdown-name">{{ userStore.userName }}</div>
280
- <div class="header__dropdown-role">{{ userStore.departmentName }}</div>
281
- </div>
282
- </div>
283
- <div class="header__dropdown-divider"></div>
284
- <div class="header__dropdown-menu">
285
- <div class="header__dropdown-item" @click="handleProfile">
286
- <Icon name="user" :size="16" />
287
- <span>个人信息</span>
288
- </div>
289
- <div class="header__dropdown-item" @click="handleChangePassword">
290
- <Icon name="lock" :size="16" />
291
- <span>修改密码</span>
292
- </div>
293
- <div class="header__dropdown-divider"></div>
294
- <div class="header__dropdown-item header__dropdown-item--danger" @click="handleLogout">
295
- <Icon name="logout" :size="16" />
296
- <span>退出登录</span>
297
- </div>
298
- </div>
299
- </div>
300
- </Transition>
301
- </div>
302
- </div>
303
-
304
- <!-- 全局搜索弹窗 -->
305
- <Transition name="search">
306
- <div v-if="searchVisible" class="header__search-modal" ref="searchRef">
307
- <div class="search-container">
308
- <div class="search-input-wrapper">
309
- <Icon class="search-icon" name="search" :size="20" />
310
- <input
311
- v-model="searchKeyword"
312
- type="text"
313
- class="search-input"
314
- placeholder="搜索菜单..."
315
- autofocus
316
- @keyup.enter="searchResults[0] && handleSearchItemClick(searchResults[0].path)"
317
- />
318
- <span class="search-shortcut">ESC 关闭</span>
319
- </div>
320
- <div v-if="searchResults.length > 0" class="search-results">
321
- <div
322
- v-for="(item, index) in searchResults"
323
- :key="item.path"
324
- :class="['search-result-item', { 'is-first': index === 0 }]"
325
- @click="handleSearchItemClick(item.path)"
326
- >
327
- <Icon class="search-result-icon" :name="item.icon || 'file'" :size="20" />
328
- <div class="search-result-info">
329
- <span class="search-result-title">{{ item.title }}</span>
330
- <span v-if="item.parentTitle" class="search-result-parent">{{ item.parentTitle }}</span>
331
- </div>
332
- </div>
333
- </div>
334
- <div v-else-if="searchKeyword" class="search-empty">
335
- 未找到匹配的菜单
336
- </div>
337
- </div>
338
- </div>
339
- </Transition>
340
-
341
- <!-- 换肤设置抽屉 -->
342
- <Drawer
343
- v-model="drawerVisible"
344
- title="换肤设置"
345
- direction="rtl"
346
- size="320px"
347
- >
348
- <div class="settings-drawer">
349
- <!-- 布局模式 -->
350
- <div class="settings-section">
351
- <div class="settings-title">布局模式</div>
352
- <div class="settings-layout-options">
353
- <div
354
- v-for="option in layoutOptions"
355
- :key="option.value"
356
- :class="['layout-option', { 'is-active': currentLayout === option.value }]"
357
- @click="handleLayoutChange(option.value)"
358
- >
359
- <div class="layout-option__preview">
360
- <div v-if="option.value === 'sidebar'" class="layout-preview-sidebar">
361
- <div class="preview-aside"></div>
362
- <div class="preview-main">
363
- <div class="preview-header"></div>
364
- <div class="preview-content"></div>
365
- </div>
366
- </div>
367
- <div v-else-if="option.value === 'top'" class="layout-preview-top">
368
- <div class="preview-header-full"></div>
369
- <div class="preview-content-full"></div>
370
- </div>
371
- <div v-else class="layout-preview-mix">
372
- <div class="preview-header-mix">
373
- <div class="preview-mix-left"></div>
374
- </div>
375
- <div class="preview-mix-body">
376
- <div class="preview-mix-aside"></div>
377
- <div class="preview-mix-content"></div>
378
- </div>
379
- </div>
380
- </div>
381
- <span class="layout-option__label">{{ option.label }}</span>
382
- </div>
383
- </div>
384
- </div>
385
-
386
- <!-- 主题色 -->
387
- <div class="settings-section">
388
- <div class="settings-title">主题色</div>
389
- <div class="settings-color-options">
390
- <div
391
- v-for="color in colorOptions"
392
- :key="color.value"
393
- :class="['color-option', { 'is-active': appStore.primaryColor === color.value }]"
394
- :style="{ backgroundColor: color.value }"
395
- :title="color.label"
396
- @click="handleColorChange(color.value)"
397
- >
398
- <Icon v-if="appStore.primaryColor === color.value" name="check" :size="12" color="#fff" />
399
- </div>
400
- </div>
401
- </div>
402
-
403
- <!-- 功能开关 -->
404
- <div class="settings-section">
405
- <div class="settings-title">功能设置</div>
406
- <div class="settings-switch-list">
407
- <div class="settings-switch-item">
408
- <span>灰色模式</span>
409
- <div class="switch-wrapper" :class="{ 'is-checked': greyMode }" @click="handleGreyModeChange(!greyMode)">
410
- <span class="switch-core"></span>
411
- </div>
412
- </div>
413
- <div class="settings-switch-item">
414
- <span>暗黑模式</span>
415
- <div class="switch-wrapper" :class="{ 'is-checked': appStore.isDark }" @click="toggleTheme">
416
- <span class="switch-core"></span>
417
- </div>
418
- </div>
419
- </div>
420
- </div>
421
- </div>
422
- </Drawer>
423
- </div>
424
- </template>
425
-
426
- <style lang="scss" scoped>
427
- .header {
428
- display: flex;
429
- align-items: center;
430
- justify-content: space-between;
431
- padding: 0 20px;
432
- height: 100%;
433
- background-color: var(--bg-color);
434
- border-bottom: 1px solid var(--color-border-lighter);
435
-
436
- &__left {
437
- display: flex;
438
- align-items: center;
439
- gap: 15px;
440
- }
441
-
442
- &__collapse {
443
- width: 24px;
444
- height: 24px;
445
- display: flex;
446
- align-items: center;
447
- justify-content: center;
448
- cursor: pointer;
449
- color: var(--color-text-regular);
450
- transition: color 0.2s;
451
-
452
- &:hover {
453
- color: var(--color-primary);
454
- }
455
- }
456
-
457
- &__breadcrumb {
458
- font-size: 14px;
459
- color: var(--color-text-secondary);
460
-
461
- .breadcrumb-separator {
462
- margin: 0 8px;
463
- color: var(--color-text-placeholder);
464
- }
465
-
466
- .is-current {
467
- color: var(--color-text-primary);
468
- font-weight: 500;
469
- }
470
- }
471
-
472
- &__right {
473
- display: flex;
474
- align-items: center;
475
- gap: 8px;
476
- }
477
-
478
- &__action {
479
- width: 32px;
480
- height: 32px;
481
- display: flex;
482
- align-items: center;
483
- justify-content: center;
484
- cursor: pointer;
485
- border-radius: var(--border-radius-base);
486
- color: var(--color-text-regular);
487
- transition: all 0.2s;
488
-
489
- &:hover {
490
- background-color: var(--color-fill);
491
- color: var(--color-primary);
492
- }
493
- }
494
-
495
- &__user {
496
- position: relative;
497
- margin-left: 8px;
498
-
499
- &-trigger {
500
- display: flex;
501
- align-items: center;
502
- gap: 8px;
503
- cursor: pointer;
504
- padding: 4px 8px;
505
- border-radius: var(--border-radius-base);
506
- transition: background-color 0.2s;
507
-
508
- &:hover {
509
- background-color: var(--color-fill);
510
- }
511
- }
512
-
513
- &-name {
514
- font-size: 14px;
515
- color: var(--color-text-primary);
516
- max-width: 100px;
517
- overflow: hidden;
518
- text-overflow: ellipsis;
519
- white-space: nowrap;
520
- }
521
-
522
- &-arrow {
523
- font-size: 10px;
524
- color: var(--color-text-secondary);
525
- transition: transform 0.2s;
526
-
527
- &.is-active {
528
- transform: rotate(180deg);
529
- }
530
- }
531
- }
532
-
533
- &__avatar {
534
- width: 32px;
535
- height: 32px;
536
- border-radius: 50%;
537
- background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
538
- display: flex;
539
- align-items: center;
540
- justify-content: center;
541
- color: #fff;
542
- font-size: 14px;
543
- font-weight: 500;
544
- }
545
-
546
- &__dropdown {
547
- position: absolute;
548
- top: calc(100% + 8px);
549
- right: 0;
550
- min-width: 200px;
551
- background-color: var(--bg-color);
552
- border-radius: var(--border-radius-base);
553
- box-shadow: var(--box-shadow);
554
- overflow: hidden;
555
- z-index: 100;
556
-
557
- &-header {
558
- display: flex;
559
- align-items: center;
560
- gap: 12px;
561
- padding: 16px;
562
- }
563
-
564
- &-avatar {
565
- width: 40px;
566
- height: 40px;
567
- border-radius: 50%;
568
- background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
569
- display: flex;
570
- align-items: center;
571
- justify-content: center;
572
- color: #fff;
573
- font-size: 16px;
574
- font-weight: 500;
575
- }
576
-
577
- &-info {
578
- flex: 1;
579
- }
580
-
581
- &-name {
582
- font-size: 14px;
583
- font-weight: 500;
584
- color: var(--color-text-primary);
585
- }
586
-
587
- &-role {
588
- font-size: 12px;
589
- color: var(--color-text-secondary);
590
- margin-top: 2px;
591
- }
592
-
593
- &-divider {
594
- height: 1px;
595
- background-color: var(--color-border-lighter);
596
- }
597
-
598
- &-menu {
599
- padding: 8px 0;
600
- }
601
-
602
- &-item {
603
- display: flex;
604
- align-items: center;
605
- gap: 10px;
606
- padding: 10px 16px;
607
- cursor: pointer;
608
- font-size: 14px;
609
- color: var(--color-text-regular);
610
- transition: all 0.2s;
611
-
612
- &:hover {
613
- background-color: var(--color-fill);
614
- color: var(--color-text-primary);
615
- }
616
-
617
- &--danger {
618
- color: var(--color-danger);
619
-
620
- &:hover {
621
- background-color: var(--color-danger-light);
622
- color: var(--color-danger);
623
- }
624
- }
625
- }
626
- }
627
-
628
- &__search-modal {
629
- position: fixed;
630
- top: 0;
631
- left: 0;
632
- right: 0;
633
- bottom: 0;
634
- background-color: rgba(0, 0, 0, 0.5);
635
- display: flex;
636
- align-items: flex-start;
637
- justify-content: center;
638
- padding-top: 100px;
639
- z-index: 200;
640
- }
641
- }
642
-
643
- // 搜索容器
644
- .search-container {
645
- width: 600px;
646
- max-width: 90vw;
647
- background-color: var(--bg-color);
648
- border-radius: var(--border-radius-large);
649
- box-shadow: var(--box-shadow-dark);
650
- overflow: hidden;
651
- }
652
-
653
- .search-input-wrapper {
654
- display: flex;
655
- align-items: center;
656
- padding: 16px 20px;
657
- border-bottom: 1px solid var(--color-border-lighter);
658
-
659
- .search-icon {
660
- color: var(--color-text-secondary);
661
- margin-right: 12px;
662
- }
663
-
664
- .search-input {
665
- flex: 1;
666
- font-size: 16px;
667
- color: var(--color-text-primary);
668
- background: transparent;
669
- border: none;
670
- outline: none;
671
-
672
- &::placeholder {
673
- color: var(--color-text-placeholder);
674
- }
675
- }
676
-
677
- .search-shortcut {
678
- font-size: 12px;
679
- color: var(--color-text-secondary);
680
- padding: 4px 8px;
681
- background-color: var(--color-fill);
682
- border-radius: var(--border-radius-base);
683
- }
684
- }
685
-
686
- .search-results {
687
- max-height: 400px;
688
- overflow-y: auto;
689
- padding: 8px 0;
690
- }
691
-
692
- .search-result-item {
693
- display: flex;
694
- align-items: center;
695
- gap: 12px;
696
- padding: 12px 20px;
697
- cursor: pointer;
698
- transition: background-color 0.2s;
699
-
700
- &:hover {
701
- background-color: var(--color-fill);
702
- }
703
-
704
- &.is-first {
705
- background-color: var(--color-primary-light-9);
706
- }
707
-
708
- .search-result-icon {
709
- color: var(--color-text-secondary);
710
- }
711
-
712
- .search-result-info {
713
- flex: 1;
714
- display: flex;
715
- flex-direction: column;
716
- gap: 2px;
717
- }
718
-
719
- .search-result-title {
720
- font-size: 14px;
721
- color: var(--color-text-primary);
722
- }
723
-
724
- .search-result-parent {
725
- font-size: 12px;
726
- color: var(--color-text-secondary);
727
- }
728
- }
729
-
730
- .search-empty {
731
- padding: 40px 20px;
732
- text-align: center;
733
- color: var(--color-text-secondary);
734
- font-size: 14px;
735
- }
736
-
737
- // 设置抽屉内容
738
- .settings-drawer {
739
- .settings-section {
740
- margin-bottom: 24px;
741
- }
742
-
743
- .settings-title {
744
- font-size: 14px;
745
- font-weight: 500;
746
- color: var(--color-text-primary);
747
- margin-bottom: 12px;
748
- }
749
-
750
- .settings-layout-options {
751
- display: flex;
752
- gap: 12px;
753
- }
754
-
755
- .layout-option {
756
- flex: 1;
757
- display: flex;
758
- flex-direction: column;
759
- align-items: center;
760
- gap: 8px;
761
- padding: 12px;
762
- border: 1px solid var(--color-border);
763
- border-radius: var(--border-radius-base);
764
- cursor: pointer;
765
- transition: all 0.2s;
766
-
767
- &:hover {
768
- border-color: var(--color-primary-light-5);
769
- }
770
-
771
- &.is-active {
772
- border-color: var(--color-primary);
773
- background-color: var(--color-primary-light-9);
774
- }
775
-
776
- &__preview {
777
- width: 48px;
778
- height: 36px;
779
- border: 1px solid var(--color-border-light);
780
- border-radius: 2px;
781
- overflow: hidden;
782
- }
783
-
784
- &__label {
785
- font-size: 12px;
786
- color: var(--color-text-regular);
787
- }
788
- }
789
-
790
- // 布局预览样式
791
- .layout-preview-sidebar {
792
- display: flex;
793
- height: 100%;
794
-
795
- .preview-aside {
796
- width: 25%;
797
- height: 100%;
798
- background-color: var(--color-primary-light-7);
799
- }
800
-
801
- .preview-main {
802
- flex: 1;
803
- display: flex;
804
- flex-direction: column;
805
-
806
- .preview-header {
807
- height: 20%;
808
- background-color: var(--color-border-light);
809
- }
810
-
811
- .preview-content {
812
- flex: 1;
813
- background-color: var(--bg-color-page);
814
- }
815
- }
816
- }
817
-
818
- .layout-preview-top {
819
- display: flex;
820
- flex-direction: column;
821
- height: 100%;
822
-
823
- .preview-header-full {
824
- height: 25%;
825
- background-color: var(--color-primary-light-7);
826
- }
827
-
828
- .preview-content-full {
829
- flex: 1;
830
- background-color: var(--bg-color-page);
831
- }
832
- }
833
-
834
- .layout-preview-mix {
835
- display: flex;
836
- flex-direction: column;
837
- height: 100%;
838
-
839
- .preview-header-mix {
840
- height: 25%;
841
- background-color: var(--color-primary-light-7);
842
- display: flex;
843
-
844
- .preview-mix-left {
845
- width: 30%;
846
- background-color: var(--color-primary);
847
- }
848
- }
849
-
850
- .preview-mix-body {
851
- flex: 1;
852
- display: flex;
853
-
854
- .preview-mix-aside {
855
- width: 25%;
856
- background-color: var(--color-primary-light-8);
857
- }
858
-
859
- .preview-mix-content {
860
- flex: 1;
861
- background-color: var(--bg-color-page);
862
- }
863
- }
864
- }
865
-
866
- .settings-color-options {
867
- display: flex;
868
- gap: 12px;
869
- }
870
-
871
- .color-option {
872
- width: 24px;
873
- height: 24px;
874
- border-radius: 4px;
875
- cursor: pointer;
876
- display: flex;
877
- align-items: center;
878
- justify-content: center;
879
- transition: transform 0.2s;
880
-
881
- &:hover {
882
- transform: scale(1.1);
883
- }
884
-
885
- &.is-active {
886
- box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--color-primary);
887
- }
888
- }
889
-
890
- .settings-switch-list {
891
- display: flex;
892
- flex-direction: column;
893
- gap: 12px;
894
- }
895
-
896
- .settings-switch-item {
897
- display: flex;
898
- align-items: center;
899
- justify-content: space-between;
900
-
901
- span {
902
- font-size: 14px;
903
- color: var(--color-text-regular);
904
- }
905
- }
906
-
907
- .switch-wrapper {
908
- width: 44px;
909
- height: 22px;
910
- display: flex;
911
- align-items: center;
912
- cursor: pointer;
913
-
914
- .switch-core {
915
- width: 100%;
916
- height: 100%;
917
- border-radius: 11px;
918
- background-color: var(--color-border);
919
- position: relative;
920
- transition: background-color 0.2s;
921
-
922
- &::after {
923
- content: '';
924
- position: absolute;
925
- top: 2px;
926
- left: 2px;
927
- width: 18px;
928
- height: 18px;
929
- background-color: #fff;
930
- border-radius: 50%;
931
- transition: left 0.2s;
932
- }
933
- }
934
-
935
- &.is-checked {
936
- .switch-core {
937
- background-color: var(--color-primary);
938
-
939
- &::after {
940
- left: 24px;
941
- }
942
- }
943
- }
944
- }
945
- }
946
-
947
- // 下拉动画
948
- .dropdown-enter-active,
949
- .dropdown-leave-active {
950
- transition: all 0.2s ease;
951
- }
952
-
953
- .dropdown-enter-from,
954
- .dropdown-leave-to {
955
- opacity: 0;
956
- transform: translateY(-10px);
957
- }
958
-
959
- // 搜索弹窗动画
960
- .search-enter-active,
961
- .search-leave-active {
962
- transition: all 0.2s ease;
963
- }
964
-
965
- .search-enter-from,
966
- .search-leave-to {
967
- opacity: 0;
968
- }
969
-
970
- // 灰色模式
971
- :root.grey-mode {
972
- filter: grayscale(100%);
973
- }
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useAppStore } from '@/stores/app'
5
+ import { useUserStore } from '@/stores/user'
6
+ import { useAuthStore } from '@/stores/auth'
7
+ import { useMenuStore } from '@/stores/menu'
8
+ import { Icon } from '@xto/base'
9
+ import { Drawer } from '@xto/feedback'
10
+
11
+ type LayoutMode = 'sidebar' | 'top' | 'mix'
12
+
13
+ const route = useRoute()
14
+ const router = useRouter()
15
+ const appStore = useAppStore()
16
+ const userStore = useUserStore()
17
+ const authStore = useAuthStore()
18
+ const menuStore = useMenuStore()
19
+
20
+ const dropdownVisible = ref(false)
21
+ const drawerVisible = ref(false)
22
+ const dropdownRef = ref<HTMLElement | null>(null)
23
+ const isFullscreen = ref(false)
24
+ const searchVisible = ref(false)
25
+ const searchKeyword = ref('')
26
+ const searchRef = ref<HTMLElement | null>(null)
27
+
28
+ // 灰色模式状态
29
+ const greyMode = ref(false)
30
+
31
+ // 布局模式选项
32
+ const layoutOptions: { value: LayoutMode; label: string; icon: string }[] = [
33
+ { value: 'sidebar', label: '左侧菜单', icon: 'sidebar-left' },
34
+ { value: 'top', label: '顶部菜单', icon: 'menu' },
35
+ { value: 'mix', label: '混合菜单', icon: 'grid' }
36
+ ]
37
+
38
+ // 主题色选项
39
+ const colorOptions = [
40
+ { value: '#409eff', label: '默认蓝' },
41
+ { value: '#1890ff', label: '科技蓝' },
42
+ { value: '#52c41a', label: '极光绿' },
43
+ { value: '#faad14', label: '日落橙' },
44
+ { value: '#f5222d', label: '薄暮红' },
45
+ { value: '#722ed1', label: '酱紫' }
46
+ ]
47
+
48
+ // 面包屑
49
+ const breadcrumbs = computed(() => {
50
+ const matched = route.matched.filter(item => item.meta && item.meta.title)
51
+ return matched.map(item => ({
52
+ title: item.meta.title as string,
53
+ path: item.path
54
+ }))
55
+ })
56
+
57
+ // 扁平化菜单用于搜索
58
+ const flattenMenus = (menus: any[], parentTitle = ''): any[] => {
59
+ const result: any[] = []
60
+ menus.forEach(menu => {
61
+ if (menu.children && menu.children.length > 0) {
62
+ result.push(...flattenMenus(menu.children, menu.title))
63
+ } else {
64
+ result.push({ ...menu, parentTitle })
65
+ }
66
+ })
67
+ return result
68
+ }
69
+
70
+ // 搜索结果
71
+ const searchResults = computed(() => {
72
+ if (!searchKeyword.value.trim()) return []
73
+ const flatMenus = flattenMenus(menuStore.menuList)
74
+ return flatMenus.filter(menu =>
75
+ menu.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
76
+ )
77
+ })
78
+
79
+ // 当前布局模式
80
+ const currentLayout = computed(() => appStore.layout)
81
+
82
+ // 切换折叠
83
+ const toggleCollapse = () => {
84
+ appStore.toggleCollapse()
85
+ }
86
+
87
+ // 切换主题
88
+ const toggleTheme = () => {
89
+ appStore.toggleTheme()
90
+ }
91
+
92
+ // 打开设置抽屉
93
+ const openSettingsDrawer = () => {
94
+ drawerVisible.value = true
95
+ }
96
+
97
+ // 切换布局模式
98
+ const handleLayoutChange = (mode: LayoutMode) => {
99
+ appStore.setLayout(mode)
100
+ }
101
+
102
+ // 切换灰色模式
103
+ const handleGreyModeChange = (value: boolean) => {
104
+ greyMode.value = value
105
+ const html = document.documentElement
106
+ if (value) {
107
+ html.classList.add('grey-mode')
108
+ } else {
109
+ html.classList.remove('grey-mode')
110
+ }
111
+ }
112
+
113
+ // 切换全屏
114
+ const toggleFullscreen = () => {
115
+ if (!document.fullscreenElement) {
116
+ document.documentElement.requestFullscreen()
117
+ } else {
118
+ document.exitFullscreen()
119
+ }
120
+ }
121
+
122
+ // 监听全屏变化
123
+ const handleFullscreenChange = () => {
124
+ isFullscreen.value = !!document.fullscreenElement
125
+ }
126
+
127
+ // 切换下拉菜单
128
+ const toggleDropdown = () => {
129
+ dropdownVisible.value = !dropdownVisible.value
130
+ }
131
+
132
+ // 关闭下拉菜单
133
+ const closeDropdowns = () => {
134
+ dropdownVisible.value = false
135
+ }
136
+
137
+ // 显示搜索
138
+ const showSearch = () => {
139
+ searchVisible.value = true
140
+ }
141
+
142
+ // 隐藏搜索
143
+ const hideSearch = () => {
144
+ searchVisible.value = false
145
+ searchKeyword.value = ''
146
+ }
147
+
148
+ // 搜索结果点击
149
+ const handleSearchItemClick = (path: string) => {
150
+ router.push(path)
151
+ hideSearch()
152
+ }
153
+
154
+ // 设置主题色
155
+ const handleColorChange = (color: string) => {
156
+ appStore.setPrimaryColor(color)
157
+ }
158
+
159
+ // 个人信息
160
+ const handleProfile = () => {
161
+ closeDropdowns()
162
+ router.push('/profile')
163
+ }
164
+
165
+ // 修改密码
166
+ const handleChangePassword = () => {
167
+ closeDropdowns()
168
+ router.push('/change-password')
169
+ }
170
+
171
+ // 退出登录
172
+ const handleLogout = () => {
173
+ closeDropdowns()
174
+ authStore.logout()
175
+ userStore.clearUserInfo()
176
+ menuStore.clearMenu()
177
+ router.push('/login')
178
+ }
179
+
180
+ // 点击外部关闭下拉菜单
181
+ const handleClickOutside = (event: MouseEvent) => {
182
+ if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
183
+ closeDropdowns()
184
+ }
185
+ if (searchRef.value && !searchRef.value.contains(event.target as Node)) {
186
+ hideSearch()
187
+ }
188
+ }
189
+
190
+ // 键盘快捷键
191
+ const handleKeydown = (event: KeyboardEvent) => {
192
+ if (event.key === 'Escape') {
193
+ hideSearch()
194
+ closeDropdowns()
195
+ }
196
+ // Ctrl+K 打开搜索
197
+ if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
198
+ event.preventDefault()
199
+ showSearch()
200
+ }
201
+ }
202
+
203
+ onMounted(() => {
204
+ document.addEventListener('click', handleClickOutside)
205
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
206
+ document.addEventListener('keydown', handleKeydown)
207
+ appStore.initTheme()
208
+ // 初始化灰色模式状态
209
+ greyMode.value = document.documentElement.classList.contains('grey-mode')
210
+ })
211
+
212
+ onUnmounted(() => {
213
+ document.removeEventListener('click', handleClickOutside)
214
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
215
+ document.removeEventListener('keydown', handleKeydown)
216
+ })
217
+ </script>
218
+
219
+ <template>
220
+ <div class="header">
221
+ <!-- 左侧 -->
222
+ <div class="header__left">
223
+ <!-- 折叠按钮 -->
224
+ <div class="header__collapse" @click="toggleCollapse">
225
+ <Icon :name="appStore.isCollapsed ? 'menu-unfold' : 'menu-fold'" :size="18" />
226
+ </div>
227
+
228
+ <!-- 面包屑 -->
229
+ <div v-if="appStore.showBreadcrumb" class="header__breadcrumb">
230
+ <span v-for="(item, index) in breadcrumbs" :key="item.path">
231
+ <span v-if="index > 0" class="breadcrumb-separator">/</span>
232
+ <span :class="{ 'is-current': index === breadcrumbs.length - 1 }">
233
+ {{ item.title }}
234
+ </span>
235
+ </span>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- 右侧 -->
240
+ <div class="header__right">
241
+ <!-- 搜索按钮 -->
242
+ <div class="header__action" @click="showSearch" title="搜索 (Ctrl+K)">
243
+ <Icon name="search" :size="16" />
244
+ </div>
245
+
246
+ <!-- 全屏切换 -->
247
+ <div class="header__action" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
248
+ <Icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" :size="16" />
249
+ </div>
250
+
251
+ <!-- 换肤设置 -->
252
+ <div class="header__action" @click="openSettingsDrawer" title="换肤设置">
253
+ <Icon name="skin" :size="16" />
254
+ </div>
255
+
256
+ <!-- 主题切换 -->
257
+ <div class="header__action" @click="toggleTheme" title="切换主题">
258
+ <Icon :name="appStore.isDark ? 'sun' : 'moon'" :size="16" />
259
+ </div>
260
+
261
+ <!-- 用户头像 -->
262
+ <div class="header__user" ref="dropdownRef">
263
+ <div class="header__user-trigger" @click.stop="toggleDropdown">
264
+ <div class="header__avatar">
265
+ <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
266
+ </div>
267
+ <span class="header__user-name">{{ userStore.userName }}</span>
268
+ <span class="header__user-arrow" :class="{ 'is-active': dropdownVisible }">▼</span>
269
+ </div>
270
+
271
+ <!-- 下拉菜单 -->
272
+ <Transition name="dropdown">
273
+ <div v-if="dropdownVisible" class="header__dropdown">
274
+ <div class="header__dropdown-header">
275
+ <div class="header__dropdown-avatar">
276
+ <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
277
+ </div>
278
+ <div class="header__dropdown-info">
279
+ <div class="header__dropdown-name">{{ userStore.userName }}</div>
280
+ <div class="header__dropdown-role">{{ userStore.departmentName }}</div>
281
+ </div>
282
+ </div>
283
+ <div class="header__dropdown-divider"></div>
284
+ <div class="header__dropdown-menu">
285
+ <div class="header__dropdown-item" @click="handleProfile">
286
+ <Icon name="user" :size="16" />
287
+ <span>个人信息</span>
288
+ </div>
289
+ <div class="header__dropdown-item" @click="handleChangePassword">
290
+ <Icon name="lock" :size="16" />
291
+ <span>修改密码</span>
292
+ </div>
293
+ <div class="header__dropdown-divider"></div>
294
+ <div class="header__dropdown-item header__dropdown-item--danger" @click="handleLogout">
295
+ <Icon name="logout" :size="16" />
296
+ <span>退出登录</span>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </Transition>
301
+ </div>
302
+ </div>
303
+
304
+ <!-- 全局搜索弹窗 -->
305
+ <Transition name="search">
306
+ <div v-if="searchVisible" class="header__search-modal" ref="searchRef">
307
+ <div class="search-container">
308
+ <div class="search-input-wrapper">
309
+ <Icon class="search-icon" name="search" :size="20" />
310
+ <input
311
+ v-model="searchKeyword"
312
+ type="text"
313
+ class="search-input"
314
+ placeholder="搜索菜单..."
315
+ autofocus
316
+ @keyup.enter="searchResults[0] && handleSearchItemClick(searchResults[0].path)"
317
+ />
318
+ <span class="search-shortcut">ESC 关闭</span>
319
+ </div>
320
+ <div v-if="searchResults.length > 0" class="search-results">
321
+ <div
322
+ v-for="(item, index) in searchResults"
323
+ :key="item.path"
324
+ :class="['search-result-item', { 'is-first': index === 0 }]"
325
+ @click="handleSearchItemClick(item.path)"
326
+ >
327
+ <Icon class="search-result-icon" :name="item.icon || 'file'" :size="20" />
328
+ <div class="search-result-info">
329
+ <span class="search-result-title">{{ item.title }}</span>
330
+ <span v-if="item.parentTitle" class="search-result-parent">{{ item.parentTitle }}</span>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ <div v-else-if="searchKeyword" class="search-empty">
335
+ 未找到匹配的菜单
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </Transition>
340
+
341
+ <!-- 换肤设置抽屉 -->
342
+ <Drawer
343
+ v-model="drawerVisible"
344
+ title="换肤设置"
345
+ direction="rtl"
346
+ size="320px"
347
+ >
348
+ <div class="settings-drawer">
349
+ <!-- 布局模式 -->
350
+ <div class="settings-section">
351
+ <div class="settings-title">布局模式</div>
352
+ <div class="settings-layout-options">
353
+ <div
354
+ v-for="option in layoutOptions"
355
+ :key="option.value"
356
+ :class="['layout-option', { 'is-active': currentLayout === option.value }]"
357
+ @click="handleLayoutChange(option.value)"
358
+ >
359
+ <div class="layout-option__preview">
360
+ <div v-if="option.value === 'sidebar'" class="layout-preview-sidebar">
361
+ <div class="preview-aside"></div>
362
+ <div class="preview-main">
363
+ <div class="preview-header"></div>
364
+ <div class="preview-content"></div>
365
+ </div>
366
+ </div>
367
+ <div v-else-if="option.value === 'top'" class="layout-preview-top">
368
+ <div class="preview-header-full"></div>
369
+ <div class="preview-content-full"></div>
370
+ </div>
371
+ <div v-else class="layout-preview-mix">
372
+ <div class="preview-header-mix">
373
+ <div class="preview-mix-left"></div>
374
+ </div>
375
+ <div class="preview-mix-body">
376
+ <div class="preview-mix-aside"></div>
377
+ <div class="preview-mix-content"></div>
378
+ </div>
379
+ </div>
380
+ </div>
381
+ <span class="layout-option__label">{{ option.label }}</span>
382
+ </div>
383
+ </div>
384
+ </div>
385
+
386
+ <!-- 主题色 -->
387
+ <div class="settings-section">
388
+ <div class="settings-title">主题色</div>
389
+ <div class="settings-color-options">
390
+ <div
391
+ v-for="color in colorOptions"
392
+ :key="color.value"
393
+ :class="['color-option', { 'is-active': appStore.primaryColor === color.value }]"
394
+ :style="{ backgroundColor: color.value }"
395
+ :title="color.label"
396
+ @click="handleColorChange(color.value)"
397
+ >
398
+ <Icon v-if="appStore.primaryColor === color.value" name="check" :size="12" color="#fff" />
399
+ </div>
400
+ </div>
401
+ </div>
402
+
403
+ <!-- 功能开关 -->
404
+ <div class="settings-section">
405
+ <div class="settings-title">功能设置</div>
406
+ <div class="settings-switch-list">
407
+ <div class="settings-switch-item">
408
+ <span>灰色模式</span>
409
+ <div class="switch-wrapper" :class="{ 'is-checked': greyMode }" @click="handleGreyModeChange(!greyMode)">
410
+ <span class="switch-core"></span>
411
+ </div>
412
+ </div>
413
+ <div class="settings-switch-item">
414
+ <span>暗黑模式</span>
415
+ <div class="switch-wrapper" :class="{ 'is-checked': appStore.isDark }" @click="toggleTheme">
416
+ <span class="switch-core"></span>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ </div>
422
+ </Drawer>
423
+ </div>
424
+ </template>
425
+
426
+ <style lang="scss" scoped>
427
+ .header {
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: space-between;
431
+ padding: 0 20px;
432
+ height: 100%;
433
+ background-color: var(--bg-color);
434
+ border-bottom: 1px solid var(--color-border-lighter);
435
+
436
+ &__left {
437
+ display: flex;
438
+ align-items: center;
439
+ gap: 15px;
440
+ }
441
+
442
+ &__collapse {
443
+ width: 24px;
444
+ height: 24px;
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ cursor: pointer;
449
+ color: var(--color-text-regular);
450
+ transition: color 0.2s;
451
+
452
+ &:hover {
453
+ color: var(--color-primary);
454
+ }
455
+ }
456
+
457
+ &__breadcrumb {
458
+ font-size: 14px;
459
+ color: var(--color-text-secondary);
460
+
461
+ .breadcrumb-separator {
462
+ margin: 0 8px;
463
+ color: var(--color-text-placeholder);
464
+ }
465
+
466
+ .is-current {
467
+ color: var(--color-text-primary);
468
+ font-weight: 500;
469
+ }
470
+ }
471
+
472
+ &__right {
473
+ display: flex;
474
+ align-items: center;
475
+ gap: 8px;
476
+ }
477
+
478
+ &__action {
479
+ width: 32px;
480
+ height: 32px;
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ cursor: pointer;
485
+ border-radius: var(--border-radius-base);
486
+ color: var(--color-text-regular);
487
+ transition: all 0.2s;
488
+
489
+ &:hover {
490
+ background-color: var(--color-fill);
491
+ color: var(--color-primary);
492
+ }
493
+ }
494
+
495
+ &__user {
496
+ position: relative;
497
+ margin-left: 8px;
498
+
499
+ &-trigger {
500
+ display: flex;
501
+ align-items: center;
502
+ gap: 8px;
503
+ cursor: pointer;
504
+ padding: 4px 8px;
505
+ border-radius: var(--border-radius-base);
506
+ transition: background-color 0.2s;
507
+
508
+ &:hover {
509
+ background-color: var(--color-fill);
510
+ }
511
+ }
512
+
513
+ &-name {
514
+ font-size: 14px;
515
+ color: var(--color-text-primary);
516
+ max-width: 100px;
517
+ overflow: hidden;
518
+ text-overflow: ellipsis;
519
+ white-space: nowrap;
520
+ }
521
+
522
+ &-arrow {
523
+ font-size: 10px;
524
+ color: var(--color-text-secondary);
525
+ transition: transform 0.2s;
526
+
527
+ &.is-active {
528
+ transform: rotate(180deg);
529
+ }
530
+ }
531
+ }
532
+
533
+ &__avatar {
534
+ width: 32px;
535
+ height: 32px;
536
+ border-radius: 50%;
537
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
538
+ display: flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ color: #fff;
542
+ font-size: 14px;
543
+ font-weight: 500;
544
+ }
545
+
546
+ &__dropdown {
547
+ position: absolute;
548
+ top: calc(100% + 8px);
549
+ right: 0;
550
+ min-width: 200px;
551
+ background-color: var(--bg-color);
552
+ border-radius: var(--border-radius-base);
553
+ box-shadow: var(--box-shadow);
554
+ overflow: hidden;
555
+ z-index: 100;
556
+
557
+ &-header {
558
+ display: flex;
559
+ align-items: center;
560
+ gap: 12px;
561
+ padding: 16px;
562
+ }
563
+
564
+ &-avatar {
565
+ width: 40px;
566
+ height: 40px;
567
+ border-radius: 50%;
568
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: center;
572
+ color: #fff;
573
+ font-size: 16px;
574
+ font-weight: 500;
575
+ }
576
+
577
+ &-info {
578
+ flex: 1;
579
+ }
580
+
581
+ &-name {
582
+ font-size: 14px;
583
+ font-weight: 500;
584
+ color: var(--color-text-primary);
585
+ }
586
+
587
+ &-role {
588
+ font-size: 12px;
589
+ color: var(--color-text-secondary);
590
+ margin-top: 2px;
591
+ }
592
+
593
+ &-divider {
594
+ height: 1px;
595
+ background-color: var(--color-border-lighter);
596
+ }
597
+
598
+ &-menu {
599
+ padding: 8px 0;
600
+ }
601
+
602
+ &-item {
603
+ display: flex;
604
+ align-items: center;
605
+ gap: 10px;
606
+ padding: 10px 16px;
607
+ cursor: pointer;
608
+ font-size: 14px;
609
+ color: var(--color-text-regular);
610
+ transition: all 0.2s;
611
+
612
+ &:hover {
613
+ background-color: var(--color-fill);
614
+ color: var(--color-text-primary);
615
+ }
616
+
617
+ &--danger {
618
+ color: var(--color-danger);
619
+
620
+ &:hover {
621
+ background-color: var(--color-danger-light);
622
+ color: var(--color-danger);
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ &__search-modal {
629
+ position: fixed;
630
+ top: 0;
631
+ left: 0;
632
+ right: 0;
633
+ bottom: 0;
634
+ background-color: rgba(0, 0, 0, 0.5);
635
+ display: flex;
636
+ align-items: flex-start;
637
+ justify-content: center;
638
+ padding-top: 100px;
639
+ z-index: 200;
640
+ }
641
+ }
642
+
643
+ // 搜索容器
644
+ .search-container {
645
+ width: 600px;
646
+ max-width: 90vw;
647
+ background-color: var(--bg-color);
648
+ border-radius: var(--border-radius-large);
649
+ box-shadow: var(--box-shadow-dark);
650
+ overflow: hidden;
651
+ }
652
+
653
+ .search-input-wrapper {
654
+ display: flex;
655
+ align-items: center;
656
+ padding: 16px 20px;
657
+ border-bottom: 1px solid var(--color-border-lighter);
658
+
659
+ .search-icon {
660
+ color: var(--color-text-secondary);
661
+ margin-right: 12px;
662
+ }
663
+
664
+ .search-input {
665
+ flex: 1;
666
+ font-size: 16px;
667
+ color: var(--color-text-primary);
668
+ background: transparent;
669
+ border: none;
670
+ outline: none;
671
+
672
+ &::placeholder {
673
+ color: var(--color-text-placeholder);
674
+ }
675
+ }
676
+
677
+ .search-shortcut {
678
+ font-size: 12px;
679
+ color: var(--color-text-secondary);
680
+ padding: 4px 8px;
681
+ background-color: var(--color-fill);
682
+ border-radius: var(--border-radius-base);
683
+ }
684
+ }
685
+
686
+ .search-results {
687
+ max-height: 400px;
688
+ overflow-y: auto;
689
+ padding: 8px 0;
690
+ }
691
+
692
+ .search-result-item {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 12px;
696
+ padding: 12px 20px;
697
+ cursor: pointer;
698
+ transition: background-color 0.2s;
699
+
700
+ &:hover {
701
+ background-color: var(--color-fill);
702
+ }
703
+
704
+ &.is-first {
705
+ background-color: var(--color-primary-light-9);
706
+ }
707
+
708
+ .search-result-icon {
709
+ color: var(--color-text-secondary);
710
+ }
711
+
712
+ .search-result-info {
713
+ flex: 1;
714
+ display: flex;
715
+ flex-direction: column;
716
+ gap: 2px;
717
+ }
718
+
719
+ .search-result-title {
720
+ font-size: 14px;
721
+ color: var(--color-text-primary);
722
+ }
723
+
724
+ .search-result-parent {
725
+ font-size: 12px;
726
+ color: var(--color-text-secondary);
727
+ }
728
+ }
729
+
730
+ .search-empty {
731
+ padding: 40px 20px;
732
+ text-align: center;
733
+ color: var(--color-text-secondary);
734
+ font-size: 14px;
735
+ }
736
+
737
+ // 设置抽屉内容
738
+ .settings-drawer {
739
+ .settings-section {
740
+ margin-bottom: 24px;
741
+ }
742
+
743
+ .settings-title {
744
+ font-size: 14px;
745
+ font-weight: 500;
746
+ color: var(--color-text-primary);
747
+ margin-bottom: 12px;
748
+ }
749
+
750
+ .settings-layout-options {
751
+ display: flex;
752
+ gap: 12px;
753
+ }
754
+
755
+ .layout-option {
756
+ flex: 1;
757
+ display: flex;
758
+ flex-direction: column;
759
+ align-items: center;
760
+ gap: 8px;
761
+ padding: 12px;
762
+ border: 1px solid var(--color-border);
763
+ border-radius: var(--border-radius-base);
764
+ cursor: pointer;
765
+ transition: all 0.2s;
766
+
767
+ &:hover {
768
+ border-color: var(--color-primary-light-5);
769
+ }
770
+
771
+ &.is-active {
772
+ border-color: var(--color-primary);
773
+ background-color: var(--color-primary-light-9);
774
+ }
775
+
776
+ &__preview {
777
+ width: 48px;
778
+ height: 36px;
779
+ border: 1px solid var(--color-border-light);
780
+ border-radius: 2px;
781
+ overflow: hidden;
782
+ }
783
+
784
+ &__label {
785
+ font-size: 12px;
786
+ color: var(--color-text-regular);
787
+ }
788
+ }
789
+
790
+ // 布局预览样式
791
+ .layout-preview-sidebar {
792
+ display: flex;
793
+ height: 100%;
794
+
795
+ .preview-aside {
796
+ width: 25%;
797
+ height: 100%;
798
+ background-color: var(--color-primary-light-7);
799
+ }
800
+
801
+ .preview-main {
802
+ flex: 1;
803
+ display: flex;
804
+ flex-direction: column;
805
+
806
+ .preview-header {
807
+ height: 20%;
808
+ background-color: var(--color-border-light);
809
+ }
810
+
811
+ .preview-content {
812
+ flex: 1;
813
+ background-color: var(--bg-color-page);
814
+ }
815
+ }
816
+ }
817
+
818
+ .layout-preview-top {
819
+ display: flex;
820
+ flex-direction: column;
821
+ height: 100%;
822
+
823
+ .preview-header-full {
824
+ height: 25%;
825
+ background-color: var(--color-primary-light-7);
826
+ }
827
+
828
+ .preview-content-full {
829
+ flex: 1;
830
+ background-color: var(--bg-color-page);
831
+ }
832
+ }
833
+
834
+ .layout-preview-mix {
835
+ display: flex;
836
+ flex-direction: column;
837
+ height: 100%;
838
+
839
+ .preview-header-mix {
840
+ height: 25%;
841
+ background-color: var(--color-primary-light-7);
842
+ display: flex;
843
+
844
+ .preview-mix-left {
845
+ width: 30%;
846
+ background-color: var(--color-primary);
847
+ }
848
+ }
849
+
850
+ .preview-mix-body {
851
+ flex: 1;
852
+ display: flex;
853
+
854
+ .preview-mix-aside {
855
+ width: 25%;
856
+ background-color: var(--color-primary-light-8);
857
+ }
858
+
859
+ .preview-mix-content {
860
+ flex: 1;
861
+ background-color: var(--bg-color-page);
862
+ }
863
+ }
864
+ }
865
+
866
+ .settings-color-options {
867
+ display: flex;
868
+ gap: 12px;
869
+ }
870
+
871
+ .color-option {
872
+ width: 24px;
873
+ height: 24px;
874
+ border-radius: 4px;
875
+ cursor: pointer;
876
+ display: flex;
877
+ align-items: center;
878
+ justify-content: center;
879
+ transition: transform 0.2s;
880
+
881
+ &:hover {
882
+ transform: scale(1.1);
883
+ }
884
+
885
+ &.is-active {
886
+ box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--color-primary);
887
+ }
888
+ }
889
+
890
+ .settings-switch-list {
891
+ display: flex;
892
+ flex-direction: column;
893
+ gap: 12px;
894
+ }
895
+
896
+ .settings-switch-item {
897
+ display: flex;
898
+ align-items: center;
899
+ justify-content: space-between;
900
+
901
+ span {
902
+ font-size: 14px;
903
+ color: var(--color-text-regular);
904
+ }
905
+ }
906
+
907
+ .switch-wrapper {
908
+ width: 44px;
909
+ height: 22px;
910
+ display: flex;
911
+ align-items: center;
912
+ cursor: pointer;
913
+
914
+ .switch-core {
915
+ width: 100%;
916
+ height: 100%;
917
+ border-radius: 11px;
918
+ background-color: var(--color-border);
919
+ position: relative;
920
+ transition: background-color 0.2s;
921
+
922
+ &::after {
923
+ content: '';
924
+ position: absolute;
925
+ top: 2px;
926
+ left: 2px;
927
+ width: 18px;
928
+ height: 18px;
929
+ background-color: #fff;
930
+ border-radius: 50%;
931
+ transition: left 0.2s;
932
+ }
933
+ }
934
+
935
+ &.is-checked {
936
+ .switch-core {
937
+ background-color: var(--color-primary);
938
+
939
+ &::after {
940
+ left: 24px;
941
+ }
942
+ }
943
+ }
944
+ }
945
+ }
946
+
947
+ // 下拉动画
948
+ .dropdown-enter-active,
949
+ .dropdown-leave-active {
950
+ transition: all 0.2s ease;
951
+ }
952
+
953
+ .dropdown-enter-from,
954
+ .dropdown-leave-to {
955
+ opacity: 0;
956
+ transform: translateY(-10px);
957
+ }
958
+
959
+ // 搜索弹窗动画
960
+ .search-enter-active,
961
+ .search-leave-active {
962
+ transition: all 0.2s ease;
963
+ }
964
+
965
+ .search-enter-from,
966
+ .search-leave-to {
967
+ opacity: 0;
968
+ }
969
+
970
+ // 灰色模式
971
+ html.grey-mode {
972
+ filter: grayscale(100%);
973
+ }
974
974
  </style>