diff --git a/package.json b/package.json index f304b751..05dc5a3b 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@changesets/cli": "catalog:", "@tsdown/css": "catalog:", "@types/node": "catalog:", + "@types/sortablejs": "catalog:", "@vben/commitlint-config": "workspace:*", "@vben/eslint-config": "workspace:*", "@vben/oxfmt-config": "workspace:*", @@ -106,5 +107,8 @@ "node": "^22.18.0 || ^24.0.0", "pnpm": ">=11.0.0" }, - "packageManager": "pnpm@11.7.0" + "packageManager": "pnpm@11.7.0", + "dependencies": { + "sortablejs": "catalog:" + } } diff --git a/packages/@core/base/typings/src/app.d.ts b/packages/@core/base/typings/src/app.d.ts index dc6081b1..420ca800 100644 --- a/packages/@core/base/typings/src/app.d.ts +++ b/packages/@core/base/typings/src/app.d.ts @@ -10,16 +10,18 @@ type LayoutType = type ThemeModeType = 'auto' | 'dark' | 'light'; /** - * 按钮位置 - * user-dropdown 用户的下拉弹出框中 - * fixed 固定在右侧 + * 偏好设置按钮位置 + * auto 自动(按布局上下文在 header/fixed 间切换) + * fixed 固定在屏幕右边缘 * header 顶栏 - * auto 自动 + * user-dropdown 用户的下拉弹出框中 + * none 不显示 */ type PreferencesButtonPositionType = | 'auto' | 'fixed' | 'header' + | 'none' | 'user-dropdown'; type BuiltinThemeType = diff --git a/packages/@core/preferences/src/config.ts b/packages/@core/preferences/src/config.ts index edf97c1f..0c47ad1b 100644 --- a/packages/@core/preferences/src/config.ts +++ b/packages/@core/preferences/src/config.ts @@ -154,6 +154,17 @@ const defaultPreferences: Preferences = { themeToggleButtonPosition: 'header', timezone: true, timezoneButtonPosition: 'header', + order: [ + 'globalSearch', + 'preferences', + 'themeToggle', + 'languageToggle', + 'timezone', + 'fullscreen', + 'notification', + 'lockScreenBtn', + 'logoutBtn', + ], }, }; diff --git a/packages/@core/preferences/src/preferences.ts b/packages/@core/preferences/src/preferences.ts index c7f5caa3..80677da0 100644 --- a/packages/@core/preferences/src/preferences.ts +++ b/packages/@core/preferences/src/preferences.ts @@ -11,7 +11,11 @@ import type { import { markRaw, reactive, readonly, watch } from 'vue'; import { StorageManager } from '@vben-core/shared/cache'; -import { isMacOs, merge } from '@vben-core/shared/utils'; +import { + isMacOs, + merge, + mergeWithArrayOverride, +} from '@vben-core/shared/utils'; import { breakpointsTailwind, @@ -133,7 +137,11 @@ class PreferenceManager { // 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段 const cachedPreferences = (await this.loadFromCache()) || {}; - const mergedPreference = merge( + // 修复历史脏数据:早期版本用 defu(merge) 合并数组,导致 widget.order + // 在每次 updatePreferences 时被 concat 追加,膨胀到几十/上百条重复 key。 + // 这里在加载缓存后做一次去重,保留首次出现的 key。 + this.sanitizeCachedArray(cachedPreferences, 'widget', 'order'); + const mergedPreference = mergeWithArrayOverride( {}, cachedPreferences, // 用户缓存的设置优先 this.initialPreferences, // 初始设置仅补齐缺失字段 @@ -144,7 +152,7 @@ class PreferenceManager { const cachedCustom = (await this.loadCustomFromCache()) || {}; this.replaceCustomPreferences( - merge( + mergeWithArrayOverride( {}, this.sanitizeCustomPreferences(cachedCustom), this.initialCustomPreferences, @@ -194,7 +202,7 @@ class PreferenceManager { } this.replaceCustomPreferences( - merge({}, sanitizedUpdates, markRaw(this.customState)), + mergeWithArrayOverride({}, sanitizedUpdates, markRaw(this.customState)), ); this.debouncedSave(); }; @@ -205,7 +213,14 @@ class PreferenceManager { */ updatePreferences = (updates: DeepPartial) => { // 深度合并更新内容和当前状态 - const mergedState = merge({}, updates, markRaw(this.state)); + // 注意:必须用 mergeWithArrayOverride 而不是 merge。 + // defu(merge) 对数组的语义是 concat 追加,会导致 widget.order 等 + // 数组字段在每次 updatePreferences 时被重复追加,指数级膨胀。 + const mergedState = mergeWithArrayOverride( + {}, + updates, + markRaw(this.state), + ); Object.assign(this.state, mergedState); // 根据更新的值执行更新 @@ -361,6 +376,28 @@ class PreferenceManager { : undefined; } + /** + * 清理缓存中膨胀的数组字段(保留首次出现的元素) + * 用于修复早期版本 defu 合并数组时 concat 追加导致的脏数据 + */ + private sanitizeCachedArray( + cached: Record, + group: string, + field: string, + ) { + const node = cached?.[group]?.[field]; + if (!Array.isArray(node) || node.length <= 1) return; + const seen = new Set(); + const deduped = node.filter((item) => { + if (seen.has(item)) return false; + seen.add(item); + return true; + }); + if (deduped.length !== node.length) { + cached[group][field] = deduped; + } + } + private sanitizeCustomPreferences( updates: DeepPartial, ) { diff --git a/packages/@core/preferences/src/types.ts b/packages/@core/preferences/src/types.ts index 9cd39e20..2eca2292 100644 --- a/packages/@core/preferences/src/types.ts +++ b/packages/@core/preferences/src/types.ts @@ -385,6 +385,8 @@ interface WidgetPreferences { notification: boolean; /** 通知按钮位置 */ notificationButtonPosition: 'header' | 'none' | 'user-dropdown'; + /** 小部件排序 */ + order: readonly string[]; /** 显示刷新按钮 */ refresh: boolean; /** 刷新按钮位置 */ diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue b/packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue index f9c7b0be..47e1cc32 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue +++ b/packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue @@ -12,7 +12,7 @@ import VbenButton from './button.vue'; interface Props extends VbenButtonProps { class?: any; disabled?: boolean; - onClick?: () => void; + onClick?: (() => void)[] | (() => void); tooltip?: string; tooltipDelayDuration?: number; tooltipSide?: 'bottom' | 'left' | 'right' | 'top'; @@ -30,6 +30,16 @@ const props = withDefaults(defineProps(), { const slots = useSlots(); const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip); + +function handleClick() { + if (Array.isArray(props.onClick)) { + for (const fn of props.onClick) { + fn?.(); + } + } else { + props.onClick?.(); + } +}