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:
Caisin
2026-04-13 15:11:57 +08:00
committed by GitHub
parent 5b84ac5b13
commit ccabbf0e97
24 changed files with 1720 additions and 39 deletions

2
.gitignore vendored
View File

@@ -22,7 +22,7 @@ yarn.lock
package-lock.json
.VSCodeCounter
**/backend-mock/data
.omx
# local env files
.env.local
.env.*.local

View File

@@ -1,7 +1,7 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -15,6 +15,7 @@ async function initApplication() {
// app偏好设置初始化
await initPreferences({
extension: preferencesExtension,
namespace,
overrides: overridesPreferences,
});

View File

@@ -1,4 +1,14 @@
import { defineOverridesPreferences } from '@vben/preferences';
import {
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface WebAntdPreferencesExtension {
defaultTableSize: number;
enableFormFullscreen: boolean;
reportTitle: string;
tenantMode: 'multi' | 'single';
}
/**
* @description 项目配置文件
@@ -11,3 +21,52 @@ export const overridesPreferences = defineOverridesPreferences({
name: import.meta.env.VITE_APP_TITLE,
},
});
export const preferencesExtension =
definePreferencesExtension<WebAntdPreferencesExtension>({
tabLabel: 'preferences.antd.tabLabel',
title: 'preferences.antd.title',
fields: [
{
component: 'switch',
defaultValue: true,
key: 'enableFormFullscreen',
label: 'preferences.antd.fields.enableFormFullscreen.label',
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
},
{
component: 'select',
defaultValue: 'single',
key: 'tenantMode',
label: 'preferences.antd.fields.tenantMode.label',
options: [
{
label: 'preferences.antd.fields.tenantMode.options.single.label',
value: 'single',
},
{
label: 'preferences.antd.fields.tenantMode.options.multi.label',
value: 'multi',
},
],
},
{
component: 'number',
componentProps: {
max: 200,
min: 10,
step: 10,
},
defaultValue: 20,
key: 'defaultTableSize',
label: 'preferences.antd.fields.defaultTableSize.label',
},
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'preferences.antd.fields.reportTitle.label',
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
},
],
});

View File

@@ -193,6 +193,156 @@ export const overridesPreferences = defineOverridesPreferences({
});
```
### Extend project-level preferences
In addition to overriding the built-in framework preferences, you can also add a set of business preferences for each application. After configuration, the preferences drawer will display an extra tab for the current app, and the data will be stored together with the app `namespace`. This is useful for project-specific fields such as tenant mode, business titles, or default page size.
#### 1. Define the extension in `src/preferences.ts`
```ts
import {
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface ProjectPreferencesExtension {
defaultTableSize: number;
enableFormFullscreen: boolean;
reportTitle: string;
tenantMode: 'multi' | 'single';
}
export const overridesPreferences = defineOverridesPreferences({
app: {
name: import.meta.env.VITE_APP_TITLE,
},
});
export const preferencesExtension =
definePreferencesExtension<ProjectPreferencesExtension>({
tabLabel: 'preferences.antd.tabLabel',
title: 'preferences.antd.title',
fields: [
{
component: 'switch',
defaultValue: true,
key: 'enableFormFullscreen',
label: 'preferences.antd.fields.enableFormFullscreen.label',
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
},
{
component: 'select',
defaultValue: 'single',
key: 'tenantMode',
label: 'preferences.antd.fields.tenantMode.label',
options: [
{
label: 'preferences.antd.fields.tenantMode.options.single.label',
value: 'single',
},
{
label: 'preferences.antd.fields.tenantMode.options.multi.label',
value: 'multi',
},
],
},
{
component: 'number',
componentProps: {
max: 200,
min: 10,
step: 10,
},
defaultValue: 20,
key: 'defaultTableSize',
label: 'preferences.antd.fields.defaultTableSize.label',
},
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'preferences.antd.fields.reportTitle.label',
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
},
],
});
```
- `tabLabel` is the tab label, and `title` is the panel title. If `title` is omitted, `tabLabel` is used as the fallback.
- `fields` currently supports four component types: `input`, `number`, `select`, and `switch`.
- `label`, `placeholder`, `tip`, and `options[].label` can be i18n keys directly. The preferences drawer resolves them with `$t` automatically.
#### 2. Pass `extension` when initializing preferences
```ts
import { initPreferences } from '@vben/preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
await initPreferences({
namespace,
overrides: overridesPreferences,
extension: preferencesExtension,
});
```
The same `namespace` isolates both framework preferences and extension preferences. So even if multiple subprojects run in the same browser, their business preferences remain independent.
#### 3. Read or update extension preferences in business pages
```ts
import {
getCustomPreferences,
updateCustomPreferences,
usePreferences,
} from '@vben/preferences';
interface ProjectPreferencesExtension {
defaultTableSize: number;
enableFormFullscreen: boolean;
reportTitle: string;
tenantMode: 'multi' | 'single';
}
const projectPreferences = getCustomPreferences<ProjectPreferencesExtension>();
const { customPreferences, preferencesExtension } = usePreferences();
updateCustomPreferences<ProjectPreferencesExtension>({
defaultTableSize: 50,
tenantMode: 'multi',
});
```
- `getCustomPreferences` returns the reactive extension-preferences object for the current app.
- `customPreferences` and `preferencesExtension` from `usePreferences` are convenient when composing reusable logic.
- Calling `resetPreferences()` also resets extension preferences back to their default values.
#### 4. Number fields validate `min` / `max` / `step` automatically
If you provide `componentProps.min`, `componentProps.max`, and `componentProps.step` for a `number` field, runtime persistence follows the same constraints. For example:
```ts
{
component: 'number',
componentProps: {
min: 10,
max: 200,
step: 10,
},
defaultValue: 20,
key: 'defaultTableSize',
label: 'preferences.antd.fields.defaultTableSize.label',
}
```
Only values within `10 ~ 200` and increasing by `10` will be saved. Values like `15`, `205`, or invalid legacy cache values are ignored automatically.
For complete examples, see:
- `playground/src/preferences.ts`
- `playground/src/views/demos/features/preferences-extension/index.vue`
### Framework default configuration
::: details View the default configuration of the framework

View File

@@ -192,6 +192,156 @@ export const overridesPreferences = defineOverridesPreferences({
});
```
### 扩展项目级偏好
除了覆盖框架内置偏好外,还可以为每个应用追加一组“业务偏好”。配置后,偏好设置抽屉会新增一个独立标签页,并且这组数据会跟随当前应用的 `namespace` 一起存储,适合放租户模式、业务标题、默认分页条数等项目字段。
#### 1. 在应用的 `src/preferences.ts` 中定义扩展
```ts
import {
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface ProjectPreferencesExtension {
defaultTableSize: number;
enableFormFullscreen: boolean;
reportTitle: string;
tenantMode: 'multi' | 'single';
}
export const overridesPreferences = defineOverridesPreferences({
app: {
name: import.meta.env.VITE_APP_TITLE,
},
});
export const preferencesExtension =
definePreferencesExtension<ProjectPreferencesExtension>({
tabLabel: 'preferences.antd.tabLabel',
title: 'preferences.antd.title',
fields: [
{
component: 'switch',
defaultValue: true,
key: 'enableFormFullscreen',
label: 'preferences.antd.fields.enableFormFullscreen.label',
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
},
{
component: 'select',
defaultValue: 'single',
key: 'tenantMode',
label: 'preferences.antd.fields.tenantMode.label',
options: [
{
label: 'preferences.antd.fields.tenantMode.options.single.label',
value: 'single',
},
{
label: 'preferences.antd.fields.tenantMode.options.multi.label',
value: 'multi',
},
],
},
{
component: 'number',
componentProps: {
max: 200,
min: 10,
step: 10,
},
defaultValue: 20,
key: 'defaultTableSize',
label: 'preferences.antd.fields.defaultTableSize.label',
},
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'preferences.antd.fields.reportTitle.label',
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
},
],
});
```
- `tabLabel` 是标签名称,`title` 是该标签页标题;如果不传 `title`,会回退使用 `tabLabel`。
- `fields` 目前支持 `input`、`number`、`select`、`switch` 四种组件。
- `label`、`placeholder`、`tip`、`options[].label` 可以直接写 i18n key偏好设置面板会自动调用 `$t` 渲染。
#### 2. 初始化偏好设置时传入 `extension`
```ts
import { initPreferences } from '@vben/preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
await initPreferences({
namespace,
overrides: overridesPreferences,
extension: preferencesExtension,
});
```
这里的 `namespace` 会同时隔离框架偏好和扩展偏好。因此同一浏览器中即使运行多个子项目,它们的业务偏好也不会互相污染。
#### 3. 在业务页面中读取或更新扩展偏好
```ts
import {
getCustomPreferences,
updateCustomPreferences,
usePreferences,
} from '@vben/preferences';
interface ProjectPreferencesExtension {
defaultTableSize: number;
enableFormFullscreen: boolean;
reportTitle: string;
tenantMode: 'multi' | 'single';
}
const projectPreferences = getCustomPreferences<ProjectPreferencesExtension>();
const { customPreferences, preferencesExtension } = usePreferences();
updateCustomPreferences<ProjectPreferencesExtension>({
defaultTableSize: 50,
tenantMode: 'multi',
});
```
- `getCustomPreferences` 返回当前应用扩展偏好的响应式对象,适合直接在页面中读取。
- `usePreferences` 中的 `customPreferences` 和 `preferencesExtension` 适合在组合式逻辑里统一使用。
- 调用 `resetPreferences()` 时,扩展偏好也会一起重置到默认值。
#### 4. 数字字段会自动校验 `min` / `max` / `step`
为 `number` 字段设置 `componentProps.min`、`componentProps.max`、`componentProps.step` 后,运行时保存也会遵守同样的规则。例如下面的配置:
```ts
{
component: 'number',
componentProps: {
min: 10,
max: 200,
step: 10,
},
defaultValue: 20,
key: 'defaultTableSize',
label: 'preferences.antd.fields.defaultTableSize.label',
}
```
此时只有 `10 ~ 200` 且按 `10` 递增的值会被保存;像 `15`、`205`,或者旧缓存里不满足约束的值,都会被自动忽略。
完整示例可以参考:
- `playground/src/preferences.ts`
- `playground/src/views/demos/features/preferences-extension/index.vue`
### 框架默认配置
::: details 查看框架默认配置

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

View File

@@ -44,6 +44,7 @@
"loginExpired": "Login Expired",
"icons": "Icons",
"watermark": "Watermark",
"preferencesExtension": "Preferences Extension",
"tabs": "Tabs",
"tabDetail": "Tab Detail Page",
"fullScreen": "FullScreen",
@@ -52,6 +53,63 @@
"openInNewWindow": "Open in New Window",
"fileDownload": "File Download"
},
"preferencesExtensionDemo": {
"description": "This page directly uses extension preferences defined in the playground app. You can modify them in “Preferences → Playground Extension” at the top right, or click the preset buttons below to see the page update in real time.",
"currentConfig": "Current Extension Preferences",
"currentTitle": "Current Title: {title}",
"currentDescription": "Displaying {count} tasks by default; quick actions are {quickActionText}; the current highlight tone is “{tone}”.",
"showQuickActions": "shown",
"hideQuickActions": "hidden",
"boardDescription": "This is a real usage example: the page title, action area, visible row count, and highlight styles are all driven by extension preferences.",
"quickActionsEnabled": "Quick actions are currently disabled, so the page only keeps the read-only information view.",
"quickActions": {
"create": "Create Task",
"export": "Batch Export",
"refresh": "Refresh Data"
},
"presetTitle": "Quick Presets (demonstrates updateCustomPreferences)",
"presetButtons": {
"default": "Reset Default",
"compact": "Switch to Compact",
"review": "Switch to Review"
},
"presetTitles": {
"compact": "Compact Dashboard",
"default": "Weekly Operations Overview",
"review": "Review Dashboard"
},
"tones": {
"default": "Default",
"success": "Success",
"warning": "Warning"
},
"owner": "Owner"
},
"preferencesExtensionConfig": {
"tabLabel": "Playground Extension",
"title": "Playground Business Preferences",
"fields": {
"reportTitle": {
"label": "Report Title",
"placeholder": "Please enter the report title"
},
"defaultVisibleRows": {
"label": "Default Visible Rows",
"tip": "Controls how many task items are rendered by default on the demo page."
},
"enableQuickActions": {
"label": "Show Quick Actions"
},
"highlightTone": {
"label": "Highlight Tone",
"options": {
"default": "Default",
"success": "Success",
"warning": "Warning"
}
}
}
},
"breadcrumb": {
"navigation": "Breadcrumb Navigation",
"lateral": "Lateral Mode",

View File

@@ -44,6 +44,7 @@
"loginExpired": "登录过期",
"icons": "图标",
"watermark": "水印",
"preferencesExtension": "偏好扩展示例",
"tabs": "标签页",
"tabDetail": "标签详情页",
"fullScreen": "全屏",
@@ -53,6 +54,63 @@
"fileDownload": "文件下载",
"requestParamsSerializer": "参数序列化"
},
"preferencesExtensionDemo": {
"description": "这个页面直接读取 playground 子项目定义的拓展偏好。你可以在右上角的“偏好设置 → Playground 拓展”中修改字段,也可以点击下方预设按钮,页面会实时联动。",
"currentConfig": "当前拓展配置",
"currentTitle": "当前标题:{title}",
"currentDescription": "默认展示 {count} 条任务;{quickActionText} 快捷操作;当前高亮风格为“{tone}”。",
"showQuickActions": "显示",
"hideQuickActions": "隐藏",
"boardDescription": "这是一个“真实使用”的示例:页面标题、操作区、列表条数和高亮样式都由拓展偏好驱动。",
"quickActionsEnabled": "当前已关闭快捷操作,页面只保留只读信息展示。",
"quickActions": {
"create": "新建任务",
"export": "批量导出",
"refresh": "刷新数据"
},
"presetTitle": "快捷预设(演示 updateCustomPreferences 用法)",
"presetButtons": {
"default": "恢复默认",
"compact": "切换紧凑模式",
"review": "切换评审模式"
},
"presetTitles": {
"compact": "紧凑模式看板",
"default": "本周运营概览",
"review": "评审态工作看板"
},
"tones": {
"default": "默认",
"success": "成功",
"warning": "警告"
},
"owner": "负责人"
},
"preferencesExtensionConfig": {
"tabLabel": "Playground 拓展",
"title": "Playground 业务偏好",
"fields": {
"reportTitle": {
"label": "看板标题",
"placeholder": "请输入看板标题"
},
"defaultVisibleRows": {
"label": "默认展示条数",
"tip": "用于控制示例页中任务列表默认渲染多少条数据。"
},
"enableQuickActions": {
"label": "显示快捷操作"
},
"highlightTone": {
"label": "高亮风格",
"options": {
"default": "默认",
"success": "成功",
"warning": "警告"
}
}
}
},
"breadcrumb": {
"navigation": "面包屑导航",
"lateral": "平级模式",

View File

@@ -1,7 +1,7 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import { overridesPreferences, preferencesExtension } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -15,6 +15,7 @@ async function initApplication() {
// app偏好设置初始化
await initPreferences({
extension: preferencesExtension,
namespace,
overrides: overridesPreferences,
});

View File

@@ -1,4 +1,14 @@
import { defineOverridesPreferences } from '@vben/preferences';
import {
defineOverridesPreferences,
definePreferencesExtension,
} from '@vben/preferences';
interface PlaygroundPreferencesExtension {
defaultVisibleRows: number;
enableQuickActions: boolean;
highlightTone: 'default' | 'success' | 'warning';
reportTitle: string;
}
/**
* @description 项目配置文件
@@ -11,3 +21,64 @@ export const overridesPreferences = defineOverridesPreferences({
name: import.meta.env.VITE_APP_TITLE,
},
});
export type { PlaygroundPreferencesExtension };
export const preferencesExtension =
definePreferencesExtension<PlaygroundPreferencesExtension>({
tabLabel: 'demos.preferencesExtensionConfig.tabLabel',
title: 'demos.preferencesExtensionConfig.title',
fields: [
{
component: 'input',
defaultValue: '',
key: 'reportTitle',
label: 'demos.preferencesExtensionConfig.fields.reportTitle.label',
placeholder:
'demos.preferencesExtensionConfig.fields.reportTitle.placeholder',
},
{
component: 'number',
componentProps: {
max: 8,
min: 1,
step: 1,
},
defaultValue: 4,
key: 'defaultVisibleRows',
label:
'demos.preferencesExtensionConfig.fields.defaultVisibleRows.label',
tip: 'demos.preferencesExtensionConfig.fields.defaultVisibleRows.tip',
},
{
component: 'switch',
defaultValue: true,
key: 'enableQuickActions',
label:
'demos.preferencesExtensionConfig.fields.enableQuickActions.label',
},
{
component: 'select',
defaultValue: 'default',
key: 'highlightTone',
label: 'demos.preferencesExtensionConfig.fields.highlightTone.label',
options: [
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.default',
value: 'default',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.success',
value: 'success',
},
{
label:
'demos.preferencesExtensionConfig.fields.highlightTone.options.warning',
value: 'warning',
},
],
},
],
});

View File

@@ -124,6 +124,16 @@ const routes: RouteRecordRaw[] = [
title: $t('demos.features.watermark'),
},
},
{
name: 'PreferencesExtensionDemo',
path: '/demos/features/preferences-extension',
component: () =>
import('#/views/demos/features/preferences-extension/index.vue'),
meta: {
icon: 'lucide:sliders-horizontal',
title: $t('demos.features.preferencesExtension'),
},
},
{
name: 'FeatureTabsDemo',
path: '/demos/features/tabs',

View File

@@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { PlaygroundPreferencesExtension } from '#/preferences';
import { computed } from 'vue';
import { Page } from '@vben/common-ui';
import {
getCustomPreferences,
updateCustomPreferences,
} from '@vben/preferences';
import { Alert, Button, Card, Space, Tag } from 'ant-design-vue';
import { $t } from '#/locales';
interface DemoTaskItem {
id: number;
owner: string;
priority: 'P0' | 'P1' | 'P2';
title: string;
}
type HighlightTone = PlaygroundPreferencesExtension['highlightTone'];
const playgroundPreferences =
getCustomPreferences<PlaygroundPreferencesExtension>();
const demoTasks: DemoTaskItem[] = [
{ id: 1, owner: 'Luna', priority: 'P0', title: '同步租户配置到缓存' },
{ id: 2, owner: 'Aiden', priority: 'P1', title: '补充角色权限回归用例' },
{ id: 3, owner: 'Mia', priority: 'P0', title: '修复看板接口超时重试' },
{ id: 4, owner: 'Noah', priority: 'P2', title: '整理本周运营周报模板' },
{ id: 5, owner: 'Ethan', priority: 'P1', title: '验证暗黑主题下图表对比度' },
{ id: 6, owner: 'Sophia', priority: 'P1', title: '更新埋点字段映射文档' },
{ id: 7, owner: 'Lucas', priority: 'P2', title: '检查消息中心未读状态同步' },
{ id: 8, owner: 'Emma', priority: 'P0', title: '补齐导出任务失败告警' },
];
const toneMap = {
default: {
alertType: 'info',
cardClass: 'border-primary/20 bg-primary/5',
label: $t('demos.preferencesExtensionDemo.tones.default'),
tagColor: 'processing',
},
success: {
alertType: 'success',
cardClass: 'border-emerald-500/20 bg-emerald-500/5',
label: $t('demos.preferencesExtensionDemo.tones.success'),
tagColor: 'success',
},
warning: {
alertType: 'warning',
cardClass: 'border-amber-500/20 bg-amber-500/5',
label: $t('demos.preferencesExtensionDemo.tones.warning'),
tagColor: 'warning',
},
} as const satisfies Record<
HighlightTone,
{
alertType: 'info' | 'success' | 'warning';
cardClass: string;
label: string;
tagColor: string;
}
>;
const visibleTasks = computed(() => {
return demoTasks.slice(0, playgroundPreferences.defaultVisibleRows);
});
const toneConfig = computed(() => {
return toneMap[playgroundPreferences.highlightTone];
});
const formattedPlaygroundPreferences = computed(() => {
return JSON.stringify(playgroundPreferences, null, 2);
});
const preClasses =
'mt-4 overflow-auto rounded-lg border border-border bg-muted p-4 text-sm';
function applyPreset(type: 'compact' | 'focus' | 'review') {
const presetMap: Record<
typeof type,
Partial<PlaygroundPreferencesExtension>
> = {
compact: {
defaultVisibleRows: 3,
enableQuickActions: false,
highlightTone: 'warning',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.compact'),
},
focus: {
defaultVisibleRows: 4,
enableQuickActions: true,
highlightTone: 'default',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.default'),
},
review: {
defaultVisibleRows: 6,
enableQuickActions: true,
highlightTone: 'success',
reportTitle: $t('demos.preferencesExtensionDemo.presetTitles.review'),
},
};
updateCustomPreferences<PlaygroundPreferencesExtension>(presetMap[type]);
}
function getPriorityColor(priority: DemoTaskItem['priority']) {
switch (priority) {
case 'P0': {
return 'error';
}
case 'P1': {
return 'warning';
}
default: {
return 'default';
}
}
}
</script>
<template>
<Page :title="$t('demos.features.preferencesExtension')">
<template #description>
<div class="mt-2 text-foreground/80">
{{ $t('demos.preferencesExtensionDemo.description') }}
</div>
</template>
<Card
class="mb-5"
:title="$t('demos.preferencesExtensionDemo.currentConfig')"
>
<Alert :type="toneConfig.alertType" show-icon>
<template #message>
{{
$t('demos.preferencesExtensionDemo.currentTitle', {
title: playgroundPreferences.reportTitle,
})
}}
</template>
<template #description>
{{
$t('demos.preferencesExtensionDemo.currentDescription', {
count: playgroundPreferences.defaultVisibleRows,
quickActionText: playgroundPreferences.enableQuickActions
? $t('demos.preferencesExtensionDemo.showQuickActions')
: $t('demos.preferencesExtensionDemo.hideQuickActions'),
tone: toneConfig.label,
})
}}
</template>
</Alert>
<div class="mt-4 rounded-xl border p-4" :class="toneConfig.cardClass">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">
{{ playgroundPreferences.reportTitle }}
</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.boardDescription') }}
</div>
</div>
<Tag :color="toneConfig.tagColor">
{{ toneConfig.label }}
</Tag>
</div>
<Space
v-if="playgroundPreferences.enableQuickActions"
wrap
class="mb-4"
>
<Button type="primary">
{{ $t('demos.preferencesExtensionDemo.quickActions.create') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.export') }}
</Button>
<Button>
{{ $t('demos.preferencesExtensionDemo.quickActions.refresh') }}
</Button>
</Space>
<div v-else class="mb-4 text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.quickActionsEnabled') }}
</div>
<div class="space-y-2">
<div
v-for="task in visibleTasks"
:key="task.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-background px-4 py-3"
>
<div>
<div class="font-medium">{{ task.title }}</div>
<div class="text-sm text-foreground/60">
{{ $t('demos.preferencesExtensionDemo.owner') }}{{
task.owner
}}
</div>
</div>
<Tag :color="getPriorityColor(task.priority)">
{{ task.priority }}
</Tag>
</div>
</div>
</div>
</Card>
<Card :title="$t('demos.preferencesExtensionDemo.presetTitle')">
<Space wrap>
<Button @click="applyPreset('focus')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.default') }}
</Button>
<Button @click="applyPreset('compact')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.compact') }}
</Button>
<Button type="primary" @click="applyPreset('review')">
{{ $t('demos.preferencesExtensionDemo.presetButtons.review') }}
</Button>
</Space>
<pre :class="preClasses">{{ formattedPlaygroundPreferences }}</pre>
</Card>
</Page>
</template>