xto-fronted 0.4.110 → 0.4.112

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 (99) hide show
  1. package/dist/assets/404-Cw_4ZCL6.css +1 -0
  2. package/dist/assets/404-G0TYnM2t.js +1 -0
  3. package/dist/assets/_plugin-vue_export-helper-DlAUqK2U.js +1 -0
  4. package/dist/assets/index-8nuVIlbv.css +1 -0
  5. package/dist/assets/index-BRR97dc6.js +1 -0
  6. package/dist/assets/index-BlRslYYI.css +1 -0
  7. package/dist/assets/index-Br0rA9ST.js +1 -0
  8. package/dist/assets/index-BudArKxR.css +1 -0
  9. package/dist/assets/index-CEwTCJMr.js +1 -0
  10. package/dist/assets/index-CFhWBbxk.css +1 -0
  11. package/dist/assets/index-Cm58iTuj.js +1 -0
  12. package/dist/assets/index-Cno3g_Ca.js +1 -0
  13. package/dist/assets/index-Cpew6d-v.css +1 -0
  14. package/dist/assets/index-D1AzIZb6.js +1 -0
  15. package/dist/assets/index-DCn9x-He.js +2 -0
  16. package/dist/assets/index-Dk64JJvN.js +1 -0
  17. package/dist/assets/index-rQHcgyhm.css +1 -0
  18. package/dist/assets/index-vfvEFrCH.css +1 -0
  19. package/dist/assets/vendor-CWD9azq9.js +16 -0
  20. package/dist/assets/vue-vendor-gcK82_Kd.js +29 -0
  21. package/dist/assets/xto-base-CL2NKZJJ.css +1 -0
  22. package/dist/assets/xto-base-wYW0Uxvr.js +1 -0
  23. package/dist/assets/xto-business--V1F5Gwb.css +1 -0
  24. package/dist/assets/xto-core-DtPNWVUu.js +1 -0
  25. package/dist/assets/xto-data-2diO2SyB.js +1 -0
  26. package/dist/assets/xto-data-Bi03KULC.css +1 -0
  27. package/dist/assets/xto-feedback-BOOXC1Ev.js +1 -0
  28. package/dist/assets/xto-feedback-Bxx38c3P.css +1 -0
  29. package/dist/assets/xto-form-BBugNbnQ.js +1 -0
  30. package/dist/assets/xto-form-Cu6q3VLG.css +1 -0
  31. package/dist/assets/xto-layout-BDD6sSlM.css +1 -0
  32. package/dist/assets/xto-navigation-D_LFy9y9.js +1 -0
  33. package/dist/assets/xto-navigation-XfpyMpEo.css +1 -0
  34. package/dist/index.html +28 -0
  35. package/package.json +1 -1
  36. package/src/components/Layout/Header.vue +1012 -1012
  37. package/src/directives/permission.ts +36 -36
  38. package/src/stores/app.ts +179 -179
  39. package/src/stores/menu.ts +109 -109
  40. package/src/stores/user.ts +52 -50
  41. package/src/types/api.d.ts +81 -80
  42. package/src/utils/config.ts +16 -3
  43. package/src/utils/permission.ts +39 -39
  44. package/src/utils/request.ts +1 -1
  45. package/dist/App.vue.d.ts +0 -2
  46. package/dist/api/auth.d.ts +0 -8
  47. package/dist/api/index.d.ts +0 -6
  48. package/dist/api/index.js +0 -26
  49. package/dist/api/system.d.ts +0 -16
  50. package/dist/api/user.d.ts +0 -13
  51. package/dist/api.d.ts +0 -2
  52. package/dist/components/Layout/Footer.vue.d.ts +0 -2
  53. package/dist/components/Layout/Header.vue.d.ts +0 -5
  54. package/dist/components/Layout/MixTopMenu.vue.d.ts +0 -10
  55. package/dist/components/Layout/Sidebar.vue.d.ts +0 -13
  56. package/dist/components/Layout/SidebarMenuItem.vue.d.ts +0 -5
  57. package/dist/components/Layout/Tabs.vue.d.ts +0 -2
  58. package/dist/components/Layout/TopMenu.vue.d.ts +0 -10
  59. package/dist/components/Layout/index.vue.d.ts +0 -7
  60. package/dist/composables/useApp.d.ts +0 -29
  61. package/dist/composables/useAuth.d.ts +0 -6
  62. package/dist/composables/useForm.d.ts +0 -20
  63. package/dist/composables/useI18n.d.ts +0 -30
  64. package/dist/composables/useTable.d.ts +0 -29
  65. package/dist/directives/permission.d.ts +0 -4
  66. package/dist/enums/index.d.ts +0 -32
  67. package/dist/index-BMIR-PSJ.js +0 -515
  68. package/dist/index-C3r5dnEs.js +0 -479
  69. package/dist/index-CLcJb_H7.js +0 -189
  70. package/dist/index-Ch8vmOiD.js +0 -641
  71. package/dist/index-DTWHgS2f.js +0 -3167
  72. package/dist/index.d.ts +0 -63
  73. package/dist/index.js +0 -99
  74. package/dist/main.d.ts +0 -0
  75. package/dist/router/dynamicRoutes.d.ts +0 -30
  76. package/dist/router/guards.d.ts +0 -17
  77. package/dist/router/index.d.ts +0 -6
  78. package/dist/router/layoutRoute.d.ts +0 -23
  79. package/dist/router/staticRoutes.d.ts +0 -2
  80. package/dist/stores/app.d.ts +0 -99
  81. package/dist/stores/auth.d.ts +0 -41
  82. package/dist/stores/index.d.ts +0 -10
  83. package/dist/stores/locale.d.ts +0 -42
  84. package/dist/stores/menu.d.ts +0 -83
  85. package/dist/stores/user.d.ts +0 -92
  86. package/dist/style.css +0 -1
  87. package/dist/user-CHxvQHYq.js +0 -1182
  88. package/dist/utils/auth.d.ts +0 -27
  89. package/dist/utils/config.d.ts +0 -46
  90. package/dist/utils/permission.d.ts +0 -17
  91. package/dist/utils/request.d.ts +0 -34
  92. package/dist/utils/storage.d.ts +0 -24
  93. package/dist/views/dashboard/index.vue.d.ts +0 -2
  94. package/dist/views/error/403.vue.d.ts +0 -2
  95. package/dist/views/error/404.vue.d.ts +0 -2
  96. package/dist/views/login/index.vue.d.ts +0 -4
  97. package/dist/views/system/menu/index.vue.d.ts +0 -4
  98. package/dist/views/system/role/index.vue.d.ts +0 -4
  99. package/dist/views/system/user/index.vue.d.ts +0 -4
@@ -1,1013 +1,1013 @@
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.menuName))
63
- } else {
64
- result.push({ ...menu, parentTitle, title: menu.menuName, path: menu.menuUrl })
65
- }
66
- })
67
- return result
68
- }
69
-
70
- // 已知的图标名称列表(来自 @xto/base/icons.ts)
71
- const knownIcons = new Set([
72
- 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right',
73
- 'caret-down', 'caret-right', 'plus', 'minus', 'close', 'check',
74
- 'edit', 'delete', 'copy', 'download', 'upload', 'refresh', 'search',
75
- 'filter', 'more', 'setting', 'share', 'loading', 'info', 'success',
76
- 'warning', 'error', 'question', 'user', 'user-add', 'user-group',
77
- 'logout', 'login', 'file', 'folder', 'folder-open', 'document',
78
- 'image', 'video', 'music', 'camera', 'mail', 'phone', 'chat',
79
- 'bell', 'message', 'eye', 'eye-off', 'calendar', 'clock', 'history',
80
- 'timer', 'location', 'map', 'globe', 'star', 'heart', 'thumb-up',
81
- 'link', 'external-link', 'lock', 'unlock', 'key', 'home', 'menu',
82
- 'menu-fold', 'menu-unfold', 'sidebar-fold', 'sidebar-expand',
83
- 'sidebar-left', 'dashboard', 'chart', 'chart-pie', 'chart-line',
84
- 'report', 'analytics', 'system', 'permission', 'role', 'user-manage',
85
- 'log', 'notification', 'app', 'list', 'grid', 'fullscreen',
86
- 'fullscreen-exit', 'zoom-in', 'zoom-out', 'print', 'bookmark',
87
- 'tag', 'code', 'terminal', 'database', 'server', 'cloud', 'gift',
88
- 'moon', 'sun', 'theme', 'skin'
89
- ])
90
-
91
- // 获取菜单图标名称
92
- const getMenuIcon = (icon?: string): string => {
93
- if (!icon || icon === '') return ''
94
- if (icon.startsWith('tineco-icon-')) {
95
- const iconName = icon.replace('tineco-icon-', '')
96
- const tinecoIconMap: Record<string, string> = {
97
- home: 'home', dashboard: 'dashboard', system: 'system',
98
- user: 'user', role: 'role', menu: 'list', setting: 'setting',
99
- file: 'file', folder: 'folder', chart: 'chart', report: 'report', analytics: 'analytics'
100
- }
101
- return tinecoIconMap[iconName] || iconName
102
- }
103
- const iconMap: Record<string, string> = {
104
- dashboard: 'dashboard', system: 'system', user: 'user', role: 'role',
105
- menu: 'list', setting: 'setting', home: 'home', chart: 'chart',
106
- report: 'report', analytics: 'analytics', permission: 'permission',
107
- log: 'log', notification: 'notification', app: 'app', list: 'list', grid: 'grid'
108
- }
109
- return iconMap[icon] || icon
110
- }
111
-
112
- // 获取菜单名称第一个字
113
- const getFirstChar = (name?: string): string => {
114
- if (!name) return ''
115
- return name.charAt(0)
116
- }
117
-
118
- // 判断图标是否存在
119
- const iconExists = (iconName: string): boolean => {
120
- return knownIcons.has(iconName)
121
- }
122
-
123
- // 搜索结果
124
- const searchResults = computed(() => {
125
- if (!searchKeyword.value.trim()) return []
126
- const flatMenus = flattenMenus(menuStore.menuList)
127
- return flatMenus.filter(menu =>
128
- menu.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
129
- )
130
- })
131
-
132
- // 当前布局模式
133
- const currentLayout = computed(() => appStore.layout)
134
-
135
- // 切换折叠
136
- const toggleCollapse = () => {
137
- appStore.toggleCollapse()
138
- }
139
-
140
- // 切换主题
141
- const toggleTheme = () => {
142
- appStore.toggleTheme()
143
- }
144
-
145
- // 打开设置抽屉
146
- const openSettingsDrawer = () => {
147
- drawerVisible.value = true
148
- }
149
-
150
- // 切换布局模式
151
- const handleLayoutChange = (mode: LayoutMode) => {
152
- appStore.setLayout(mode)
153
- drawerVisible.value = false
154
- }
155
-
156
- // 切换灰色模式
157
- const handleGreyModeChange = (value: boolean) => {
158
- greyMode.value = value
159
- const html = document.documentElement
160
- if (value) {
161
- html.classList.add('grey-mode')
162
- } else {
163
- html.classList.remove('grey-mode')
164
- }
165
- }
166
-
167
- // 抽屉内灰色模式开关
168
- const handleGreyModeToggle = () => {
169
- handleGreyModeChange(!greyMode.value)
170
- drawerVisible.value = false
171
- }
172
-
173
- // 抽屉内暗黑模式开关
174
- const handleDarkModeToggle = () => {
175
- appStore.toggleTheme()
176
- drawerVisible.value = false
177
- }
178
-
179
- // 切换全屏
180
- const toggleFullscreen = () => {
181
- if (!document.fullscreenElement) {
182
- document.documentElement.requestFullscreen()
183
- } else {
184
- document.exitFullscreen()
185
- }
186
- }
187
-
188
- // 监听全屏变化
189
- const handleFullscreenChange = () => {
190
- isFullscreen.value = !!document.fullscreenElement
191
- }
192
-
193
- // 切换下拉菜单
194
- const toggleDropdown = () => {
195
- dropdownVisible.value = !dropdownVisible.value
196
- }
197
-
198
- // 关闭下拉菜单
199
- const closeDropdowns = () => {
200
- dropdownVisible.value = false
201
- }
202
-
203
- // 隐藏搜索
204
- const hideSearch = () => {
205
- searchVisible.value = false
206
- searchKeyword.value = ''
207
- }
208
-
209
- // 搜索结果点击
210
- const handleSearchItemClick = (path: string) => {
211
- router.push(path)
212
- hideSearch()
213
- }
214
-
215
- // 设置主题色
216
- const handleColorChange = (color: string) => {
217
- appStore.setPrimaryColor(color)
218
- drawerVisible.value = false
219
- }
220
-
221
- // 个人信息
222
- const handleProfile = () => {
223
- closeDropdowns()
224
- router.push('/profile')
225
- }
226
-
227
- // 修改密码
228
- const handleChangePassword = () => {
229
- closeDropdowns()
230
- router.push('/change-password')
231
- }
232
-
233
- // 退出登录
234
- const handleLogout = () => {
235
- closeDropdowns()
236
- authStore.logout()
237
- userStore.clearUserInfo()
238
- menuStore.clearMenu()
239
- router.push(appStore.loginPath)
240
- }
241
-
242
- // 点击外部关闭下拉菜单
243
- const handleClickOutside = (event: MouseEvent) => {
244
- if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
245
- closeDropdowns()
246
- }
247
- if (searchRef.value && !searchRef.value.contains(event.target as Node)) {
248
- hideSearch()
249
- }
250
- }
251
-
252
- // 键盘快捷键
253
- const handleKeydown = (event: KeyboardEvent) => {
254
- if (event.key === 'Escape') {
255
- hideSearch()
256
- closeDropdowns()
257
- }
258
- }
259
-
260
- onMounted(() => {
261
- document.addEventListener('click', handleClickOutside)
262
- document.addEventListener('fullscreenchange', handleFullscreenChange)
263
- document.addEventListener('keydown', handleKeydown)
264
- appStore.initTheme()
265
- // 初始化灰色模式状态
266
- greyMode.value = document.documentElement.classList.contains('grey-mode')
267
- })
268
-
269
- onUnmounted(() => {
270
- document.removeEventListener('click', handleClickOutside)
271
- document.removeEventListener('fullscreenchange', handleFullscreenChange)
272
- document.removeEventListener('keydown', handleKeydown)
273
- })
274
- </script>
275
-
276
- <template>
277
- <div class="header">
278
- <!-- 左侧 -->
279
- <div class="header__left">
280
- <!-- 折叠按钮 -->
281
- <div class="header__collapse" @click="toggleCollapse">
282
- <Icon :name="appStore.isCollapsed ? 'menu-unfold' : 'menu-fold'" :size="18" />
283
- </div>
284
-
285
- <!-- 面包屑 -->
286
- <div v-if="appStore.showBreadcrumb" class="header__breadcrumb">
287
- <span v-for="(item, index) in breadcrumbs" :key="item.path">
288
- <span v-if="index > 0" class="breadcrumb-separator">/</span>
289
- <span :class="{ 'is-current': index === breadcrumbs.length - 1 }">
290
- {{ item.title }}
291
- </span>
292
- </span>
293
- </div>
294
- </div>
295
-
296
- <!-- 右侧 -->
297
- <div class="header__right">
298
- <!-- 搜索输入框 -->
299
- <div class="header__search" ref="searchRef">
300
- <Icon name="search" :size="14" class="header__search-icon" />
301
- <input
302
- v-model="searchKeyword"
303
- type="text"
304
- class="header__search-input"
305
- placeholder="搜索菜单..."
306
- @focus="searchVisible = true"
307
- />
308
- <!-- 搜索结果下拉 -->
309
- <Transition name="search-dropdown">
310
- <div v-if="searchVisible && (searchResults.length > 0 || searchKeyword)" class="header__search-dropdown">
311
- <div v-if="searchResults.length > 0" class="header__search-results">
312
- <div
313
- v-for="item in searchResults"
314
- :key="item.path"
315
- class="header__search-item"
316
- @click="handleSearchItemClick(item.path)"
317
- >
318
- <span v-if="item.title !== '首页'" class="header__search-icon">
319
- <Icon v-if="iconExists(getMenuIcon(item.icon))" :name="getMenuIcon(item.icon)" :size="16" />
320
- <span v-else class="header__search-char">{{ getFirstChar(item.title) }}</span>
321
- </span>
322
- <span class="header__search-item-title">{{ item.title }}</span>
323
- <span v-if="item.parentTitle" class="header__search-item-parent">{{ item.parentTitle }}</span>
324
- </div>
325
- </div>
326
- <div v-else class="header__search-empty">
327
- 未找到匹配的菜单
328
- </div>
329
- </div>
330
- </Transition>
331
- </div>
332
-
333
- <!-- 全屏切换 -->
334
- <div class="header__action" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
335
- <Icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" :size="16" />
336
- </div>
337
-
338
- <!-- 换肤设置 -->
339
- <div class="header__action" @click="openSettingsDrawer" title="换肤设置">
340
- <Icon name="skin" :size="16" />
341
- </div>
342
-
343
- <!-- 主题切换 -->
344
- <div class="header__action" @click="toggleTheme" title="切换主题">
345
- <Icon :name="appStore.isDark ? 'sun' : 'moon'" :size="16" />
346
- </div>
347
-
348
- <!-- 用户头像 -->
349
- <div class="header__user" ref="dropdownRef">
350
- <div class="header__user-trigger" @click.stop="toggleDropdown">
351
- <div class="header__avatar">
352
- <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
353
- </div>
354
- <span class="header__user-name">{{ userStore.userName }}</span>
355
- <span class="header__user-arrow" :class="{ 'is-active': dropdownVisible }">▼</span>
356
- </div>
357
-
358
- <!-- 下拉菜单 -->
359
- <Transition name="dropdown">
360
- <div v-if="dropdownVisible" class="header__dropdown">
361
- <div class="header__dropdown-header">
362
- <div class="header__dropdown-avatar">
363
- <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
364
- </div>
365
- <div class="header__dropdown-info">
366
- <div class="header__dropdown-name">{{ userStore.userName }}</div>
367
- <div class="header__dropdown-role">{{ userStore.departmentName }}</div>
368
- </div>
369
- </div>
370
- <div class="header__dropdown-divider"></div>
371
- <div class="header__dropdown-menu">
372
- <div class="header__dropdown-item" @click="handleProfile">
373
- <Icon name="user" :size="16" />
374
- <span>个人信息</span>
375
- </div>
376
- <div class="header__dropdown-item" @click="handleChangePassword">
377
- <Icon name="lock" :size="16" />
378
- <span>修改密码</span>
379
- </div>
380
- <div class="header__dropdown-divider"></div>
381
- <div class="header__dropdown-item header__dropdown-item--danger" @click="handleLogout">
382
- <Icon name="logout" :size="16" />
383
- <span>退出登录</span>
384
- </div>
385
- </div>
386
- </div>
387
- </Transition>
388
- </div>
389
- </div>
390
-
391
- <!-- 换肤设置抽屉 -->
392
- <Drawer
393
- v-model="drawerVisible"
394
- title="换肤设置"
395
- direction="rtl"
396
- size="320px"
397
- >
398
- <div class="settings-drawer">
399
- <!-- 布局模式 -->
400
- <div class="settings-section">
401
- <div class="settings-title">布局模式</div>
402
- <div class="settings-layout-options">
403
- <div
404
- v-for="option in layoutOptions"
405
- :key="option.value"
406
- :class="['layout-option', { 'is-active': currentLayout === option.value }]"
407
- @click="handleLayoutChange(option.value)"
408
- >
409
- <div class="layout-option__preview">
410
- <div v-if="option.value === 'sidebar'" class="layout-preview-sidebar">
411
- <div class="preview-aside"></div>
412
- <div class="preview-main">
413
- <div class="preview-header"></div>
414
- <div class="preview-content"></div>
415
- </div>
416
- </div>
417
- <div v-else-if="option.value === 'top'" class="layout-preview-top">
418
- <div class="preview-header-full"></div>
419
- <div class="preview-content-full"></div>
420
- </div>
421
- <div v-else class="layout-preview-mix">
422
- <div class="preview-header-mix">
423
- <div class="preview-mix-left"></div>
424
- </div>
425
- <div class="preview-mix-body">
426
- <div class="preview-mix-aside"></div>
427
- <div class="preview-mix-content"></div>
428
- </div>
429
- </div>
430
- </div>
431
- <span class="layout-option__label">{{ option.label }}</span>
432
- </div>
433
- </div>
434
- </div>
435
-
436
- <!-- 主题色 -->
437
- <div class="settings-section">
438
- <div class="settings-title">主题色</div>
439
- <div class="settings-color-options">
440
- <div
441
- v-for="color in colorOptions"
442
- :key="color.value"
443
- :class="['color-option', { 'is-active': appStore.primaryColor === color.value }]"
444
- :style="{ backgroundColor: color.value }"
445
- :title="color.label"
446
- @click="handleColorChange(color.value)"
447
- >
448
- <Icon v-if="appStore.primaryColor === color.value" name="check" :size="12" color="#fff" />
449
- </div>
450
- </div>
451
- </div>
452
-
453
- <!-- 功能开关 -->
454
- <div class="settings-section">
455
- <div class="settings-title">功能设置</div>
456
- <div class="settings-switch-list">
457
- <div class="settings-switch-item">
458
- <span>灰色模式</span>
459
- <div class="switch-wrapper" :class="{ 'is-checked': greyMode }" @click="handleGreyModeToggle">
460
- <span class="switch-core"></span>
461
- </div>
462
- </div>
463
- <div class="settings-switch-item">
464
- <span>暗黑模式</span>
465
- <div class="switch-wrapper" :class="{ 'is-checked': appStore.isDark }" @click="handleDarkModeToggle">
466
- <span class="switch-core"></span>
467
- </div>
468
- </div>
469
- </div>
470
- </div>
471
- </div>
472
- </Drawer>
473
- </div>
474
- </template>
475
-
476
- <style lang="scss" scoped>
477
- .header {
478
- display: flex;
479
- align-items: center;
480
- justify-content: space-between;
481
- padding: 0 20px;
482
- height: 100%;
483
- background-color: var(--bg-color);
484
- border-bottom: 1px solid var(--color-border-lighter);
485
-
486
- &__left {
487
- display: flex;
488
- align-items: center;
489
- gap: 15px;
490
- }
491
-
492
- &__collapse {
493
- width: 24px;
494
- height: 24px;
495
- display: flex;
496
- align-items: center;
497
- justify-content: center;
498
- cursor: pointer;
499
- color: var(--color-text-regular);
500
- transition: color 0.2s;
501
-
502
- &:hover {
503
- color: var(--color-primary);
504
- }
505
- }
506
-
507
- &__breadcrumb {
508
- font-size: 14px;
509
- color: var(--color-text-secondary);
510
-
511
- .breadcrumb-separator {
512
- margin: 0 8px;
513
- color: var(--color-text-placeholder);
514
- }
515
-
516
- .is-current {
517
- color: var(--color-text-primary);
518
- font-weight: 500;
519
- }
520
- }
521
-
522
- &__right {
523
- display: flex;
524
- align-items: center;
525
- gap: 8px;
526
- }
527
-
528
- &__search {
529
- position: relative;
530
- display: flex;
531
- align-items: center;
532
- background-color: var(--color-fill-light);
533
- border-radius: var(--border-radius-base);
534
- padding: 0 12px;
535
- height: 32px;
536
- width: 200px;
537
-
538
- &-icon {
539
- color: var(--color-text-secondary);
540
- }
541
-
542
- &-input {
543
- flex: 1;
544
- height: 100%;
545
- font-size: 14px;
546
- color: var(--color-text-primary);
547
- background: transparent;
548
- border: none;
549
- outline: none;
550
- padding-left: 8px;
551
-
552
- &::placeholder {
553
- color: var(--color-text-placeholder);
554
- }
555
- }
556
-
557
- &-dropdown {
558
- position: absolute;
559
- top: calc(100% + 4px);
560
- left: 0;
561
- right: 0;
562
- min-width: 200px;
563
- max-height: 300px;
564
- overflow-y: auto;
565
- background-color: var(--bg-color);
566
- border-radius: var(--border-radius-base);
567
- box-shadow: var(--box-shadow);
568
- z-index: 100;
569
- }
570
-
571
- &-results {
572
- padding: 8px 0;
573
- }
574
-
575
- &-item {
576
- display: flex;
577
- align-items: center;
578
- gap: 8px;
579
- padding: 8px 12px;
580
- cursor: pointer;
581
- transition: background-color 0.2s;
582
-
583
- &:hover {
584
- background-color: var(--color-fill);
585
- }
586
-
587
- &-title {
588
- font-size: 14px;
589
- color: var(--color-text-primary);
590
- }
591
-
592
- &-parent {
593
- font-size: 12px;
594
- color: var(--color-text-secondary);
595
- margin-left: auto;
596
- }
597
- }
598
-
599
- &-icon {
600
- display: inline-flex;
601
- align-items: center;
602
- justify-content: center;
603
- width: 16px;
604
- height: 16px;
605
- flex-shrink: 0;
606
- }
607
-
608
- &-char {
609
- display: inline-flex;
610
- align-items: center;
611
- justify-content: center;
612
- width: 16px;
613
- height: 16px;
614
- font-size: 12px;
615
- font-weight: 600;
616
- color: var(--color-primary);
617
- background-color: var(--color-primary-light-8);
618
- border-radius: 4px;
619
- }
620
-
621
- &-empty {
622
- padding: 16px 12px;
623
- text-align: center;
624
- color: var(--color-text-secondary);
625
- font-size: 14px;
626
- }
627
- }
628
-
629
- &__action {
630
- width: 32px;
631
- height: 32px;
632
- display: flex;
633
- align-items: center;
634
- justify-content: center;
635
- cursor: pointer;
636
- border-radius: var(--border-radius-base);
637
- color: var(--color-text-regular);
638
- transition: all 0.2s;
639
-
640
- &:hover {
641
- background-color: var(--color-fill);
642
- color: var(--color-primary);
643
- }
644
- }
645
-
646
- &__user {
647
- position: relative;
648
- margin-left: 8px;
649
-
650
- &-trigger {
651
- display: flex;
652
- align-items: center;
653
- gap: 8px;
654
- cursor: pointer;
655
- padding: 4px 8px;
656
- border-radius: var(--border-radius-base);
657
- transition: background-color 0.2s;
658
-
659
- &:hover {
660
- background-color: var(--color-fill);
661
- }
662
- }
663
-
664
- &-name {
665
- font-size: 14px;
666
- color: var(--color-text-primary);
667
- max-width: 100px;
668
- overflow: hidden;
669
- text-overflow: ellipsis;
670
- white-space: nowrap;
671
- }
672
-
673
- &-arrow {
674
- font-size: 10px;
675
- color: var(--color-text-secondary);
676
- transition: transform 0.2s;
677
-
678
- &.is-active {
679
- transform: rotate(180deg);
680
- }
681
- }
682
- }
683
-
684
- &__avatar {
685
- width: 32px;
686
- height: 32px;
687
- border-radius: 50%;
688
- background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
689
- display: flex;
690
- align-items: center;
691
- justify-content: center;
692
- color: #fff;
693
- font-size: 14px;
694
- font-weight: 500;
695
- }
696
-
697
- &__dropdown {
698
- position: absolute;
699
- top: calc(100% + 8px);
700
- right: 0;
701
- min-width: 200px;
702
- background-color: var(--bg-color);
703
- border-radius: var(--border-radius-base);
704
- box-shadow: var(--box-shadow);
705
- overflow: hidden;
706
- z-index: 100;
707
-
708
- &-header {
709
- display: flex;
710
- align-items: center;
711
- gap: 12px;
712
- padding: 16px;
713
- }
714
-
715
- &-avatar {
716
- width: 40px;
717
- height: 40px;
718
- border-radius: 50%;
719
- background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
720
- display: flex;
721
- align-items: center;
722
- justify-content: center;
723
- color: #fff;
724
- font-size: 16px;
725
- font-weight: 500;
726
- }
727
-
728
- &-info {
729
- flex: 1;
730
- }
731
-
732
- &-name {
733
- font-size: 14px;
734
- font-weight: 500;
735
- color: var(--color-text-primary);
736
- }
737
-
738
- &-role {
739
- font-size: 12px;
740
- color: var(--color-text-secondary);
741
- margin-top: 2px;
742
- }
743
-
744
- &-divider {
745
- height: 1px;
746
- background-color: var(--color-border-lighter);
747
- }
748
-
749
- &-menu {
750
- padding: 8px 0;
751
- }
752
-
753
- &-item {
754
- display: flex;
755
- align-items: center;
756
- gap: 10px;
757
- padding: 10px 16px;
758
- cursor: pointer;
759
- font-size: 14px;
760
- color: var(--color-text-regular);
761
- transition: all 0.2s;
762
-
763
- &:hover {
764
- background-color: var(--color-fill);
765
- color: var(--color-text-primary);
766
- }
767
-
768
- &--danger {
769
- color: var(--color-danger);
770
-
771
- &:hover {
772
- background-color: var(--color-danger-light);
773
- color: var(--color-danger);
774
- }
775
- }
776
- }
777
- }
778
- }
779
-
780
- // 设置抽屉内容
781
- .settings-drawer {
782
- .settings-section {
783
- margin-bottom: 24px;
784
- }
785
-
786
- .settings-title {
787
- font-size: 14px;
788
- font-weight: 500;
789
- color: var(--color-text-primary);
790
- margin-bottom: 12px;
791
- }
792
-
793
- .settings-layout-options {
794
- display: flex;
795
- gap: 12px;
796
- }
797
-
798
- .layout-option {
799
- flex: 1;
800
- display: flex;
801
- flex-direction: column;
802
- align-items: center;
803
- gap: 8px;
804
- padding: 12px;
805
- border: 1px solid var(--color-border);
806
- border-radius: var(--border-radius-base);
807
- cursor: pointer;
808
- transition: all 0.2s;
809
-
810
- &:hover {
811
- border-color: var(--color-primary-light-5);
812
- }
813
-
814
- &.is-active {
815
- border-color: var(--color-primary);
816
- background-color: var(--color-primary-light-9);
817
- }
818
-
819
- &__preview {
820
- width: 48px;
821
- height: 36px;
822
- border: 1px solid var(--color-border-light);
823
- border-radius: 2px;
824
- overflow: hidden;
825
- }
826
-
827
- &__label {
828
- font-size: 12px;
829
- color: var(--color-text-regular);
830
- }
831
- }
832
-
833
- // 布局预览样式
834
- .layout-preview-sidebar {
835
- display: flex;
836
- height: 100%;
837
-
838
- .preview-aside {
839
- width: 25%;
840
- height: 100%;
841
- background-color: var(--color-primary-light-7);
842
- }
843
-
844
- .preview-main {
845
- flex: 1;
846
- display: flex;
847
- flex-direction: column;
848
-
849
- .preview-header {
850
- height: 20%;
851
- background-color: var(--color-border-light);
852
- }
853
-
854
- .preview-content {
855
- flex: 1;
856
- background-color: var(--bg-color-page);
857
- }
858
- }
859
- }
860
-
861
- .layout-preview-top {
862
- display: flex;
863
- flex-direction: column;
864
- height: 100%;
865
-
866
- .preview-header-full {
867
- height: 25%;
868
- background-color: var(--color-primary-light-7);
869
- }
870
-
871
- .preview-content-full {
872
- flex: 1;
873
- background-color: var(--bg-color-page);
874
- }
875
- }
876
-
877
- .layout-preview-mix {
878
- display: flex;
879
- flex-direction: column;
880
- height: 100%;
881
-
882
- .preview-header-mix {
883
- height: 25%;
884
- background-color: var(--color-primary-light-7);
885
- display: flex;
886
-
887
- .preview-mix-left {
888
- width: 30%;
889
- background-color: var(--color-primary);
890
- }
891
- }
892
-
893
- .preview-mix-body {
894
- flex: 1;
895
- display: flex;
896
-
897
- .preview-mix-aside {
898
- width: 25%;
899
- background-color: var(--color-primary-light-8);
900
- }
901
-
902
- .preview-mix-content {
903
- flex: 1;
904
- background-color: var(--bg-color-page);
905
- }
906
- }
907
- }
908
-
909
- .settings-color-options {
910
- display: flex;
911
- gap: 12px;
912
- }
913
-
914
- .color-option {
915
- width: 24px;
916
- height: 24px;
917
- border-radius: 4px;
918
- cursor: pointer;
919
- display: flex;
920
- align-items: center;
921
- justify-content: center;
922
- transition: transform 0.2s;
923
-
924
- &:hover {
925
- transform: scale(1.1);
926
- }
927
-
928
- &.is-active {
929
- box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--color-primary);
930
- }
931
- }
932
-
933
- .settings-switch-list {
934
- display: flex;
935
- flex-direction: column;
936
- gap: 12px;
937
- }
938
-
939
- .settings-switch-item {
940
- display: flex;
941
- align-items: center;
942
- justify-content: space-between;
943
-
944
- span {
945
- font-size: 14px;
946
- color: var(--color-text-regular);
947
- }
948
- }
949
-
950
- .switch-wrapper {
951
- width: 44px;
952
- height: 22px;
953
- display: flex;
954
- align-items: center;
955
- cursor: pointer;
956
-
957
- .switch-core {
958
- width: 100%;
959
- height: 100%;
960
- border-radius: 11px;
961
- background-color: var(--color-border);
962
- position: relative;
963
- transition: background-color 0.2s;
964
-
965
- &::after {
966
- content: '';
967
- position: absolute;
968
- top: 2px;
969
- left: 2px;
970
- width: 18px;
971
- height: 18px;
972
- background-color: #fff;
973
- border-radius: 50%;
974
- transition: left 0.2s;
975
- }
976
- }
977
-
978
- &.is-checked {
979
- .switch-core {
980
- background-color: var(--color-primary);
981
-
982
- &::after {
983
- left: 24px;
984
- }
985
- }
986
- }
987
- }
988
- }
989
-
990
- // 下拉动画
991
- .dropdown-enter-active,
992
- .dropdown-leave-active {
993
- transition: all 0.2s ease;
994
- }
995
-
996
- .dropdown-enter-from,
997
- .dropdown-leave-to {
998
- opacity: 0;
999
- transform: translateY(-10px);
1000
- }
1001
-
1002
- // 搜索下拉动画
1003
- .search-dropdown-enter-active,
1004
- .search-dropdown-leave-active {
1005
- transition: all 0.2s ease;
1006
- }
1007
-
1008
- .search-dropdown-enter-from,
1009
- .search-dropdown-leave-to {
1010
- opacity: 0;
1011
- transform: translateY(-4px);
1012
- }
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.menuName))
63
+ } else {
64
+ result.push({ ...menu, parentTitle, title: menu.menuName, path: menu.menuUrl })
65
+ }
66
+ })
67
+ return result
68
+ }
69
+
70
+ // 已知的图标名称列表(来自 @xto/base/icons.ts)
71
+ const knownIcons = new Set([
72
+ 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right',
73
+ 'caret-down', 'caret-right', 'plus', 'minus', 'close', 'check',
74
+ 'edit', 'delete', 'copy', 'download', 'upload', 'refresh', 'search',
75
+ 'filter', 'more', 'setting', 'share', 'loading', 'info', 'success',
76
+ 'warning', 'error', 'question', 'user', 'user-add', 'user-group',
77
+ 'logout', 'login', 'file', 'folder', 'folder-open', 'document',
78
+ 'image', 'video', 'music', 'camera', 'mail', 'phone', 'chat',
79
+ 'bell', 'message', 'eye', 'eye-off', 'calendar', 'clock', 'history',
80
+ 'timer', 'location', 'map', 'globe', 'star', 'heart', 'thumb-up',
81
+ 'link', 'external-link', 'lock', 'unlock', 'key', 'home', 'menu',
82
+ 'menu-fold', 'menu-unfold', 'sidebar-fold', 'sidebar-expand',
83
+ 'sidebar-left', 'dashboard', 'chart', 'chart-pie', 'chart-line',
84
+ 'report', 'analytics', 'system', 'permission', 'role', 'user-manage',
85
+ 'log', 'notification', 'app', 'list', 'grid', 'fullscreen',
86
+ 'fullscreen-exit', 'zoom-in', 'zoom-out', 'print', 'bookmark',
87
+ 'tag', 'code', 'terminal', 'database', 'server', 'cloud', 'gift',
88
+ 'moon', 'sun', 'theme', 'skin'
89
+ ])
90
+
91
+ // 获取菜单图标名称
92
+ const getMenuIcon = (icon?: string): string => {
93
+ if (!icon || icon === '') return ''
94
+ if (icon.startsWith('tineco-icon-')) {
95
+ const iconName = icon.replace('tineco-icon-', '')
96
+ const tinecoIconMap: Record<string, string> = {
97
+ home: 'home', dashboard: 'dashboard', system: 'system',
98
+ user: 'user', role: 'role', menu: 'list', setting: 'setting',
99
+ file: 'file', folder: 'folder', chart: 'chart', report: 'report', analytics: 'analytics'
100
+ }
101
+ return tinecoIconMap[iconName] || iconName
102
+ }
103
+ const iconMap: Record<string, string> = {
104
+ dashboard: 'dashboard', system: 'system', user: 'user', role: 'role',
105
+ menu: 'list', setting: 'setting', home: 'home', chart: 'chart',
106
+ report: 'report', analytics: 'analytics', permission: 'permission',
107
+ log: 'log', notification: 'notification', app: 'app', list: 'list', grid: 'grid'
108
+ }
109
+ return iconMap[icon] || icon
110
+ }
111
+
112
+ // 获取菜单名称第一个字
113
+ const getFirstChar = (name?: string): string => {
114
+ if (!name) return ''
115
+ return name.charAt(0)
116
+ }
117
+
118
+ // 判断图标是否存在
119
+ const iconExists = (iconName: string): boolean => {
120
+ return knownIcons.has(iconName)
121
+ }
122
+
123
+ // 搜索结果
124
+ const searchResults = computed(() => {
125
+ if (!searchKeyword.value.trim()) return []
126
+ const flatMenus = flattenMenus(menuStore.menuList)
127
+ return flatMenus.filter(menu =>
128
+ menu.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
129
+ )
130
+ })
131
+
132
+ // 当前布局模式
133
+ const currentLayout = computed(() => appStore.layout)
134
+
135
+ // 切换折叠
136
+ const toggleCollapse = () => {
137
+ appStore.toggleCollapse()
138
+ }
139
+
140
+ // 切换主题
141
+ const toggleTheme = () => {
142
+ appStore.toggleTheme()
143
+ }
144
+
145
+ // 打开设置抽屉
146
+ const openSettingsDrawer = () => {
147
+ drawerVisible.value = true
148
+ }
149
+
150
+ // 切换布局模式
151
+ const handleLayoutChange = (mode: LayoutMode) => {
152
+ appStore.setLayout(mode)
153
+ drawerVisible.value = false
154
+ }
155
+
156
+ // 切换灰色模式
157
+ const handleGreyModeChange = (value: boolean) => {
158
+ greyMode.value = value
159
+ const html = document.documentElement
160
+ if (value) {
161
+ html.classList.add('grey-mode')
162
+ } else {
163
+ html.classList.remove('grey-mode')
164
+ }
165
+ }
166
+
167
+ // 抽屉内灰色模式开关
168
+ const handleGreyModeToggle = () => {
169
+ handleGreyModeChange(!greyMode.value)
170
+ drawerVisible.value = false
171
+ }
172
+
173
+ // 抽屉内暗黑模式开关
174
+ const handleDarkModeToggle = () => {
175
+ appStore.toggleTheme()
176
+ drawerVisible.value = false
177
+ }
178
+
179
+ // 切换全屏
180
+ const toggleFullscreen = () => {
181
+ if (!document.fullscreenElement) {
182
+ document.documentElement.requestFullscreen()
183
+ } else {
184
+ document.exitFullscreen()
185
+ }
186
+ }
187
+
188
+ // 监听全屏变化
189
+ const handleFullscreenChange = () => {
190
+ isFullscreen.value = !!document.fullscreenElement
191
+ }
192
+
193
+ // 切换下拉菜单
194
+ const toggleDropdown = () => {
195
+ dropdownVisible.value = !dropdownVisible.value
196
+ }
197
+
198
+ // 关闭下拉菜单
199
+ const closeDropdowns = () => {
200
+ dropdownVisible.value = false
201
+ }
202
+
203
+ // 隐藏搜索
204
+ const hideSearch = () => {
205
+ searchVisible.value = false
206
+ searchKeyword.value = ''
207
+ }
208
+
209
+ // 搜索结果点击
210
+ const handleSearchItemClick = (path: string) => {
211
+ router.push(path)
212
+ hideSearch()
213
+ }
214
+
215
+ // 设置主题色
216
+ const handleColorChange = (color: string) => {
217
+ appStore.setPrimaryColor(color)
218
+ drawerVisible.value = false
219
+ }
220
+
221
+ // 个人信息
222
+ const handleProfile = () => {
223
+ closeDropdowns()
224
+ router.push('/profile')
225
+ }
226
+
227
+ // 修改密码
228
+ const handleChangePassword = () => {
229
+ closeDropdowns()
230
+ router.push('/change-password')
231
+ }
232
+
233
+ // 退出登录
234
+ const handleLogout = () => {
235
+ closeDropdowns()
236
+ authStore.logout()
237
+ userStore.clearUserInfo()
238
+ menuStore.clearMenu()
239
+ router.push(appStore.loginPath)
240
+ }
241
+
242
+ // 点击外部关闭下拉菜单
243
+ const handleClickOutside = (event: MouseEvent) => {
244
+ if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
245
+ closeDropdowns()
246
+ }
247
+ if (searchRef.value && !searchRef.value.contains(event.target as Node)) {
248
+ hideSearch()
249
+ }
250
+ }
251
+
252
+ // 键盘快捷键
253
+ const handleKeydown = (event: KeyboardEvent) => {
254
+ if (event.key === 'Escape') {
255
+ hideSearch()
256
+ closeDropdowns()
257
+ }
258
+ }
259
+
260
+ onMounted(() => {
261
+ document.addEventListener('click', handleClickOutside)
262
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
263
+ document.addEventListener('keydown', handleKeydown)
264
+ appStore.initTheme()
265
+ // 初始化灰色模式状态
266
+ greyMode.value = document.documentElement.classList.contains('grey-mode')
267
+ })
268
+
269
+ onUnmounted(() => {
270
+ document.removeEventListener('click', handleClickOutside)
271
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
272
+ document.removeEventListener('keydown', handleKeydown)
273
+ })
274
+ </script>
275
+
276
+ <template>
277
+ <div class="header">
278
+ <!-- 左侧 -->
279
+ <div class="header__left">
280
+ <!-- 折叠按钮 -->
281
+ <div class="header__collapse" @click="toggleCollapse">
282
+ <Icon :name="appStore.isCollapsed ? 'menu-unfold' : 'menu-fold'" :size="18" />
283
+ </div>
284
+
285
+ <!-- 面包屑 -->
286
+ <div v-if="appStore.showBreadcrumb" class="header__breadcrumb">
287
+ <span v-for="(item, index) in breadcrumbs" :key="item.path">
288
+ <span v-if="index > 0" class="breadcrumb-separator">/</span>
289
+ <span :class="{ 'is-current': index === breadcrumbs.length - 1 }">
290
+ {{ item.title }}
291
+ </span>
292
+ </span>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- 右侧 -->
297
+ <div class="header__right">
298
+ <!-- 搜索输入框 -->
299
+ <div class="header__search" ref="searchRef">
300
+ <Icon name="search" :size="14" class="header__search-icon" />
301
+ <input
302
+ v-model="searchKeyword"
303
+ type="text"
304
+ class="header__search-input"
305
+ placeholder="搜索菜单..."
306
+ @focus="searchVisible = true"
307
+ />
308
+ <!-- 搜索结果下拉 -->
309
+ <Transition name="search-dropdown">
310
+ <div v-if="searchVisible && (searchResults.length > 0 || searchKeyword)" class="header__search-dropdown">
311
+ <div v-if="searchResults.length > 0" class="header__search-results">
312
+ <div
313
+ v-for="item in searchResults"
314
+ :key="item.path"
315
+ class="header__search-item"
316
+ @click="handleSearchItemClick(item.path)"
317
+ >
318
+ <span v-if="item.title !== '首页'" class="header__search-icon">
319
+ <Icon v-if="iconExists(getMenuIcon(item.icon))" :name="getMenuIcon(item.icon)" :size="16" />
320
+ <span v-else class="header__search-char">{{ getFirstChar(item.title) }}</span>
321
+ </span>
322
+ <span class="header__search-item-title">{{ item.title }}</span>
323
+ <span v-if="item.parentTitle" class="header__search-item-parent">{{ item.parentTitle }}</span>
324
+ </div>
325
+ </div>
326
+ <div v-else class="header__search-empty">
327
+ 未找到匹配的菜单
328
+ </div>
329
+ </div>
330
+ </Transition>
331
+ </div>
332
+
333
+ <!-- 全屏切换 -->
334
+ <div class="header__action" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
335
+ <Icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" :size="16" />
336
+ </div>
337
+
338
+ <!-- 换肤设置 -->
339
+ <div class="header__action" @click="openSettingsDrawer" title="换肤设置">
340
+ <Icon name="skin" :size="16" />
341
+ </div>
342
+
343
+ <!-- 主题切换 -->
344
+ <div class="header__action" @click="toggleTheme" title="切换主题">
345
+ <Icon :name="appStore.isDark ? 'sun' : 'moon'" :size="16" />
346
+ </div>
347
+
348
+ <!-- 用户头像 -->
349
+ <div class="header__user" ref="dropdownRef">
350
+ <div class="header__user-trigger" @click.stop="toggleDropdown">
351
+ <div class="header__avatar">
352
+ <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
353
+ </div>
354
+ <span class="header__user-name">{{ userStore.userName }}</span>
355
+ <span class="header__user-arrow" :class="{ 'is-active': dropdownVisible }">▼</span>
356
+ </div>
357
+
358
+ <!-- 下拉菜单 -->
359
+ <Transition name="dropdown">
360
+ <div v-if="dropdownVisible" class="header__dropdown">
361
+ <div class="header__dropdown-header">
362
+ <div class="header__dropdown-avatar">
363
+ <span>{{ userStore.userName?.charAt(0) || 'U' }}</span>
364
+ </div>
365
+ <div class="header__dropdown-info">
366
+ <div class="header__dropdown-name">{{ userStore.userName }}</div>
367
+ <div class="header__dropdown-role">{{ userStore.roleName || userStore.departmentName }}</div>
368
+ </div>
369
+ </div>
370
+ <div class="header__dropdown-divider"></div>
371
+ <div class="header__dropdown-menu">
372
+ <div class="header__dropdown-item" @click="handleProfile">
373
+ <Icon name="user" :size="16" />
374
+ <span>个人信息</span>
375
+ </div>
376
+ <div class="header__dropdown-item" @click="handleChangePassword">
377
+ <Icon name="lock" :size="16" />
378
+ <span>修改密码</span>
379
+ </div>
380
+ <div class="header__dropdown-divider"></div>
381
+ <div class="header__dropdown-item header__dropdown-item--danger" @click="handleLogout">
382
+ <Icon name="logout" :size="16" />
383
+ <span>退出登录</span>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ </Transition>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- 换肤设置抽屉 -->
392
+ <Drawer
393
+ v-model="drawerVisible"
394
+ title="换肤设置"
395
+ direction="rtl"
396
+ size="320px"
397
+ >
398
+ <div class="settings-drawer">
399
+ <!-- 布局模式 -->
400
+ <div class="settings-section">
401
+ <div class="settings-title">布局模式</div>
402
+ <div class="settings-layout-options">
403
+ <div
404
+ v-for="option in layoutOptions"
405
+ :key="option.value"
406
+ :class="['layout-option', { 'is-active': currentLayout === option.value }]"
407
+ @click="handleLayoutChange(option.value)"
408
+ >
409
+ <div class="layout-option__preview">
410
+ <div v-if="option.value === 'sidebar'" class="layout-preview-sidebar">
411
+ <div class="preview-aside"></div>
412
+ <div class="preview-main">
413
+ <div class="preview-header"></div>
414
+ <div class="preview-content"></div>
415
+ </div>
416
+ </div>
417
+ <div v-else-if="option.value === 'top'" class="layout-preview-top">
418
+ <div class="preview-header-full"></div>
419
+ <div class="preview-content-full"></div>
420
+ </div>
421
+ <div v-else class="layout-preview-mix">
422
+ <div class="preview-header-mix">
423
+ <div class="preview-mix-left"></div>
424
+ </div>
425
+ <div class="preview-mix-body">
426
+ <div class="preview-mix-aside"></div>
427
+ <div class="preview-mix-content"></div>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ <span class="layout-option__label">{{ option.label }}</span>
432
+ </div>
433
+ </div>
434
+ </div>
435
+
436
+ <!-- 主题色 -->
437
+ <div class="settings-section">
438
+ <div class="settings-title">主题色</div>
439
+ <div class="settings-color-options">
440
+ <div
441
+ v-for="color in colorOptions"
442
+ :key="color.value"
443
+ :class="['color-option', { 'is-active': appStore.primaryColor === color.value }]"
444
+ :style="{ backgroundColor: color.value }"
445
+ :title="color.label"
446
+ @click="handleColorChange(color.value)"
447
+ >
448
+ <Icon v-if="appStore.primaryColor === color.value" name="check" :size="12" color="#fff" />
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ <!-- 功能开关 -->
454
+ <div class="settings-section">
455
+ <div class="settings-title">功能设置</div>
456
+ <div class="settings-switch-list">
457
+ <div class="settings-switch-item">
458
+ <span>灰色模式</span>
459
+ <div class="switch-wrapper" :class="{ 'is-checked': greyMode }" @click="handleGreyModeToggle">
460
+ <span class="switch-core"></span>
461
+ </div>
462
+ </div>
463
+ <div class="settings-switch-item">
464
+ <span>暗黑模式</span>
465
+ <div class="switch-wrapper" :class="{ 'is-checked': appStore.isDark }" @click="handleDarkModeToggle">
466
+ <span class="switch-core"></span>
467
+ </div>
468
+ </div>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </Drawer>
473
+ </div>
474
+ </template>
475
+
476
+ <style lang="scss" scoped>
477
+ .header {
478
+ display: flex;
479
+ align-items: center;
480
+ justify-content: space-between;
481
+ padding: 0 20px;
482
+ height: 100%;
483
+ background-color: var(--bg-color);
484
+ border-bottom: 1px solid var(--color-border-lighter);
485
+
486
+ &__left {
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 15px;
490
+ }
491
+
492
+ &__collapse {
493
+ width: 24px;
494
+ height: 24px;
495
+ display: flex;
496
+ align-items: center;
497
+ justify-content: center;
498
+ cursor: pointer;
499
+ color: var(--color-text-regular);
500
+ transition: color 0.2s;
501
+
502
+ &:hover {
503
+ color: var(--color-primary);
504
+ }
505
+ }
506
+
507
+ &__breadcrumb {
508
+ font-size: 14px;
509
+ color: var(--color-text-secondary);
510
+
511
+ .breadcrumb-separator {
512
+ margin: 0 8px;
513
+ color: var(--color-text-placeholder);
514
+ }
515
+
516
+ .is-current {
517
+ color: var(--color-text-primary);
518
+ font-weight: 500;
519
+ }
520
+ }
521
+
522
+ &__right {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 8px;
526
+ }
527
+
528
+ &__search {
529
+ position: relative;
530
+ display: flex;
531
+ align-items: center;
532
+ background-color: var(--color-fill-light);
533
+ border-radius: var(--border-radius-base);
534
+ padding: 0 12px;
535
+ height: 32px;
536
+ width: 200px;
537
+
538
+ &-icon {
539
+ color: var(--color-text-secondary);
540
+ }
541
+
542
+ &-input {
543
+ flex: 1;
544
+ height: 100%;
545
+ font-size: 14px;
546
+ color: var(--color-text-primary);
547
+ background: transparent;
548
+ border: none;
549
+ outline: none;
550
+ padding-left: 8px;
551
+
552
+ &::placeholder {
553
+ color: var(--color-text-placeholder);
554
+ }
555
+ }
556
+
557
+ &-dropdown {
558
+ position: absolute;
559
+ top: calc(100% + 4px);
560
+ left: 0;
561
+ right: 0;
562
+ min-width: 200px;
563
+ max-height: 300px;
564
+ overflow-y: auto;
565
+ background-color: var(--bg-color);
566
+ border-radius: var(--border-radius-base);
567
+ box-shadow: var(--box-shadow);
568
+ z-index: 100;
569
+ }
570
+
571
+ &-results {
572
+ padding: 8px 0;
573
+ }
574
+
575
+ &-item {
576
+ display: flex;
577
+ align-items: center;
578
+ gap: 8px;
579
+ padding: 8px 12px;
580
+ cursor: pointer;
581
+ transition: background-color 0.2s;
582
+
583
+ &:hover {
584
+ background-color: var(--color-fill);
585
+ }
586
+
587
+ &-title {
588
+ font-size: 14px;
589
+ color: var(--color-text-primary);
590
+ }
591
+
592
+ &-parent {
593
+ font-size: 12px;
594
+ color: var(--color-text-secondary);
595
+ margin-left: auto;
596
+ }
597
+ }
598
+
599
+ &-icon {
600
+ display: inline-flex;
601
+ align-items: center;
602
+ justify-content: center;
603
+ width: 16px;
604
+ height: 16px;
605
+ flex-shrink: 0;
606
+ }
607
+
608
+ &-char {
609
+ display: inline-flex;
610
+ align-items: center;
611
+ justify-content: center;
612
+ width: 16px;
613
+ height: 16px;
614
+ font-size: 12px;
615
+ font-weight: 600;
616
+ color: var(--color-primary);
617
+ background-color: var(--color-primary-light-8);
618
+ border-radius: 4px;
619
+ }
620
+
621
+ &-empty {
622
+ padding: 16px 12px;
623
+ text-align: center;
624
+ color: var(--color-text-secondary);
625
+ font-size: 14px;
626
+ }
627
+ }
628
+
629
+ &__action {
630
+ width: 32px;
631
+ height: 32px;
632
+ display: flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ cursor: pointer;
636
+ border-radius: var(--border-radius-base);
637
+ color: var(--color-text-regular);
638
+ transition: all 0.2s;
639
+
640
+ &:hover {
641
+ background-color: var(--color-fill);
642
+ color: var(--color-primary);
643
+ }
644
+ }
645
+
646
+ &__user {
647
+ position: relative;
648
+ margin-left: 8px;
649
+
650
+ &-trigger {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 8px;
654
+ cursor: pointer;
655
+ padding: 4px 8px;
656
+ border-radius: var(--border-radius-base);
657
+ transition: background-color 0.2s;
658
+
659
+ &:hover {
660
+ background-color: var(--color-fill);
661
+ }
662
+ }
663
+
664
+ &-name {
665
+ font-size: 14px;
666
+ color: var(--color-text-primary);
667
+ max-width: 100px;
668
+ overflow: hidden;
669
+ text-overflow: ellipsis;
670
+ white-space: nowrap;
671
+ }
672
+
673
+ &-arrow {
674
+ font-size: 10px;
675
+ color: var(--color-text-secondary);
676
+ transition: transform 0.2s;
677
+
678
+ &.is-active {
679
+ transform: rotate(180deg);
680
+ }
681
+ }
682
+ }
683
+
684
+ &__avatar {
685
+ width: 32px;
686
+ height: 32px;
687
+ border-radius: 50%;
688
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
689
+ display: flex;
690
+ align-items: center;
691
+ justify-content: center;
692
+ color: #fff;
693
+ font-size: 14px;
694
+ font-weight: 500;
695
+ }
696
+
697
+ &__dropdown {
698
+ position: absolute;
699
+ top: calc(100% + 8px);
700
+ right: 0;
701
+ min-width: 200px;
702
+ background-color: var(--bg-color);
703
+ border-radius: var(--border-radius-base);
704
+ box-shadow: var(--box-shadow);
705
+ overflow: hidden;
706
+ z-index: 100;
707
+
708
+ &-header {
709
+ display: flex;
710
+ align-items: center;
711
+ gap: 12px;
712
+ padding: 16px;
713
+ }
714
+
715
+ &-avatar {
716
+ width: 40px;
717
+ height: 40px;
718
+ border-radius: 50%;
719
+ background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light-3));
720
+ display: flex;
721
+ align-items: center;
722
+ justify-content: center;
723
+ color: #fff;
724
+ font-size: 16px;
725
+ font-weight: 500;
726
+ }
727
+
728
+ &-info {
729
+ flex: 1;
730
+ }
731
+
732
+ &-name {
733
+ font-size: 14px;
734
+ font-weight: 500;
735
+ color: var(--color-text-primary);
736
+ }
737
+
738
+ &-role {
739
+ font-size: 12px;
740
+ color: var(--color-text-secondary);
741
+ margin-top: 2px;
742
+ }
743
+
744
+ &-divider {
745
+ height: 1px;
746
+ background-color: var(--color-border-lighter);
747
+ }
748
+
749
+ &-menu {
750
+ padding: 8px 0;
751
+ }
752
+
753
+ &-item {
754
+ display: flex;
755
+ align-items: center;
756
+ gap: 10px;
757
+ padding: 10px 16px;
758
+ cursor: pointer;
759
+ font-size: 14px;
760
+ color: var(--color-text-regular);
761
+ transition: all 0.2s;
762
+
763
+ &:hover {
764
+ background-color: var(--color-fill);
765
+ color: var(--color-text-primary);
766
+ }
767
+
768
+ &--danger {
769
+ color: var(--color-danger);
770
+
771
+ &:hover {
772
+ background-color: var(--color-danger-light);
773
+ color: var(--color-danger);
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+
780
+ // 设置抽屉内容
781
+ .settings-drawer {
782
+ .settings-section {
783
+ margin-bottom: 24px;
784
+ }
785
+
786
+ .settings-title {
787
+ font-size: 14px;
788
+ font-weight: 500;
789
+ color: var(--color-text-primary);
790
+ margin-bottom: 12px;
791
+ }
792
+
793
+ .settings-layout-options {
794
+ display: flex;
795
+ gap: 12px;
796
+ }
797
+
798
+ .layout-option {
799
+ flex: 1;
800
+ display: flex;
801
+ flex-direction: column;
802
+ align-items: center;
803
+ gap: 8px;
804
+ padding: 12px;
805
+ border: 1px solid var(--color-border);
806
+ border-radius: var(--border-radius-base);
807
+ cursor: pointer;
808
+ transition: all 0.2s;
809
+
810
+ &:hover {
811
+ border-color: var(--color-primary-light-5);
812
+ }
813
+
814
+ &.is-active {
815
+ border-color: var(--color-primary);
816
+ background-color: var(--color-primary-light-9);
817
+ }
818
+
819
+ &__preview {
820
+ width: 48px;
821
+ height: 36px;
822
+ border: 1px solid var(--color-border-light);
823
+ border-radius: 2px;
824
+ overflow: hidden;
825
+ }
826
+
827
+ &__label {
828
+ font-size: 12px;
829
+ color: var(--color-text-regular);
830
+ }
831
+ }
832
+
833
+ // 布局预览样式
834
+ .layout-preview-sidebar {
835
+ display: flex;
836
+ height: 100%;
837
+
838
+ .preview-aside {
839
+ width: 25%;
840
+ height: 100%;
841
+ background-color: var(--color-primary-light-7);
842
+ }
843
+
844
+ .preview-main {
845
+ flex: 1;
846
+ display: flex;
847
+ flex-direction: column;
848
+
849
+ .preview-header {
850
+ height: 20%;
851
+ background-color: var(--color-border-light);
852
+ }
853
+
854
+ .preview-content {
855
+ flex: 1;
856
+ background-color: var(--bg-color-page);
857
+ }
858
+ }
859
+ }
860
+
861
+ .layout-preview-top {
862
+ display: flex;
863
+ flex-direction: column;
864
+ height: 100%;
865
+
866
+ .preview-header-full {
867
+ height: 25%;
868
+ background-color: var(--color-primary-light-7);
869
+ }
870
+
871
+ .preview-content-full {
872
+ flex: 1;
873
+ background-color: var(--bg-color-page);
874
+ }
875
+ }
876
+
877
+ .layout-preview-mix {
878
+ display: flex;
879
+ flex-direction: column;
880
+ height: 100%;
881
+
882
+ .preview-header-mix {
883
+ height: 25%;
884
+ background-color: var(--color-primary-light-7);
885
+ display: flex;
886
+
887
+ .preview-mix-left {
888
+ width: 30%;
889
+ background-color: var(--color-primary);
890
+ }
891
+ }
892
+
893
+ .preview-mix-body {
894
+ flex: 1;
895
+ display: flex;
896
+
897
+ .preview-mix-aside {
898
+ width: 25%;
899
+ background-color: var(--color-primary-light-8);
900
+ }
901
+
902
+ .preview-mix-content {
903
+ flex: 1;
904
+ background-color: var(--bg-color-page);
905
+ }
906
+ }
907
+ }
908
+
909
+ .settings-color-options {
910
+ display: flex;
911
+ gap: 12px;
912
+ }
913
+
914
+ .color-option {
915
+ width: 24px;
916
+ height: 24px;
917
+ border-radius: 4px;
918
+ cursor: pointer;
919
+ display: flex;
920
+ align-items: center;
921
+ justify-content: center;
922
+ transition: transform 0.2s;
923
+
924
+ &:hover {
925
+ transform: scale(1.1);
926
+ }
927
+
928
+ &.is-active {
929
+ box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--color-primary);
930
+ }
931
+ }
932
+
933
+ .settings-switch-list {
934
+ display: flex;
935
+ flex-direction: column;
936
+ gap: 12px;
937
+ }
938
+
939
+ .settings-switch-item {
940
+ display: flex;
941
+ align-items: center;
942
+ justify-content: space-between;
943
+
944
+ span {
945
+ font-size: 14px;
946
+ color: var(--color-text-regular);
947
+ }
948
+ }
949
+
950
+ .switch-wrapper {
951
+ width: 44px;
952
+ height: 22px;
953
+ display: flex;
954
+ align-items: center;
955
+ cursor: pointer;
956
+
957
+ .switch-core {
958
+ width: 100%;
959
+ height: 100%;
960
+ border-radius: 11px;
961
+ background-color: var(--color-border);
962
+ position: relative;
963
+ transition: background-color 0.2s;
964
+
965
+ &::after {
966
+ content: '';
967
+ position: absolute;
968
+ top: 2px;
969
+ left: 2px;
970
+ width: 18px;
971
+ height: 18px;
972
+ background-color: #fff;
973
+ border-radius: 50%;
974
+ transition: left 0.2s;
975
+ }
976
+ }
977
+
978
+ &.is-checked {
979
+ .switch-core {
980
+ background-color: var(--color-primary);
981
+
982
+ &::after {
983
+ left: 24px;
984
+ }
985
+ }
986
+ }
987
+ }
988
+ }
989
+
990
+ // 下拉动画
991
+ .dropdown-enter-active,
992
+ .dropdown-leave-active {
993
+ transition: all 0.2s ease;
994
+ }
995
+
996
+ .dropdown-enter-from,
997
+ .dropdown-leave-to {
998
+ opacity: 0;
999
+ transform: translateY(-10px);
1000
+ }
1001
+
1002
+ // 搜索下拉动画
1003
+ .search-dropdown-enter-active,
1004
+ .search-dropdown-leave-active {
1005
+ transition: all 0.2s ease;
1006
+ }
1007
+
1008
+ .search-dropdown-enter-from,
1009
+ .search-dropdown-leave-to {
1010
+ opacity: 0;
1011
+ transform: translateY(-4px);
1012
+ }
1013
1013
  </style>