xto-fronted 0.4.64 → 0.4.66

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.
@@ -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('/login')
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('/login')
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
+ }
1013
1013
  </style>