Merge branch 'main' into main

This commit is contained in:
xueyitt
2026-04-13 16:10:29 +08:00
committed by GitHub
24 changed files with 1720 additions and 39 deletions

View File

@@ -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', () => {

View File

@@ -4,7 +4,11 @@ import { preferencesManager } from './preferences';
export const {
getPreferences,
getCustomPreferences,
getInitialCustomPreferences,
getPreferencesExtension,
updatePreferences,
updateCustomPreferences,
resetPreferences,
clearCache,
initPreferences,

View File

@@ -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);
}
/**

View File

@@ -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,

View File

@@ -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,