mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-10 04:22:41 +08:00
feat: enable project-scoped preferences extension tabs (#7803)
* feat: enable project-scoped preferences extension tabs Add a typed extension schema so subprojects can define extra settings, render them in the shared preferences drawer only when configured, and consume them in playground as a real feature demo. Extension labels now follow locale keys instead of hardcoded app-specific strings. Constraint: Reuse the shared preferences drawer and field blocks Rejected: Add app-specific fields to core preferences | too tightly coupled Rejected: Inline localized label objects | breaks existing locale-key flow Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep extension labels as locale keys rendered via $t in UI Tested: Vitest preferences tests Tested: Turbo typecheck for preferences, layouts, web-antd, and playground Tested: ESLint for touched preferences and playground files Not-tested: Manual browser interaction in playground preferences drawer * fix: satisfy lint formatting for preferences extension demo Adjust the playground preferences extension demo template so formatter and Vue template lint rules agree on the rendered markup. This keeps CI green without changing runtime behavior. Constraint: Must preserve the existing demo behavior while fixing CI only Rejected: Disable the Vue newline rule | would weaken shared lint guarantees Confidence: high Scope-risk: narrow Reversibility: clean Directive: Prefer computed/template structures that avoid formatter-vs-lint conflicts Tested: pnpm run lint Not-tested: Manual browser interaction in playground preferences extension demo * fix: harden custom preferences validation and i18n labels Tighten custom preferences handling so numeric extension fields respect min, max, and step constraints. Number inputs now ignore NaN values, and web-antd extension metadata uses locale keys instead of raw strings. Also align tip-based hover guards in shared preference inputs/selects. Constraint: Keep fixes scoped to verified findings only Rejected: Broader refactor of preferences field components | not needed for these fixes Confidence: high Scope-risk: narrow Reversibility: clean Directive: Reuse the same validation path for updates and cache hydration Tested: Vitest preferences tests Tested: ESLint for touched preferences and widget files Tested: Typecheck for web-antd, layouts, and core preferences Not-tested: Manual browser interaction for all preference field variants * fix: remove localized default from playground extension config Drop the hardcoded Chinese default value from the playground extension report title field and fall back to an empty string instead. This keeps extension config locale-neutral while preserving localized labels and placeholders through translation keys. Constraint: Keep the fix limited to the verified localized default issue Rejected: Compute the default from runtime locale in config | unnecessary for this finding Confidence: high Scope-risk: narrow Reversibility: clean Directive: Avoid embedding localized literals in extension default values Tested: ESLint for playground/src/preferences.ts Tested: Oxfmt check for playground/src/preferences.ts Not-tested: Manual playground preferences interaction * docs: document project-scoped preferences extension workflow Add Chinese and English guide sections explaining how to define, initialize, read, and update project-scoped preferences extensions. Also document numeric field validation and point readers to the playground demo for a complete example. Constraint: Keep this docs-only and aligned with the shipped API Rejected: Update only Chinese docs | would leave English docs inconsistent Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep zh/en examples and playground demo paths synchronized Tested: git diff --check; pnpm build:docs Not-tested: Manual browser review of the rendered docs site * fix: harden custom preferences defaults and baselines Use a locale-neutral default for the web-antd report title. Also stop preference getters from exposing mutable baseline or extension schema objects, and add a regression test for external mutation attempts. Constraint: Keep behavior compatible with the shipped preferences API Rejected: Return raw refs with readonly typing only | callers could still mutate internals Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep defensive copies for baseline and schema getters unless storage semantics change Tested: eslint, oxlint, targeted vitest, filtered typecheck, git diff --check Not-tested: Full monorepo typecheck and test suite * test: relax custom preference cache key matching Avoid coupling the custom-number cache test to one exact localStorage key string. Match the intended cache lookup more loosely so the test still verifies filtering behavior without depending on the full namespaced cache key. Constraint: Focus the test on cache filtering behavior Rejected: Assert one exact key | brittle with namespace changes Confidence: high Scope-risk: narrow Reversibility: clean Directive: Prefer behavior tests over literal storage keys Tested: targeted vitest, eslint, git diff --check Not-tested: Full monorepo test suite --------- Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
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