feat: 偏好设置小部件支持位置选择与拖拽排序

- 顶栏各小部件按钮支持选择位置(header/user-dropdown/none),偏好设置按钮额外保留 auto/fixed 智能兜底,移动端/全屏内容模式下自动切 fixed 浮动保命,避免偏好入口死锁
- draggable-list.vue 的 listRef 未在模板绑定,Sortable 永不初始化导致小部件无法拖拽;加 ref 绑定与 data-key 让 onEnd 从 DOM 读真实新顺序
- preferences.ts 的 updatePreferences 用 defu(merge) 合并数组,对 widget.order 是 concat 语义,每次拖拽触发更新都会把当前 order 追加一次,localStorage 里膨胀到上百条;改用 mergeWithArrayOverride 整体替换,加载缓存时 sanitizeCachedArray 去重已存在的脏数据
- preferences 按钮之前在 widget.order 里因 labelMap 未登记显示成裸字符串,且 widgetChecks 缺 refresh 导致刷新按钮不按 order 渲染;统一 preferences 与其它 widget 的拖拽排序与位置 select,refresh 补进 rightSlots.widgetChecks 并补模板渲染
- 补 sortablejs 依赖、WidgetPreferences.order 类型字段、preferences-drawer.vue 的 widgetOrder 绑定
This commit is contained in:
金毛88
2026-07-19 20:38:30 +08:00
parent b71ebbd673
commit f840d235ef
21 changed files with 848 additions and 161 deletions

View File

@@ -137,14 +137,34 @@ const defaultPreferences: Preferences = {
},
widget: {
fullscreen: true,
fullscreenButtonPosition: 'header',
globalSearch: true,
globalSearchButtonPosition: 'header',
languageToggle: true,
languageToggleButtonPosition: 'header',
lockScreen: true,
lockScreenButtonPosition: 'header',
logoutButtonPosition: 'header',
notification: true,
notificationButtonPosition: 'header',
refresh: true,
refreshButtonPosition: 'header',
sidebarToggle: true,
themeToggle: true,
themeToggleButtonPosition: 'header',
timezone: true,
timezoneButtonPosition: 'header',
order: [
'globalSearch',
'preferences',
'themeToggle',
'languageToggle',
'timezone',
'fullscreen',
'notification',
'lockScreenBtn',
'logoutBtn',
],
},
};

View File

@@ -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<Preferences>) => {
// 深度合并更新内容和当前状态
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<string, any>,
group: string,
field: string,
) {
const node = cached?.[group]?.[field];
if (!Array.isArray(node) || node.length <= 1) return;
const seen = new Set<unknown>();
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<CustomPreferencesRecord>,
) {

View File

@@ -365,22 +365,42 @@ interface TransitionPreferences {
interface WidgetPreferences {
/** 是否启用全屏部件 */
fullscreen: boolean;
/** 全屏按钮位置 */
fullscreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用全局搜索部件 */
globalSearch: boolean;
/** 全局搜索按钮位置 */
globalSearchButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用语言切换部件 */
languageToggle: boolean;
/** 语言切换按钮位置 */
languageToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否开启锁屏功能 */
lockScreen: boolean;
/** 锁屏按钮位置 */
lockScreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 退出登录按钮位置 */
logoutButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示通知部件 */
notification: boolean;
/** 通知按钮位置 */
notificationButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 显示刷新按钮 */
refresh: boolean;
/** 刷新按钮位置 */
refreshButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示侧边栏显示/隐藏部件 */
sidebarToggle: boolean;
/** 是否显示主题切换部件 */
themeToggle: boolean;
/** 主题切换按钮位置 */
themeToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示时区部件 */
timezone: boolean;
/** 时区按钮位置 */
timezoneButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 小部件排序 */
order: readonly string[];
}
interface Preferences {