Merge branch 'main' into main
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { defaultPreferences } from '../src/config';
|
||||
import { PreferenceManager } from '../src/preferences';
|
||||
import { isDarkTheme } from '../src/update-css-variables';
|
||||
|
||||
describe('preferences', () => {
|
||||
let preferenceManager: PreferenceManager;
|
||||
let PreferenceManager: typeof import('../src/preferences').PreferenceManager;
|
||||
let preferenceManager: InstanceType<
|
||||
typeof import('../src/preferences').PreferenceManager
|
||||
>;
|
||||
|
||||
// 模拟 window.matchMedia 方法
|
||||
vi.stubGlobal(
|
||||
@@ -21,7 +23,36 @@ describe('preferences', () => {
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
})),
|
||||
);
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PreferenceManager } = await import('../src/preferences'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => null);
|
||||
vi.mocked(localStorage.removeItem).mockReset();
|
||||
vi.mocked(localStorage.setItem).mockReset();
|
||||
vi.mocked(sessionStorage.getItem).mockImplementation(() => null);
|
||||
vi.mocked(sessionStorage.removeItem).mockReset();
|
||||
vi.mocked(sessionStorage.setItem).mockReset();
|
||||
preferenceManager = new PreferenceManager();
|
||||
});
|
||||
|
||||
@@ -214,7 +245,10 @@ describe('preferences', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await preferenceManager.initPreferences(overrides);
|
||||
await preferenceManager.initPreferences({
|
||||
namespace: 'apply-updates',
|
||||
overrides,
|
||||
});
|
||||
|
||||
preferenceManager.updatePreferences({
|
||||
theme: { mode: 'light' },
|
||||
@@ -222,6 +256,265 @@ describe('preferences', () => {
|
||||
|
||||
expect(preferenceManager.getPreferences().theme.mode).toBe('light');
|
||||
});
|
||||
|
||||
it('initializes custom preferences extension with default values', async () => {
|
||||
const extension = {
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableWorkbench',
|
||||
label: '启用工作台',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: '租户模式',
|
||||
options: [
|
||||
{ label: '单租户', value: 'single' },
|
||||
{ label: '多租户', value: 'multi' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
title: '业务偏好',
|
||||
} as const;
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension,
|
||||
namespace: 'custom-defaults',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
enableWorkbench: true,
|
||||
tenantMode: 'single',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose mutable custom preference baselines or extension schema', async () => {
|
||||
const extension = {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
title: '业务偏好',
|
||||
} as const;
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension,
|
||||
namespace: 'custom-readonly',
|
||||
});
|
||||
|
||||
const initialCustomPreferences =
|
||||
preferenceManager.getInitialCustomPreferences<{
|
||||
pageSize: number;
|
||||
}>() as { pageSize: number };
|
||||
const preferencesExtension = preferenceManager.getPreferencesExtension<{
|
||||
pageSize: number;
|
||||
}>() as {
|
||||
fields: Array<{ componentProps?: { max?: number }; label: string }>;
|
||||
};
|
||||
const [firstField] = preferencesExtension.fields;
|
||||
|
||||
initialCustomPreferences.pageSize = 8;
|
||||
expect(firstField).toBeDefined();
|
||||
expect(firstField?.componentProps).toBeDefined();
|
||||
|
||||
if (!firstField || !firstField.componentProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstField.label = '已修改';
|
||||
firstField.componentProps.max = 20;
|
||||
|
||||
expect(preferenceManager.getInitialCustomPreferences()).toEqual({
|
||||
pageSize: 4,
|
||||
});
|
||||
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
|
||||
});
|
||||
|
||||
it('updates and resets custom preferences correctly', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
defaultValue: 20,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
{
|
||||
component: 'input',
|
||||
defaultValue: '日报',
|
||||
key: 'reportTitle',
|
||||
label: '报表标题',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-reset',
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 50,
|
||||
reportTitle: '月报',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 50,
|
||||
reportTitle: '月报',
|
||||
});
|
||||
|
||||
preferenceManager.resetPreferences();
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 20,
|
||||
reportTitle: '日报',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid custom preferences updates', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableWorkbench',
|
||||
label: '启用工作台',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: '租户模式',
|
||||
options: [
|
||||
{ label: '单租户', value: 'single' },
|
||||
{ label: '多租户', value: 'multi' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-invalid',
|
||||
});
|
||||
|
||||
const originalCustomPreferences = preferenceManager.getCustomPreferences();
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
enableWorkbench: 'true' as unknown as boolean,
|
||||
tenantMode: 'unknown',
|
||||
unknownField: 'value',
|
||||
} as any);
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual(
|
||||
originalCustomPreferences,
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces custom number field min max and step constraints', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-number-constraints',
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters cached custom number values that violate field constraints', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation((key) => {
|
||||
if (key.endsWith('cache-preferences-custom')) {
|
||||
return JSON.stringify({
|
||||
value: {
|
||||
pageSize: 5,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-number-cache',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkTheme', () => {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { preferencesManager } from './preferences';
|
||||
|
||||
export const {
|
||||
getPreferences,
|
||||
getCustomPreferences,
|
||||
getInitialCustomPreferences,
|
||||
getPreferencesExtension,
|
||||
updatePreferences,
|
||||
updateCustomPreferences,
|
||||
resetPreferences,
|
||||
clearCache,
|
||||
initPreferences,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
import type { InitialOptions, Preferences } from './types';
|
||||
import type {
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesRecord,
|
||||
InitialOptions,
|
||||
Preferences,
|
||||
PreferencesExtension,
|
||||
} from './types';
|
||||
|
||||
import { markRaw, reactive, readonly, watch } from 'vue';
|
||||
|
||||
@@ -17,6 +23,7 @@ import { defaultPreferences } from './config';
|
||||
import { updateCSSVariables } from './update-css-variables';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CUSTOM: 'preferences-custom',
|
||||
MAIN: 'preferences',
|
||||
LOCALE: 'preferences-locale',
|
||||
THEME: 'preferences-theme',
|
||||
@@ -24,7 +31,10 @@ const STORAGE_KEYS = {
|
||||
|
||||
class PreferenceManager {
|
||||
private cache: StorageManager;
|
||||
private debouncedSave: (preference: Preferences) => void;
|
||||
private customPreferencesExtension: null | PreferencesExtension<any> = null;
|
||||
private customState = reactive<CustomPreferencesRecord>({});
|
||||
private debouncedSave: () => void;
|
||||
private initialCustomPreferences: CustomPreferencesRecord = {};
|
||||
private initialPreferences: Preferences = defaultPreferences;
|
||||
private isInitialized = false;
|
||||
private state: Preferences;
|
||||
@@ -34,10 +44,7 @@ class PreferenceManager {
|
||||
this.state = reactive<Preferences>(
|
||||
this.loadFromCache() || { ...defaultPreferences },
|
||||
);
|
||||
this.debouncedSave = useDebounceFn(
|
||||
(preference) => this.saveToCache(preference),
|
||||
150,
|
||||
);
|
||||
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +54,26 @@ class PreferenceManager {
|
||||
Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取扩展偏好设置
|
||||
*/
|
||||
getCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return readonly(this.customState) as Readonly<TCustomPreferences>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取初始化扩展偏好设置
|
||||
*/
|
||||
getInitialCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return this.cloneValue(
|
||||
this.initialCustomPreferences,
|
||||
) as Readonly<TCustomPreferences>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取初始化偏好设置
|
||||
*/
|
||||
@@ -61,13 +88,32 @@ class PreferenceManager {
|
||||
return readonly(this.state);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取扩展偏好设置配置
|
||||
*/
|
||||
getPreferencesExtension = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return this.customPreferencesExtension
|
||||
? (this.cloneValue(this.customPreferencesExtension) as Readonly<
|
||||
PreferencesExtension<TCustomPreferences>
|
||||
>)
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化偏好设置
|
||||
* @param options - 初始化配置项
|
||||
* @param options.namespace - 命名空间,用于隔离不同应用的配置
|
||||
* @param options.overrides - 要覆盖的偏好设置
|
||||
*/
|
||||
initPreferences = async ({ namespace, overrides }: InitialOptions) => {
|
||||
initPreferences = async <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>({
|
||||
namespace,
|
||||
overrides,
|
||||
extension,
|
||||
}: InitialOptions<TCustomPreferences>) => {
|
||||
// 防止重复初始化
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
@@ -78,6 +124,10 @@ class PreferenceManager {
|
||||
|
||||
// 合并初始偏好设置
|
||||
this.initialPreferences = merge({}, overrides, defaultPreferences);
|
||||
this.customPreferencesExtension = extension ?? null;
|
||||
this.initialCustomPreferences = this.resolveCustomPreferencesDefaults(
|
||||
this.customPreferencesExtension,
|
||||
);
|
||||
|
||||
// 加载缓存的偏好设置并与初始配置合并
|
||||
const cachedPreferences = this.loadFromCache() || {};
|
||||
@@ -89,6 +139,14 @@ class PreferenceManager {
|
||||
|
||||
// 更新偏好设置
|
||||
this.updatePreferences(mergedPreference);
|
||||
this.replaceCustomPreferences(
|
||||
merge(
|
||||
{},
|
||||
this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}),
|
||||
this.initialCustomPreferences,
|
||||
),
|
||||
);
|
||||
this.saveToCache();
|
||||
|
||||
// 设置监听器
|
||||
this.setupWatcher();
|
||||
@@ -105,14 +163,42 @@ class PreferenceManager {
|
||||
resetPreferences = () => {
|
||||
// 将状态重置为初始偏好设置
|
||||
Object.assign(this.state, this.initialPreferences);
|
||||
this.replaceCustomPreferences(this.initialCustomPreferences);
|
||||
|
||||
// 保存偏好设置至缓存
|
||||
this.saveToCache(this.state);
|
||||
this.saveToCache();
|
||||
|
||||
// 直接触发 UI 更新
|
||||
this.handleUpdates(this.state);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新扩展偏好设置
|
||||
* @param updates - 要更新的扩展偏好设置
|
||||
*/
|
||||
updateCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>(
|
||||
updates: DeepPartial<TCustomPreferences>,
|
||||
) => {
|
||||
if (!this.customPreferencesExtension) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedUpdates = this.sanitizeCustomPreferences(
|
||||
updates as DeepPartial<CustomPreferencesRecord>,
|
||||
);
|
||||
|
||||
if (Object.keys(sanitizedUpdates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.replaceCustomPreferences(
|
||||
merge({}, sanitizedUpdates, markRaw(this.customState)),
|
||||
);
|
||||
this.debouncedSave();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新偏好设置
|
||||
* @param updates - 要更新的偏好设置
|
||||
@@ -126,9 +212,25 @@ class PreferenceManager {
|
||||
this.handleUpdates(updates);
|
||||
|
||||
// 保存到缓存
|
||||
this.debouncedSave(this.state);
|
||||
this.debouncedSave();
|
||||
};
|
||||
|
||||
private cloneValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.cloneValue(item)) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(
|
||||
([key, nestedValue]) => [key, this.cloneValue(nestedValue)],
|
||||
),
|
||||
) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理更新
|
||||
* @param updates - 更新的偏好设置
|
||||
@@ -158,6 +260,70 @@ class PreferenceManager {
|
||||
document.documentElement.dataset.platform = isMacOs() ? 'macOs' : 'window';
|
||||
}
|
||||
|
||||
private isAlmostInteger(value: number, epsilon = Number.EPSILON * 10) {
|
||||
return Math.abs(value - Math.round(value)) < epsilon;
|
||||
}
|
||||
|
||||
private isValidCustomPreferenceValue(
|
||||
field: CustomPreferencesField,
|
||||
value: unknown,
|
||||
) {
|
||||
switch (field.component) {
|
||||
case 'number': {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const max = this.resolveNumericConstraint(field.componentProps?.max);
|
||||
const min = this.resolveNumericConstraint(field.componentProps?.min);
|
||||
const step = this.resolveNumericConstraint(field.componentProps?.step);
|
||||
|
||||
if (min !== undefined && value < min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (step !== undefined) {
|
||||
if (step <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stepBase = min ?? 0;
|
||||
const stepCount = (value - stepBase) / step;
|
||||
|
||||
if (!this.isAlmostInteger(stepCount)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case 'select': {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
field.options.some((option) => option.value === value)
|
||||
);
|
||||
}
|
||||
case 'switch': {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
default: {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存加载扩展偏好设置
|
||||
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
|
||||
*/
|
||||
private loadCustomFromCache(): CustomPreferencesRecord | null {
|
||||
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存加载偏好设置
|
||||
* @returns 缓存的偏好设置,如果不存在则返回 null
|
||||
@@ -166,14 +332,72 @@ class PreferenceManager {
|
||||
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
|
||||
}
|
||||
|
||||
private replaceCustomPreferences(preferences: CustomPreferencesRecord) {
|
||||
Object.keys(this.customState).forEach((key) => {
|
||||
Reflect.deleteProperty(this.customState, key);
|
||||
});
|
||||
Object.assign(this.customState, preferences);
|
||||
}
|
||||
|
||||
private resolveCustomPreferencesDefaults(
|
||||
extension: null | PreferencesExtension<any>,
|
||||
) {
|
||||
if (!extension) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: CustomPreferencesRecord = {};
|
||||
|
||||
for (const field of extension.fields) {
|
||||
result[field.key] = field.defaultValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveNumericConstraint(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private sanitizeCustomPreferences(
|
||||
updates: DeepPartial<CustomPreferencesRecord>,
|
||||
) {
|
||||
if (!this.customPreferencesExtension) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: CustomPreferencesRecord = {};
|
||||
|
||||
for (const field of this.customPreferencesExtension.fields) {
|
||||
const value = updates[field.key];
|
||||
|
||||
if (
|
||||
value !== undefined &&
|
||||
this.isValidCustomPreferenceValue(field, value)
|
||||
) {
|
||||
result[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存偏好设置到缓存
|
||||
* @param preference - 要保存的偏好设置
|
||||
*/
|
||||
private saveToCache(preference: Preferences) {
|
||||
this.cache.setItem(STORAGE_KEYS.MAIN, preference);
|
||||
this.cache.setItem(STORAGE_KEYS.LOCALE, preference.app.locale);
|
||||
this.cache.setItem(STORAGE_KEYS.THEME, preference.theme.mode);
|
||||
private saveToCache() {
|
||||
this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
|
||||
this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
|
||||
this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
|
||||
|
||||
if (this.customPreferencesExtension) {
|
||||
this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState });
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.removeItem(STORAGE_KEYS.CUSTOM);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,84 @@ import type {
|
||||
} from '@vben-core/typings';
|
||||
|
||||
type SupportedLanguagesType = 'en-US' | 'zh-CN';
|
||||
type CustomPreferencesValue = boolean | number | string;
|
||||
|
||||
interface CustomPreferencesOption<TValue extends string = string> {
|
||||
label: string;
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
interface BaseCustomPreferencesField<
|
||||
TKey extends string = string,
|
||||
TValue extends CustomPreferencesValue = CustomPreferencesValue,
|
||||
> {
|
||||
componentProps?: Record<string, any>;
|
||||
defaultValue: TValue;
|
||||
disabled?: boolean;
|
||||
key: TKey;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
interface CustomPreferencesInputField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, string> {
|
||||
component: 'input';
|
||||
}
|
||||
|
||||
interface CustomPreferencesNumberField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, number> {
|
||||
component: 'number';
|
||||
}
|
||||
|
||||
interface CustomPreferencesSelectField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, string> {
|
||||
component: 'select';
|
||||
options: CustomPreferencesOption[];
|
||||
}
|
||||
|
||||
interface CustomPreferencesSwitchField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, boolean> {
|
||||
component: 'switch';
|
||||
}
|
||||
|
||||
type CustomPreferencesRecord = Record<string, CustomPreferencesValue>;
|
||||
|
||||
type AnyCustomPreferencesField =
|
||||
| CustomPreferencesInputField
|
||||
| CustomPreferencesNumberField
|
||||
| CustomPreferencesSelectField
|
||||
| CustomPreferencesSwitchField;
|
||||
|
||||
type CustomPreferencesField<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> =
|
||||
string extends Extract<keyof TCustomPreferences, string>
|
||||
? AnyCustomPreferencesField
|
||||
: {
|
||||
[K in Extract<
|
||||
keyof TCustomPreferences,
|
||||
string
|
||||
>]: TCustomPreferences[K] extends boolean
|
||||
? CustomPreferencesSwitchField<K>
|
||||
: TCustomPreferences[K] extends number
|
||||
? CustomPreferencesNumberField<K>
|
||||
: TCustomPreferences[K] extends string
|
||||
? CustomPreferencesInputField<K> | CustomPreferencesSelectField<K>
|
||||
: never;
|
||||
}[Extract<keyof TCustomPreferences, string>];
|
||||
|
||||
interface PreferencesExtension<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> {
|
||||
fields: Array<CustomPreferencesField<TCustomPreferences>>;
|
||||
tabLabel: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface AppPreferences {
|
||||
/** 权限模式 */
|
||||
@@ -324,19 +402,33 @@ interface Preferences {
|
||||
|
||||
type PreferencesKeys = keyof Preferences;
|
||||
|
||||
interface InitialOptions {
|
||||
interface InitialOptions<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> {
|
||||
extension?: PreferencesExtension<TCustomPreferences>;
|
||||
namespace: string;
|
||||
overrides?: DeepPartial<Preferences>;
|
||||
}
|
||||
export type {
|
||||
AnyCustomPreferencesField,
|
||||
AppPreferences,
|
||||
BaseCustomPreferencesField,
|
||||
BreadcrumbPreferences,
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesInputField,
|
||||
CustomPreferencesNumberField,
|
||||
CustomPreferencesOption,
|
||||
CustomPreferencesRecord,
|
||||
CustomPreferencesSelectField,
|
||||
CustomPreferencesSwitchField,
|
||||
CustomPreferencesValue,
|
||||
FooterPreferences,
|
||||
HeaderPreferences,
|
||||
InitialOptions,
|
||||
LogoPreferences,
|
||||
NavigationPreferences,
|
||||
Preferences,
|
||||
PreferencesExtension,
|
||||
PreferencesKeys,
|
||||
ShortcutKeyPreferences,
|
||||
SidebarPreferences,
|
||||
|
||||
@@ -7,7 +7,13 @@ import { isDarkTheme } from './update-css-variables';
|
||||
|
||||
function usePreferences() {
|
||||
const preferences = preferencesManager.getPreferences();
|
||||
const customPreferences = preferencesManager.getCustomPreferences();
|
||||
const initialPreferences = preferencesManager.getInitialPreferences();
|
||||
const initialCustomPreferences =
|
||||
preferencesManager.getInitialCustomPreferences();
|
||||
const preferencesExtension = computed(() =>
|
||||
preferencesManager.getPreferencesExtension(),
|
||||
);
|
||||
/**
|
||||
* @zh_CN 计算偏好设置的变化
|
||||
*/
|
||||
@@ -15,6 +21,10 @@ function usePreferences() {
|
||||
return diff(initialPreferences, preferences);
|
||||
});
|
||||
|
||||
const diffCustomPreference = computed(() => {
|
||||
return diff(initialCustomPreferences, customPreferences);
|
||||
});
|
||||
|
||||
const appPreferences = computed(() => preferences.app);
|
||||
|
||||
const shortcutKeysPreferences = computed(() => preferences.shortcutKeys);
|
||||
@@ -228,7 +238,9 @@ function usePreferences() {
|
||||
authPanelLeft,
|
||||
authPanelRight,
|
||||
contentIsMaximize,
|
||||
customPreferences,
|
||||
diffPreference,
|
||||
diffCustomPreference,
|
||||
globalLockScreenShortcutKey,
|
||||
globalLogoutShortcutKey,
|
||||
globalSearchShortcutKey,
|
||||
@@ -245,6 +257,7 @@ function usePreferences() {
|
||||
keepAlive,
|
||||
layout,
|
||||
locale,
|
||||
preferencesExtension,
|
||||
preferencesButtonPosition,
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesRecord,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import InputItem from '../input-item.vue';
|
||||
import NumberFieldItem from '../number-field-item.vue';
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceCustomFields',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
fields: Array<CustomPreferencesField>;
|
||||
values: CustomPreferencesRecord;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [updates: CustomPreferencesRecord];
|
||||
}>();
|
||||
|
||||
function handleUpdate(key: string, value: boolean | number | string) {
|
||||
emit('update', { [key]: value });
|
||||
}
|
||||
|
||||
function handleBooleanUpdate(key: string, value: boolean | undefined) {
|
||||
handleUpdate(key, value ?? false);
|
||||
}
|
||||
|
||||
function resolveNumberValue(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function handleNumberUpdate(key: string, value: number | undefined) {
|
||||
const resolvedValue = resolveNumberValue(value);
|
||||
|
||||
if (resolvedValue !== undefined) {
|
||||
handleUpdate(key, resolvedValue);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStringUpdate(key: string, value: string | undefined) {
|
||||
handleUpdate(key, value ?? '');
|
||||
}
|
||||
|
||||
const resolvedFields = computed(() => {
|
||||
return props.fields.map((field) => {
|
||||
return {
|
||||
...field,
|
||||
label: $t(field.label),
|
||||
options:
|
||||
field.component === 'select'
|
||||
? field.options.map((option) => ({
|
||||
...option,
|
||||
label: $t(option.label),
|
||||
}))
|
||||
: undefined,
|
||||
placeholder: field.placeholder ? $t(field.placeholder) : '',
|
||||
tip: field.tip ? $t(field.tip) : '',
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="field in resolvedFields" :key="field.key">
|
||||
<SwitchItem
|
||||
v-if="field.component === 'switch'"
|
||||
:disabled="field.disabled"
|
||||
:model-value="Boolean(values[field.key])"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleBooleanUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</SwitchItem>
|
||||
<NumberFieldItem
|
||||
v-else-if="field.component === 'number'"
|
||||
:disabled="field.disabled"
|
||||
:model-value="resolveNumberValue(values[field.key])"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleNumberUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</NumberFieldItem>
|
||||
<SelectItem
|
||||
v-else-if="field.component === 'select'"
|
||||
:disabled="field.disabled"
|
||||
:items="field.options"
|
||||
:model-value="String(values[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleStringUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
<InputItem
|
||||
v-else
|
||||
:disabled="field.disabled"
|
||||
:model-value="String(values[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleStringUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</InputItem>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Block } from './block.vue';
|
||||
export { default as Custom } from './custom/custom.vue';
|
||||
export { default as Animation } from './general/animation.vue';
|
||||
export { default as General } from './general/general.vue';
|
||||
export { default as Breadcrumb } from './layout/breadcrumb.vue';
|
||||
|
||||
@@ -16,10 +16,12 @@ withDefaults(
|
||||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
tip: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
@@ -32,7 +34,7 @@ const slots = useSlots();
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'hover:bg-accent': !(slots.tip || tip),
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
@@ -40,11 +42,17 @@ const slots = useSlots();
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<div class="relative">
|
||||
|
||||
@@ -23,10 +23,12 @@ withDefaults(
|
||||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
tip: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
@@ -39,7 +41,7 @@ const slots = useSlots();
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'hover:bg-accent': !(slots.tip || tip),
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
@@ -47,11 +49,17 @@ const slots = useSlots();
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<Select v-model="selectValue">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportedLanguagesType } from '@vben/locales';
|
||||
import type { CustomPreferencesRecord } from '@vben/preferences';
|
||||
import type {
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
clearCache,
|
||||
preferences,
|
||||
resetPreferences,
|
||||
updateCustomPreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
ColorMode,
|
||||
Content,
|
||||
Copyright,
|
||||
Custom,
|
||||
FontSize,
|
||||
Footer,
|
||||
General,
|
||||
@@ -177,12 +180,15 @@ const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
|
||||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
|
||||
const {
|
||||
customPreferences,
|
||||
diffCustomPreference,
|
||||
diffPreference,
|
||||
isDark,
|
||||
isFullContent,
|
||||
isHeaderNav,
|
||||
isHeaderSidebarNav,
|
||||
isMixedNav,
|
||||
preferencesExtension,
|
||||
isSideMixedNav,
|
||||
isSideMode,
|
||||
isSideNav,
|
||||
@@ -193,8 +199,42 @@ const [Drawer] = useVbenDrawer();
|
||||
|
||||
const activeTab = ref('appearance');
|
||||
|
||||
const customPreferencesTab = computed(() => {
|
||||
return preferencesExtension.value;
|
||||
});
|
||||
|
||||
const customTabLabel = computed(() => {
|
||||
return customPreferencesTab.value?.tabLabel
|
||||
? $t(customPreferencesTab.value.tabLabel)
|
||||
: '';
|
||||
});
|
||||
|
||||
const customTabTitle = computed(() => {
|
||||
const title =
|
||||
customPreferencesTab.value?.title || customPreferencesTab.value?.tabLabel;
|
||||
return title ? $t(title) : '';
|
||||
});
|
||||
|
||||
const mergedDiffPreference = computed(() => {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (diffPreference.value) {
|
||||
Object.assign(result, diffPreference.value);
|
||||
}
|
||||
|
||||
if (diffCustomPreference.value) {
|
||||
result.custom = diffCustomPreference.value;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
});
|
||||
|
||||
const showCustomTab = computed(() => {
|
||||
return (customPreferencesTab.value?.fields.length ?? 0) > 0;
|
||||
});
|
||||
|
||||
const tabs = computed((): SegmentedItem[] => {
|
||||
return [
|
||||
const items: SegmentedItem[] = [
|
||||
{
|
||||
label: $t('preferences.appearance'),
|
||||
value: 'appearance',
|
||||
@@ -212,6 +252,15 @@ const tabs = computed((): SegmentedItem[] => {
|
||||
value: 'general',
|
||||
},
|
||||
];
|
||||
|
||||
if (showCustomTab.value) {
|
||||
items.push({
|
||||
label: customTabLabel.value,
|
||||
value: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const showBreadcrumbConfig = computed(() => {
|
||||
@@ -224,7 +273,7 @@ const showBreadcrumbConfig = computed(() => {
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
await copy(JSON.stringify(diffPreference.value, null, 2));
|
||||
await copy(JSON.stringify(mergedDiffPreference.value, null, 2));
|
||||
|
||||
message.copyPreferencesSuccess?.(
|
||||
$t('preferences.copyPreferencesSuccessTitle'),
|
||||
@@ -239,12 +288,16 @@ async function handleClearCache() {
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!diffPreference.value) {
|
||||
if (!mergedDiffPreference.value) {
|
||||
return;
|
||||
}
|
||||
resetPreferences();
|
||||
await loadLocaleMessages(preferences.app.locale);
|
||||
}
|
||||
|
||||
function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
|
||||
updateCustomPreferences(updates);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -257,13 +310,13 @@ async function handleReset() {
|
||||
<template #extra>
|
||||
<div class="flex items-center">
|
||||
<VbenIconButton
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
:tooltip="$t('preferences.resetTip')"
|
||||
class="relative"
|
||||
@click="handleReset"
|
||||
>
|
||||
<span
|
||||
v-if="diffPreference"
|
||||
v-if="mergedDiffPreference"
|
||||
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
|
||||
></span>
|
||||
<RotateCw class="size-4" />
|
||||
@@ -466,13 +519,22 @@ async function handleReset() {
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
<template #custom>
|
||||
<Block :title="customTabTitle">
|
||||
<Custom
|
||||
:fields="customPreferencesTab?.fields || []"
|
||||
:values="customPreferences"
|
||||
@update="handleCustomPreferencesUpdate"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
</VbenSegmented>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VbenButton
|
||||
v-if="appEnableCopyPreferences"
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
class="mx-4 w-full"
|
||||
size="sm"
|
||||
variant="default"
|
||||
@@ -482,7 +544,7 @@ async function handleReset() {
|
||||
{{ $t('preferences.copyPreferences') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
class="mr-4 w-full"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -197,5 +197,33 @@
|
||||
"sidebarToggle": "Enable Sidebar Toggle",
|
||||
"lockScreen": "Enable Lock Screen",
|
||||
"refresh": "Enable Refresh"
|
||||
},
|
||||
"antd": {
|
||||
"tabLabel": "Antd Extension",
|
||||
"title": "Business Preferences",
|
||||
"fields": {
|
||||
"enableFormFullscreen": {
|
||||
"label": "Enable Form Fullscreen Mode",
|
||||
"tip": "Subprojects can read this extension configuration in their own business scenarios."
|
||||
},
|
||||
"tenantMode": {
|
||||
"label": "Tenant Mode",
|
||||
"options": {
|
||||
"single": {
|
||||
"label": "Single Tenant"
|
||||
},
|
||||
"multi": {
|
||||
"label": "Multi Tenant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultTableSize": {
|
||||
"label": "Default Page Size"
|
||||
},
|
||||
"reportTitle": {
|
||||
"label": "Default Report Title",
|
||||
"placeholder": "Please enter the default report title"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,5 +197,33 @@
|
||||
"sidebarToggle": "启用侧边栏切换",
|
||||
"lockScreen": "启用锁屏",
|
||||
"refresh": "启用刷新"
|
||||
},
|
||||
"antd": {
|
||||
"tabLabel": "Antd 拓展",
|
||||
"title": "业务偏好",
|
||||
"fields": {
|
||||
"enableFormFullscreen": {
|
||||
"label": "启用表单全屏模式",
|
||||
"tip": "子项目可在自己的业务场景中读取这个拓展配置。"
|
||||
},
|
||||
"tenantMode": {
|
||||
"label": "租户模式",
|
||||
"options": {
|
||||
"single": {
|
||||
"label": "单租户"
|
||||
},
|
||||
"multi": {
|
||||
"label": "多租户"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultTableSize": {
|
||||
"label": "默认分页条数"
|
||||
},
|
||||
"reportTitle": {
|
||||
"label": "报表默认标题",
|
||||
"placeholder": "请输入报表默认标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Preferences } from '@vben-core/preferences';
|
||||
import type {
|
||||
CustomPreferencesRecord,
|
||||
Preferences,
|
||||
PreferencesExtension,
|
||||
} from '@vben-core/preferences';
|
||||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
/**
|
||||
@@ -12,6 +16,12 @@ function defineOverridesPreferences(preferences: DeepPartial<Preferences>) {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export { defineOverridesPreferences };
|
||||
function definePreferencesExtension<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>(extension: PreferencesExtension<TCustomPreferences>) {
|
||||
return extension;
|
||||
}
|
||||
|
||||
export { defineOverridesPreferences, definePreferencesExtension };
|
||||
|
||||
export * from '@vben-core/preferences';
|
||||
|
||||
Reference in New Issue
Block a user