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,

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
}
}

View File

@@ -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": "请输入报表默认标题"
}
}
}
}

View File

@@ -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';