pull/340/MERGE
xingyu4j 2026-04-13 16:45:32 +08:00
commit a653e428f3
128 changed files with 8150 additions and 3724 deletions

View File

@ -33,7 +33,7 @@ jobs:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false

View File

@ -20,6 +20,6 @@ jobs:
if: github.repository == 'vbenjs/vue-vben-admin'
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -58,7 +58,7 @@ jobs:
echo "version=${version}" >> $GITHUB_OUTPUT
echo "major=${major}" >> $GITHUB_OUTPUT
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v7
with:
version: ${{ steps.version.outputs.version }}
publish: true

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

10
.vscode/settings.json vendored
View File

@ -181,9 +181,10 @@
"stylelint.customSyntax": "postcss-html",
"stylelint.snippet": ["css", "less", "postcss", "scss", "vue"],
"typescript.inlayHints.enumMemberValues.enabled": true,
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.includePackageJsonAutoImports": "on",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.inlayHints.enumMemberValues.enabled": true,
"js/ts.preferences.preferTypeOnlyAutoImports": true,
"js/ts.preferences.includePackageJsonAutoImports": "on",
"eslint.validate": [
"javascript",
@ -236,6 +237,5 @@
},
"commentTranslate.hover.enabled": false,
"commentTranslate.multiLineMerge": true,
"vue.server.hybridMode": true,
"typescript.tsdk": "node_modules/typescript/lib"
"vue.server.hybridMode": true
}

View File

@ -12,7 +12,7 @@
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<script>
var HM_ID = '<%= VITE_APP_BAIDU_CODE %>';

View File

@ -6,14 +6,38 @@
/* eslint-disable vue/one-component-per-file */
import type {
AutoCompleteProps,
ButtonProps,
CascaderProps,
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DividerProps,
InputNumberProps,
InputProps,
MentionsProps,
RadioGroupProps,
RadioProps,
RateProps,
SelectProps,
SpaceProps,
SwitchProps,
TextAreaProps,
TimePickerProps,
TreeSelectProps,
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
IconPickerProps,
} from '@vben/common-ui';
import type { Sortable } from '@vben/hooks';
import type { Recordable } from '@vben/types';
@ -46,6 +70,15 @@ import { message, Modal, notification } from 'ant-design-vue';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { FileUpload, ImageUpload } from '#/components/upload';
type AdapterUploadProps = UploadProps & {
aspectRatio?: string;
crop?: boolean;
draggable?: boolean;
handleChange?: (event: UploadChangeParam) => void;
maxSize?: number;
onDragSort?: (oldIndex: number, newIndex: number) => void;
onHandleChange?: (event: UploadChangeParam) => void;
};
const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
@ -602,6 +635,39 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* {@link ComponentType} 便 Schema `component` + `componentProps`
*/
export interface ComponentPropsMap {
ApiCascader: ApiComponentSharedProps & CascaderProps;
ApiSelect: ApiComponentSharedProps & SelectProps;
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
AutoComplete: AutoCompleteProps;
Cascader: CascaderProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
DatePicker: DatePickerProps;
DefaultButton: ButtonProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
InputPassword: InputProps;
Mentions: MentionsProps;
PrimaryButton: ButtonProps;
Radio: RadioProps;
RadioGroup: RadioGroupProps;
RangePicker: RangePickerProps;
Rate: RateProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
Textarea: TextAreaProps;
TimePicker: TimePickerProps;
TreeSelect: TreeSelectProps;
Upload: AdapterUploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载

View File

@ -1,9 +1,9 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -61,9 +61,9 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType>;
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;

View File

@ -1,6 +1,8 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
@ -10,7 +12,7 @@ import {
AsyncVxeTable,
createRequiredValidation,
setupVbenVxeTable,
useVbenVxeGrid,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import {
erpCountInputFormatter,
@ -363,10 +365,13 @@ setupVbenVxeTable({
useVbenForm,
});
export { createRequiredValidation, useVbenVxeGrid };
export { createRequiredValidation };
export const [VxeTable, VxeColumn] = [AsyncVxeTable, AsyncVxeColumn];
export * from '#/components/table-action';
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export type * from '@vben/plugins/vxe-table';

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
@ -23,3 +33,52 @@ export const overridesPreferences = defineOverridesPreferences({
companySiteLink: 'https://gitee.com/yudaocode/yudao-ui-admin-vben',
},
});
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

@ -12,7 +12,7 @@
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计

View File

@ -5,11 +5,39 @@
/* eslint-disable vue/one-component-per-file */
import type { UploadChangeParam, UploadFile, UploadProps } from 'antdv-next';
import type {
AutoCompleteProps,
ButtonProps,
CascaderProps,
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DividerProps,
InputNumberProps,
InputProps,
MentionsProps,
RadioGroupProps,
RadioProps,
RangePickerProps,
RateProps,
SelectProps,
SpaceProps,
SwitchProps,
TextAreaProps,
TimePickerProps,
TreeSelectProps,
UploadChangeParam,
UploadFile,
UploadProps,
} from 'antdv-next';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
IconPickerProps,
} from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
@ -35,6 +63,14 @@ import { isEmpty } from '@vben/utils';
import { message, Modal, notification } from 'antdv-next';
type AdapterUploadProps = UploadProps & {
aspectRatio?: string;
crop?: boolean;
handleChange?: (event: UploadChangeParam) => void;
maxSize?: number;
onHandleChange?: (event: UploadChangeParam) => void;
};
const AutoComplete = defineAsyncComponent(
() => import('antdv-next/dist/auto-complete/index'),
);
@ -521,6 +557,39 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* {@link ComponentType} 便 Schema `component` + `componentProps`
*/
export interface ComponentPropsMap {
ApiCascader: ApiComponentSharedProps & CascaderProps;
ApiSelect: ApiComponentSharedProps & SelectProps;
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
AutoComplete: AutoCompleteProps;
Cascader: CascaderProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
DatePicker: DatePickerProps;
DefaultButton: ButtonProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
InputPassword: InputProps;
Mentions: MentionsProps;
PrimaryButton: ButtonProps;
Radio: RadioProps;
RadioGroup: RadioGroupProps;
RangePicker: RangePickerProps;
Rate: RateProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
Textarea: TextAreaProps;
TimePicker: TimePickerProps;
TreeSelect: TreeSelectProps;
Upload: AdapterUploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载

View File

@ -1,9 +1,9 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -40,10 +40,9 @@ async function initSetupVbenForm() {
},
});
}
const useVbenForm = useForm<ComponentType>;
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;

View File

@ -1,8 +1,13 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import {
setupVbenVxeTable,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import { Button, Image } from 'antdv-next';
@ -65,6 +70,8 @@ setupVbenVxeTable({
useVbenForm,
});
export { useVbenVxeGrid };
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export type * from '@vben/plugins/vxe-table';

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
@ -28,6 +28,14 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
watch(
tokenTheme,
(themeConfig) => {
ConfigProvider.config({ theme: themeConfig });
},
{ immediate: true },
);
</script>
<template>

View File

@ -12,7 +12,7 @@
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<script>
var HM_ID = '<%= VITE_APP_BAIDU_CODE %>';

View File

@ -3,9 +3,29 @@
* vben-formvben-modalvben-drawer 使,
*/
import type {
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DividerProps,
ElTimePicker as ElTimePickerType,
ElTreeSelect as ElTreeSelectType,
InputNumberProps,
InputProps,
RadioGroupProps,
SelectV2Props,
SpaceProps,
SwitchProps,
UploadProps,
} from 'element-plus';
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
IconPickerProps,
} from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
@ -24,6 +44,9 @@ const ElAutoComplete = defineAsyncComponent(() =>
import('element-plus/es/components/autocomplete/style/css'),
]).then(([res]) => res.ElAutocomplete),
);
type ElTreeSelectSchemaProps = InstanceType<typeof ElTreeSelectType>['$props'];
type ElTimePickerSchemaProps = InstanceType<typeof ElTimePickerType>['$props'];
const ElButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/button/index'),
@ -208,6 +231,28 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* {@link ComponentType} 便 Schema `component` + `componentProps`
*/
export interface ComponentPropsMap {
ApiSelect: ApiComponentSharedProps & SelectV2Props;
ApiTreeSelect: ApiComponentSharedProps & ElTreeSelectSchemaProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
DatePicker: DatePickerProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
RadioGroup: RadioGroupProps;
Select: SelectV2Props;
Space: SpaceProps;
Switch: SwitchProps;
TimePicker: ElTimePickerSchemaProps;
TreeSelect: ElTreeSelectSchemaProps;
Upload: UploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载

View File

@ -1,9 +1,9 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -53,9 +53,9 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType>;
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;

View File

@ -1,16 +1,17 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import {
AsyncVxeColumn,
AsyncVxeTable,
createRequiredValidation,
setupVbenVxeTable,
useVbenVxeGrid,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import {
erpCountInputFormatter,
@ -358,10 +359,13 @@ setupVbenVxeTable({
useVbenForm,
});
export { createRequiredValidation, useVbenVxeGrid };
export { createRequiredValidation };
export const [VxeTable, VxeColumn] = [AsyncVxeTable, AsyncVxeColumn];
export * from '#/components/table-action';
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export type * from '@vben/plugins/vxe-table';

View File

@ -12,7 +12,7 @@
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计

View File

@ -3,9 +3,29 @@
* vben-formvben-modalvben-drawer 使,
*/
import type {
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DividerProps,
InputNumberProps,
InputProps,
RadioGroupProps,
SelectProps,
SpaceProps,
SwitchProps,
TimePickerProps,
TreeSelectProps,
UploadProps,
} from 'naive-ui';
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
IconPickerProps,
} from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
@ -125,6 +145,28 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* {@link ComponentType} 便 Schema `component` + `componentProps`
*/
export interface ComponentPropsMap {
ApiSelect: ApiComponentSharedProps & SelectProps;
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
DatePicker: DatePickerProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
RadioGroup: RadioGroupProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
TimePicker: TimePickerProps;
TreeSelect: TreeSelectProps;
Upload: UploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载

View File

@ -1,9 +1,9 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -58,9 +58,9 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType>;
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;

View File

@ -1,8 +1,13 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import {
setupVbenVxeTable,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import {
erpCountInputFormatter,
erpNumberFormatter,
@ -214,7 +219,9 @@ setupVbenVxeTable({
useVbenForm,
});
export { useVbenVxeGrid };
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export * from '#/components/table-action';
export type * from '@vben/plugins/vxe-table';

View File

@ -12,7 +12,7 @@
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计

View File

@ -1,6 +1,32 @@
import type {
AutoCompleteProps,
ButtonProps,
CheckboxGroupProps,
CheckboxProps,
DatePickerProps,
DateRangePickerProps,
DividerProps,
InputNumberProps,
InputProps,
RadioGroupProps,
RadioProps,
SelectProps,
SpaceProps,
SwitchProps,
TextareaProps,
TimePickerProps,
TreeSelectProps,
} from 'tdesign-vue-next';
import type { TdRateProps } from 'tdesign-vue-next/es/rate/type';
import type { UploadProps } from 'tdesign-vue-next/es/upload/types';
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type {
ApiComponentSharedProps,
BaseFormComponentType,
IconPickerProps,
} from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
@ -126,6 +152,35 @@ export type ComponentType =
| 'Upload'
| BaseFormComponentType;
/**
* {@link ComponentType} 便 Schema `component` + `componentProps`
*/
export interface ComponentPropsMap {
ApiSelect: ApiComponentSharedProps & SelectProps;
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
AutoComplete: AutoCompleteProps;
Checkbox: CheckboxProps;
CheckboxGroup: CheckboxGroupProps;
DatePicker: DatePickerProps;
DefaultButton: ButtonProps;
Divider: DividerProps;
IconPicker: IconPickerProps;
Input: InputProps;
InputNumber: InputNumberProps;
PrimaryButton: ButtonProps;
Radio: RadioProps;
RadioGroup: RadioGroupProps;
RangePicker: DateRangePickerProps;
Rate: TdRateProps;
Select: SelectProps;
Space: SpaceProps;
Switch: SwitchProps;
Textarea: TextareaProps;
TimePicker: TimePickerProps;
TreeSelect: TreeSelectProps;
Upload: UploadProps;
}
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
@ -165,7 +220,17 @@ async function initComponentAdapter() {
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, theme: 'default' }, slots);
let ghost = false;
let variant = props.variant;
if (props.variant === 'ghost') {
ghost = true;
variant = 'base';
}
return h(
Button,
{ ...props, ghost, variant, attrs, theme: 'default' },
slots,
);
},
Divider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {

View File

@ -1,9 +1,9 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import type { ComponentPropsMap, ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -41,9 +41,9 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType>;
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;

View File

@ -1,5 +1,7 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
import { h } from 'vue';
import {
@ -7,7 +9,7 @@ import {
AsyncVxeTable,
createRequiredValidation,
setupVbenVxeTable,
useVbenVxeGrid,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import {
erpCountInputFormatter,
@ -220,10 +222,13 @@ setupVbenVxeTable({
useVbenForm,
});
export { createRequiredValidation, useVbenVxeGrid };
export { createRequiredValidation };
export const [VxeTable, VxeColumn] = [AsyncVxeTable, AsyncVxeColumn];
export * from '#/components/table-action';
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export type * from '@vben/plugins/vxe-table';

View File

@ -230,6 +230,16 @@ export { initComponentAdapter };
<DemoPreview dir="demos/vben-form/query" />
## 值格式化
当组件的展示值与后端真正需要的 payload 不一致时,可以在 schema 上使用 `valueFormat`。它会在 `getValues()`、提交、以及依赖这些输出的方法中生效。
- `return xxx`:回写当前字段
- `setValue('startTime', xxx)`:写入其他字段
- `return undefined`:保持当前字段已被移除,适合把一个字段拆成多个字段
<DemoPreview dir="demos/vben-form/value-format" />
## 表单校验
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
@ -343,6 +353,32 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
:::
::: tip valueFormat
`valueFormat` 适合处理“组件值”和“提交值”不一致的场景。例如:
- `RangePicker` 返回 `[dayjs, dayjs]`,但后端需要 `{ startTime, endTime }`
- `DatePicker` 返回 `dayjs`,但后端只需要时间戳
`valueFormat` 会在 `getValues()` 过程中执行:
- 返回 `undefined`:当前字段保持删除状态
- 返回其他值:回写当前字段
- 调用 `setValue(key, nextValue)`:写入一个或多个新字段
```ts
{
component: 'RangePicker',
fieldName: 'reportRange',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
}
```
:::
### TS 类型说明
::: details ActionButtonOptions
@ -464,11 +500,29 @@ export interface FormSchema<
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/** 获取 getValues() 输出时格式化当前字段 */
valueFormat?: FormValueFormat;
}
```
:::
::: details FormValueFormat
```ts
type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;
```
- 返回 `undefined`:保持当前字段已被移除
- 返回其他值:将当前字段恢复/写回为该值
- `setValue(fieldName, value)`:用于把一个字段拆分写入其他字段
:::
### 表单联动
表单联动需要通过 schema 内的 `dependencies` 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。

View File

@ -34,7 +34,7 @@ Modal 内的内容一般业务中,会比较复杂,所以我们可以将 moda
## 开启拖拽
通过 `draggable` 参数,可开启拖拽功能。
通过 `draggable` 参数,可开启拖拽功能。若需要拖动范围超出可视区,可设置 `overflow: true`(需已开启 `draggable`)。
<DemoPreview dir="demos/vben-modal/draggable" />
@ -101,6 +101,7 @@ const [Modal, modalApi] = useVbenModal({
| fullscreen | 全屏显示 | `boolean` | `false` |
| fullscreenButton | 显示全屏按钮 | `boolean` | `true` |
| draggable | 可拖拽 | `boolean` | `false` |
| overflow | 拖动范围可以超出可视区 | `boolean` | `false` |
| closable | 显示关闭按钮 | `boolean` | `true` |
| centered | 居中显示 | `boolean` | `false` |
| modal | 显示遮罩 | `boolean` | `true` |

View File

@ -0,0 +1,138 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Button, Card, message, Space, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
const transformedValues = ref<Record<string, any>>({});
const liveValues = ref<Record<string, any>>({});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
schema: [
{
component: 'RangePicker',
fieldName: 'reportRange',
help: '通过 setValue 拆分为 startTime / endTime并移除原字段',
label: '统计时间范围',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
},
{
component: 'DatePicker',
fieldName: 'deadline',
help: '直接 return 时间戳,保留原字段名',
label: '截止时间',
valueFormat(value) {
return value?.valueOf();
},
},
{
component: 'Input',
componentProps: {
placeholder: '请输入关键字',
},
fieldName: 'keyword',
label: '关键字',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: Record<string, any>) {
return JSON.stringify(
value,
(_key, currentValue) => {
return isFormattableDateValue(currentValue)
? currentValue.format('YYYY-MM-DD HH:mm:ss')
: currentValue;
},
2,
);
}
function isFormattableDateValue(
value: unknown,
): value is { format: (template: string) => string } {
return !!value && typeof value === 'object' && 'format' in value;
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
function handleSubmit(values: Record<string, any>) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
async function syncPreviewValues(values?: Record<string, any>) {
liveValues.value = values ?? formApi.form?.values ?? {};
transformedValues.value = await formApi.getValues();
}
onMounted(async () => {
await nextTick();
watch(
() => formApi.form?.values,
async (values) => {
await syncPreviewValues(values);
},
{
deep: true,
immediate: true,
},
);
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Tag color="processing">return 回写当前字段</Tag>
<Tag color="success">setValue拆分写入其他字段</Tag>
<Tag color="warning">return undefined保持原字段删除</Tag>
</div>
<Card title="valueFormat 示例">
<template #extra>
<Space wrap>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="原始 form.values组件值">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出valueFormat 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</div>
</template>

View File

@ -191,12 +191,23 @@ Create the form through `useVbenForm`:
<DemoPreview dir="demos/vben-form/basic" />
## Value Formatting
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape.
- return a value to write back to the current field
- call `setValue(key, nextValue)` to write derived fields
- return `undefined` to keep the original field removed after decomposition
<DemoPreview dir="demos/vben-form/value-format" />
## Key API Notes
- `useVbenForm` returns `[Form, formApi]`
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
- `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
## Reference

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

@ -39,6 +39,6 @@ If you want to customize the global loading, you can create a `loading.html` fil
</style>
<div id="__app-loading__">
<!-- ... -->
<div class="title"><%= VITE_APP_TITLE %></div>
<div class="title">%VITE_APP_TITLE%</div>
</div>
```

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

@ -39,7 +39,7 @@ VITE_INJECT_APP_LOADING=false
</style>
<div id="__app-loading__">
<!-- ... -->
<div class="title"><%= VITE_APP_TITLE %></div>
<div class="title">%VITE_APP_TITLE%</div>
</div>
```

View File

@ -104,6 +104,8 @@ export async function javascript(): Promise<Linter.Config[]> {
'keyword-spacing': 'off',
'no-control-regex': 'error',
'no-empty-function': 'off',
'no-octal': 'error',
'no-octal-escape': 'error',
'no-restricted-properties': [
'error',
{
@ -136,8 +138,32 @@ export async function javascript(): Promise<Linter.Config[]> {
'TSEnumDeclaration[const=true]',
'TSExportAssignment',
],
'no-undef-init': 'error',
'no-undef': 'off',
'no-unreachable-loop': 'error',
'object-shorthand': [
'error',
'always',
{
avoidQuotes: true,
ignoreConstructors: false,
},
],
'one-var': ['error', { initialized: 'never' }],
'prefer-arrow-callback': [
'error',
{
allowNamedFunctions: false,
allowUnboundThis: true,
},
],
'prefer-regex-literals': [
'error',
{
disallowRedundantWrapping: true,
},
],
'spaced-comment': 'error',
'space-before-function-paren': 'off',
'unused-imports/no-unused-imports': 'error',

View File

@ -46,13 +46,12 @@ const javascript: OxlintConfig = {
'no-empty': ['error', { allowEmptyCatch: true }],
'no-fallthrough': 'error',
'no-new-func': 'error',
'no-new-object': 'error',
'no-new-symbol': 'error',
'no-object-constructor': 'error',
'no-new-native-nonconstructor': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-multi-str': 'error',
'no-octal': 'error',
'no-octal-escape': 'error',
'no-nonoctal-decimal-escape': 'error',
'no-proto': 'error',
'no-prototype-builtins': 'error',
'no-redeclare': ['error', { builtinGlobals: false }],
@ -69,7 +68,6 @@ const javascript: OxlintConfig = {
],
'no-template-curly-in-string': 'error',
'no-throw-literal': 'error',
'no-undef-init': 'error',
'no-unused-expressions': [
'error',
{
@ -98,15 +96,6 @@ const javascript: OxlintConfig = {
'no-useless-computed-key': 'error',
'no-useless-constructor': 'error',
'no-useless-return': 'error',
'object-shorthand': [
'error',
'always',
{
avoidQuotes: true,
ignoreConstructors: false,
},
],
'one-var': ['error', { initialized: 'never' }],
'prefer-const': [
'error',
{
@ -114,25 +103,11 @@ const javascript: OxlintConfig = {
ignoreReadBeforeAssign: true,
},
],
'eslint/prefer-arrow-callback': [
'error',
{
allowNamedFunctions: false,
allowUnboundThis: true,
},
],
'prefer-exponentiation-operator': 'error',
'prefer-promise-reject-errors': 'error',
'eslint/prefer-regex-literals': [
'error',
{
disallowRedundantWrapping: true,
},
],
'prefer-rest-params': 'error',
'prefer-spread': 'error',
'prefer-template': 'error',
'spaced-comment': 'error',
'symbol-description': 'error',
'unicode-bom': ['error', 'never'],
'use-isnan': [

View File

@ -1,5 +1,7 @@
import type { OxlintConfig } from 'oxlint';
import { fileURLToPath } from 'node:url';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults';
import { SelectorKind } from 'eslint-plugin-better-tailwindcss/types';
@ -13,8 +15,12 @@ const selectors = [
},
];
const entryPoint = fileURLToPath(
new URL('../../../../tailwind-config/src/theme.css', import.meta.url),
);
const settings = {
entryPoint: 'internal/tailwind-config/src/theme.css',
entryPoint,
selectors,
};

View File

@ -17,6 +17,7 @@ const test: OxlintConfig = {
'vitest/no-import-node-test': 'error',
'vitest/prefer-hooks-in-order': 'error',
'vitest/prefer-lowercase-title': 'error',
'vitest/require-mock-type-parameters': 'off',
},
};

View File

@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": false
},
"exclude": ["node_modules", "src/__tests__"]

View File

@ -380,6 +380,13 @@
@apply hidden;
}
}
/* Tailwind v4 Preflight button pointer
* https://tailwindcss.com/docs/upgrade-guide#buttons-use-the-default-cursor */
button:not(:disabled),
[role='button']:not(:disabled) {
@apply cursor-pointer;
}
}
/* Custom utilities (v4 @utility syntax) */
@ -396,33 +403,36 @@
justify-content: center;
}
/* Component styles (complex selectors, not convertible to @utility) */
.outline-box {
@apply outline-border relative cursor-pointer rounded-md p-1 outline-1;
}
/* Tailwind v4 utilities @layer layer py-4
* https://tailwindcss.com/docs/adding-custom-styles#using-css-and-layering */
@layer components {
.outline-box {
@apply outline-border relative cursor-pointer rounded-md p-1 outline-1;
}
.outline-box::after {
@apply absolute top-1/2 left-1/2 z-20 h-0 w-px rounded-sm opacity-0 outline-2 outline-transparent transition-all duration-300 content-[""];
}
.outline-box::after {
@apply absolute top-1/2 left-1/2 z-20 h-0 w-px rounded-sm opacity-0 outline-2 outline-transparent transition-all duration-300 content-[""];
}
.outline-box.outline-box-active {
@apply outline-primary outline-2;
}
.outline-box.outline-box-active {
@apply outline-primary outline-2;
}
.outline-box.outline-box-active::after {
display: none;
}
.outline-box.outline-box-active::after {
display: none;
}
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary top-0 left-0 h-full w-full p-1 opacity-100;
}
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary top-0 left-0 h-full w-full p-1 opacity-100;
}
.vben-link {
@apply text-primary hover:text-primary-hover active:text-primary-active cursor-pointer;
}
.vben-link {
@apply text-primary hover:text-primary-hover active:text-primary-active cursor-pointer;
}
.card-box {
@apply bg-card text-card-foreground border-border rounded-xl border;
.card-box {
@apply bg-card text-card-foreground border-border rounded-xl border;
}
}
/* Enter animations (converted from enterAnimationPlugin) */

View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -48,13 +48,13 @@
"@vitejs/plugin-vue-jsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"rolldown": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"sass": "catalog:",
"sass-embedded": "catalog:",
"unplugin-dts": "catalog:",
"vite": "catalog:",
"vite-plugin-compression": "catalog:",
"vite-plugin-html": "catalog:",
"vite-plugin-lazy-import": "catalog:"
}
}

View File

@ -0,0 +1,35 @@
import type { Options as HtmlMinifierOptions } from 'html-minifier-terser';
import type { PluginOption } from 'vite';
import { minify } from 'html-minifier-terser';
const HTML_MINIFY_OPTIONS = {
collapseWhitespace: true,
minifyCSS: true,
minifyJS: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
} as const;
function viteHtmlPlugin(options: HtmlMinifierOptions = {}): PluginOption {
return {
name: 'vben-native-html',
transformIndexHtml: {
order: 'post',
async handler(html, ctx) {
if (!ctx.bundle) {
return html;
}
return await minify(html, {
...HTML_MINIFY_OPTIONS,
...options,
});
},
},
};
}
export { viteHtmlPlugin };

View File

@ -14,12 +14,12 @@ import viteVueJsx from '@vitejs/plugin-vue-jsx';
import { visualizer as viteVisualizerPlugin } from 'rollup-plugin-visualizer';
import viteDtsPlugin from 'unplugin-dts/vite';
import viteCompressPlugin from 'vite-plugin-compression';
import { createHtmlPlugin as viteHtmlPlugin } from 'vite-plugin-html';
import { VitePWA } from 'vite-plugin-pwa';
import viteVueDevTools from 'vite-plugin-vue-devtools';
import { viteArchiverPlugin } from './archiver';
import { viteExtraAppConfigPlugin } from './extra-app-config';
import { viteHtmlPlugin } from './html';
import { viteImportMapPlugin } from './importmap';
import { viteInjectAppLoadingPlugin } from './inject-app-loading';
import { viteMetadataPlugin } from './inject-metadata';
@ -199,7 +199,7 @@ async function loadApplicationPlugins(
},
{
condition: !!html,
plugins: () => [viteHtmlPlugin({ minify: true })],
plugins: () => [viteHtmlPlugin(typeof html === 'object' ? html : {})],
},
{
condition: isBuild && importmap,

View File

@ -103,5 +103,5 @@
</style>
<div class="loading" id="__app-loading__">
<span class="dot"><i></i><i></i><i></i><i></i></span>
<div class="title"><%= VITE_APP_TITLE %></div>
<div class="title">%VITE_APP_TITLE%</div>
</div>

View File

@ -22,8 +22,8 @@
}
.loading.hidden {
pointer-events: none;
visibility: hidden;
pointer-events: none;
opacity: 0;
transition: all 0.8s ease-out;
}
@ -109,5 +109,5 @@
</style>
<div class="loading" id="__app-loading__">
<div class="loader"></div>
<div class="title"><%= VITE_APP_TITLE %></div>
<div class="title">%VITE_APP_TITLE%</div>
</div>

View File

@ -2,7 +2,7 @@ import type {
NormalizedOutputOptions,
OutputBundle,
OutputChunk,
} from 'rollup';
} from 'rolldown';
import type { PluginOption } from 'vite';
import { EOL } from 'node:os';

View File

@ -1,3 +1,4 @@
import type { Options as HtmlMinifierOptions } from 'html-minifier-terser';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
import type { PluginOptions } from 'unplugin-dts';
import type {
@ -94,6 +95,12 @@ interface ArchiverPluginOptions {
outputDir?: string;
}
/**
* HTML
* @description transformIndexHtml HTML
*/
type HtmlPluginOptions = HtmlMinifierOptions;
/**
* ImportMap
* @description CDN
@ -217,7 +224,7 @@ interface ApplicationPluginOptions extends CommonPluginOptions {
* HTML
* @default true
*/
html?: boolean;
html?: boolean | HtmlPluginOptions;
/**
*
* @default false
@ -342,6 +349,7 @@ export type {
DefineApplicationOptions,
DefineConfig,
DefineLibraryOptions,
HtmlPluginOptions,
IImportMap,
ImportmapPluginOptions,
LibraryPluginOptions,

View File

@ -25,6 +25,7 @@
},
"type": "module",
"scripts": {
"bootstrap": "pnpm install",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
"build:analyze": "turbo build:analyze",
"build:antd": "pnpm run build --filter=@vben/web-antd",
@ -52,7 +53,7 @@
"lint": "vsh lint",
"postinstall": "pnpm -r run stub --if-present",
"preinstall": "npx only-allow pnpm",
"prepare": "pnpm exec lefthook install",
"prepare": "is-ci || pnpm exec lefthook install",
"preview": "turbo-run preview",
"publint": "vsh publint",
"reinstall": "pnpm clean --del-lock && pnpm install",
@ -65,7 +66,6 @@
"devDependencies": {
"@changesets/changelog-github": "catalog:",
"@changesets/cli": "catalog:",
"@playwright/test": "catalog:",
"@tsdown/css": "catalog:",
"@types/node": "catalog:",
"@vben/commitlint-config": "workspace:*",
@ -80,7 +80,6 @@
"@vben/vsh": "workspace:*",
"@vitejs/plugin-vue": "catalog:",
"@vitejs/plugin-vue-jsx": "catalog:",
"@vue/test-utils": "catalog:",
"cross-env": "catalog:",
"cspell": "catalog:",
"happy-dom": "catalog:",
@ -95,7 +94,6 @@
"tsdown": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"unplugin-vue": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vue": "catalog:",
@ -105,5 +103,5 @@
"node": "^20.19.0 || ^22.18.0 || ^24.0.0",
"pnpm": ">=10.0.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.33.0"
}

View File

@ -1,4 +1,7 @@
export {
TextAlignCenter as AlignCenter,
TextAlignStart as AlignLeft,
TextAlignEnd as AlignRight,
ArrowDown,
ArrowLeft,
ArrowLeftToLine,
@ -7,6 +10,7 @@ export {
ArrowUp,
ArrowUpToLine,
Bell,
Bold,
BookOpenText,
Check,
ChevronDown,
@ -24,6 +28,7 @@ export {
CornerDownLeft,
Download,
Ellipsis,
Eraser,
Expand,
ExternalLink,
Eye,
@ -34,13 +39,21 @@ export {
Grid,
Grip,
GripVertical,
Heading1,
Heading2,
Highlighter,
History,
Menu as IconDefault,
ImagePlus,
Inbox,
Info,
InspectionPanel,
Italic,
Languages,
LayoutGrid,
Link2,
List,
ListOrdered,
LoaderCircle,
LockKeyhole,
LogOut,
@ -49,16 +62,20 @@ export {
ArrowRightFromLine as MdiMenuClose,
ArrowLeftFromLine as MdiMenuOpen,
Menu,
MessageSquareCode,
Minimize,
Minimize2,
MoonStar,
Paintbrush,
Palette,
PanelLeft,
PanelRight,
Pin,
PinOff,
Plus,
Redo2,
RefreshCw,
RemoveFormatting,
RotateCw,
Search,
SearchX,
@ -67,11 +84,17 @@ export {
Shrink,
Square,
SquareCheckBig,
SquareCode,
SquareMinus,
Strikethrough,
Sun,
SunMoon,
SwatchBook,
TextQuote,
Trash2,
Underline,
Undo2,
Unlink2,
Upload,
UserRoundPen,
X,

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

@ -44,6 +44,7 @@ describe('formApi', () => {
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should get values from form', async () => {
@ -52,11 +53,54 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({ name: 'test' });
});
it('should format schema values when getting values', async () => {
formApi.setState({
schema: [
{
component: 'range-picker',
fieldName: 'filters.range',
valueFormat: (value, setValue) => {
setValue('filters.startTime', value?.[0]);
setValue('filters.endTime', value?.[1]);
},
},
],
});
const formActions: any = {
meta: {},
values: {
filters: {
range: [1_710_000_000_000, 1_720_000_000_000],
},
},
};
const originalValuesSnapshot = structuredClone(formActions.values);
await formApi.mount(formActions, new Map());
expect(formApi.getLatestSubmissionValues()).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
const values = await formApi.getValues();
expect(values).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@ -65,7 +109,7 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
@ -82,7 +126,7 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
});
@ -100,7 +144,7 @@ describe('formApi', () => {
};
formApi.setState(state);
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
@ -113,6 +157,39 @@ describe('formApi', () => {
expect(formApi.isMounted).toBe(false);
});
it('should clear component refs on unmount before mounting again', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
values: { name: 'test' },
};
const staleMap = new Map<string, unknown>([
[
'name',
{
$: {
type: { name: 'MockComponent' },
},
$el: {},
},
],
]);
await formApi.mount(formActions, staleMap);
expect(formApi.getFieldComponentRef('name')).toEqual({
$: {
type: { name: 'MockComponent' },
},
$el: {},
});
formApi.unmount();
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
await formApi.mount(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
@ -120,7 +197,7 @@ describe('formApi', () => {
validate: validateMock,
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);

View File

@ -51,5 +51,8 @@
"vue": "catalog:",
"zod": "catalog:",
"zod-defaults": "catalog:"
},
"devDependencies": {
"unplugin-vue": "catalog:"
}
}

View File

@ -0,0 +1,14 @@
export function resolveFieldNamePath(fieldName: string) {
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
return {
pathSegments: [rawKey],
rawKey,
};
}
return {
pathSegments: fieldName.match(/[^.[\]]+/g) ?? [],
rawKey: undefined,
};
}

View File

@ -16,16 +16,21 @@ import { isRef, toRaw } from 'vue';
import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
createMerge,
formatDate,
get,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
set,
StateHandler,
} from '@vben-core/shared/utils';
import { resolveFieldNamePath } from './field-name';
function getDefaultState(): VbenFormProps {
return {
actionWrapperClass: '',
@ -158,7 +163,10 @@ export class FormApi {
async getValues<T = Recordable<any>>() {
const form = await this.getForm();
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
const values = form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
: {};
return this.handleValueFormat(values) as T;
}
async isFieldValid(fieldName: string) {
@ -206,14 +214,18 @@ export class FormApi {
return proxy;
}
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
if (!this.isMounted) {
Object.assign(this.form, formActions);
this.stateHandler.setConditionTrue();
const initialValues = this.form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
: {};
this.setLatestSubmissionValues({
...toRaw(this.handleRangeTimeValue(this.form.values)),
...this.handleValueFormat(initialValues),
});
this.componentRefMap = componentRefMap;
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
}
}
@ -387,6 +399,7 @@ export class FormApi {
unmount() {
this.form?.resetForm?.();
// this.state = null;
this.componentRefMap = new Map();
this.latestSubmissionValues = null;
this.isMounted = false;
this.stateHandler.reset();
@ -467,6 +480,42 @@ export class FormApi {
return validateResult;
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (!pathSegments || pathSegments.length === 0) {
Reflect.deleteProperty(values, fieldName);
return;
}
let target: Record<string, any> | undefined = values;
for (const segment of pathSegments.slice(0, -1)) {
if (!target || !isObject(target)) {
return;
}
target = target[segment];
}
if (!target || !isObject(target)) {
return;
}
const lastPathSegment = pathSegments.at(-1);
if (!lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
private async getForm() {
if (!this.isMounted) {
// 等待form挂载
@ -584,6 +633,36 @@ export class FormApi {
return values;
};
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
const currentSchema = this.state?.schema ?? [];
currentSchema.forEach((schema) => {
if (!schema.valueFormat) {
return;
}
const fieldName = schema.fieldName;
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(values, key, nextValue);
},
values,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
});
return values;
};
private processFields = (
fields: string[],
separator: string,
@ -599,6 +678,32 @@ export class FormApi {
});
};
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];

View File

@ -10,6 +10,7 @@ import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
import { resolveFieldNamePath } from '../field-name';
import { injectRenderFormProps } from './context';
/**
@ -22,8 +23,8 @@ function resolveValueByFieldName(
fieldName: string,
) {
// vee-validate[] 表示禁用嵌套
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}

View File

@ -1,7 +1,11 @@
<script setup lang="ts">
import type { ZodType } from 'zod';
import type { FormActions, FormSchema, MaybeComponentProps } from '../types';
import type {
FormActions,
FormFieldProps,
MaybeComponentProps,
} from '../types';
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
@ -26,7 +30,7 @@ import useDependencies from './dependencies';
import FormLabel from './form-label.vue';
import { isEventObjectLike } from './helper';
interface Props extends FormSchema {}
interface Props extends FormFieldProps {}
const {
colon,
@ -48,6 +52,7 @@ const {
modelPropName,
renderComponentContent,
rules,
help,
} = defineProps<
Props & {
commonComponentProps: MaybeComponentProps;
@ -176,6 +181,18 @@ const computedProps = computed(() => {
};
});
//
const computedHelp = computed(() => {
const helpContent = help;
if (!helpContent) {
return undefined;
}
return () =>
isFunction(helpContent)
? helpContent(values.value, getFormApi())
: helpContent;
});
watch(
() => computedProps.value?.autofocus,
(value) => {
@ -324,7 +341,7 @@ onUnmounted(() => {
labelClass,
)
"
:help="help"
:help="computedHelp"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"

View File

@ -67,6 +67,14 @@ export type FormActions = FormContext<GenericObject>;
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
) => Component | string)
| string;
export type FormSchemaRuleType =
| 'mobile'
| 'mobileRequired'
@ -215,6 +223,79 @@ type RenderComponentContentType = (
api: FormActions,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
) => P & Record<string, any>)
| (P & Record<string, any>);
/**
* `getValues()`
* - `undefined` `setValue(key, nextValue)`
* `startTime` / `endTime`
* - /
* - `setValue` `(key, nextValue) => void`
*/
export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
/** 描述 */
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: CustomParamsRenderType;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单项 */
label?: CustomRenderType;
// 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/**
*
* - `undefined` fieldName
* - `undefined` setValue
*/
valueFormat?: FormValueFormat;
}
type FormSchemaDiscriminated<
T extends BaseFormComponentType,
P extends Record<string, any>,
> = {
[K in Extract<keyof P, T>]: {
/** 组件 */
component: K;
/** 组件参数 */
componentProps?: MappedComponentProps<P[K]>;
} & FormSchemaBody;
}[Extract<keyof P, T>];
type FormSchemaFallback<T extends BaseFormComponentType> = {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
} & FormSchemaBody;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormSchemaDiscriminated<T, P> | FormSchemaFallback<T>;
export type HandleSubmitFn = (
values: Record<string, any>,
) => Promise<void> | void;
@ -240,41 +321,18 @@ export type ArrayToStringFields = Array<
| string[] // 简单数组格式,最后一个元素可以是分隔符
>;
export interface FormSchema<
export interface FormFieldProps<
T extends BaseFormComponentType = BaseFormComponentType,
> extends FormCommonConfig {
> extends FormSchemaBody {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
/** 描述 */
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: CustomRenderType;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单项 */
label?: CustomRenderType;
// 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
}
export interface FormFieldProps extends FormSchema {
required?: boolean;
}
export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
/**
* 使","
@ -326,7 +384,7 @@ export interface FormRenderProps<
/**
*
*/
schema?: FormSchema<T>[];
schema?: FormSchema<T, P>[];
/**
* /
@ -351,8 +409,9 @@ export interface ActionButtonOptions extends VbenButtonProps {
export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends Omit<
FormRenderProps<T>,
FormRenderProps<T, P>,
'componentBindEventMap' | 'componentMap' | 'form'
> {
/**

View File

@ -13,9 +13,10 @@ import VbenUseForm from './vben-use-form.vue';
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormProps<T>) {
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P>) {
const IS_REACTIVE = isReactive(options);
const api = new FormApi(options);
const api = new FormApi(options as unknown as VbenFormProps);
const extendedApi: ExtendedFormApi = api as never;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);

View File

@ -47,5 +47,8 @@
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"unplugin-vue": "catalog:"
}
}

View File

@ -51,6 +51,7 @@
"vue": "catalog:"
},
"devDependencies": {
"@types/qs": "catalog:"
"@types/qs": "catalog:",
"unplugin-vue": "catalog:"
}
}

View File

@ -47,5 +47,8 @@
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"unplugin-vue": "catalog:"
}
}

View File

@ -75,7 +75,7 @@ export interface DrawerProps {
*/
headerClass?: ClassType;
/**
*
*
* @default false
*/
loading?: boolean;

View File

@ -46,6 +46,7 @@ export class ModalApi {
contentClass: '',
destroyOnClose: true,
draggable: false,
overflow: false,
footer: true,
footerClass: '',
fullscreen: false,

View File

@ -97,7 +97,7 @@ export interface ModalProps {
header?: boolean;
headerClass?: ClassType;
/**
*
*
* @default false
*/
loading?: boolean;
@ -110,6 +110,11 @@ export interface ModalProps {
*
*/
openAutoFocus?: boolean;
/**
*
* @default false
*/
overflow?: boolean;
/**
*
*/

View File

@ -81,6 +81,7 @@ const {
description,
destroyOnClose,
draggable,
overflow,
footer: showFooter,
footerClass,
fullscreen,
@ -122,6 +123,7 @@ const { dragging, transform } = useModalDraggable(
shouldDraggable,
getAppendTo,
shouldCentered,
overflow,
);
const firstOpened = ref(false);
@ -246,7 +248,8 @@ function handleClosed() {
{
'border border-border': bordered,
'shadow-3xl': !bordered,
'top-0 left-0 size-full max-h-full translate-0!': shouldFullscreen,
'top-0 left-0 size-full max-h-full transform-[translate(0,0)]!':
shouldFullscreen,
'top-1/2': centered && !shouldFullscreen,
'duration-300': !dragging,
hidden: isClosed,

View File

@ -15,6 +15,7 @@ export function useModalDraggable(
draggable: ComputedRef<boolean>,
containerSelector?: ComputedRef<string | undefined>,
centered?: ComputedRef<boolean>,
overflow?: ComputedRef<boolean>,
) {
const transform = reactive({
offsetX: 0,
@ -67,8 +68,10 @@ export function useModalDraggable(
let moveX = offsetX + e.clientX - downX;
let moveY = offsetY + e.clientY - downY;
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
moveY = Math.min(Math.max(moveY, minTop), maxTop);
if (!overflow?.value) {
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
moveY = Math.min(Math.max(moveY, minTop), maxTop);
}
transform.offsetX = moveX;
transform.offsetY = moveY;

View File

@ -1,7 +1,7 @@
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
{
defaultVariants: {
size: 'default',

View File

@ -47,5 +47,8 @@
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"unplugin-vue": "catalog:"
}
}

View File

@ -51,6 +51,7 @@
},
"devDependencies": {
"@types/json-bigint": "catalog:",
"@types/qrcode": "catalog:"
"@types/qrcode": "catalog:",
"@vue/test-utils": "catalog:"
}
}

View File

@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { Component } from 'vue';
import type { AnyPromiseFunction } from '@vben/types';
import type {
ApiComponentProps,
ApiComponentOptionsItem as OptionsItem,
} from './types';
import { computed, nextTick, ref, unref, useAttrs, watch } from 'vue';
@ -11,72 +12,12 @@ import { cloneDeep, get, isEqual, isFunction } from '@vben-core/shared/utils';
import { objectOmit } from '@vueuse/core';
type OptionsItem = {
[name: string]: any;
children?: OptionsItem[];
disabled?: boolean;
label?: string;
value?: string;
};
interface Props {
/** 组件 */
component: Component;
/** 是否将value从数字转为string */
numberToString?: boolean;
/** 获取options数据的函数 */
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
/** 传递给api的参数 */
params?: Record<string, any>;
/** 从api返回的结果中提取options数组的字段名 */
resultField?: string;
/** label字段名 */
labelField?: string;
/** children字段名需要层级数据的组件可用 */
childrenField?: string;
/** value字段名 */
valueField?: string;
/** disabled字段名 */
disabledField?: string;
/** 组件接收options数据的属性名 */
optionsPropName?: string;
/** 是否立即调用api */
immediate?: boolean;
/** 每次`visibleEvent`事件发生时都重新请求数据 */
alwaysLoad?: boolean;
/** 在api请求之前的回调函数 */
beforeFetch?: AnyPromiseFunction<any, any>;
/** 在api请求之后的回调函数 */
afterFetch?: AnyPromiseFunction<any, any>;
/** 直接传入选项数据也作为api返回空数据时的后备数据 */
options?: OptionsItem[];
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
loadingSlot?: string;
/** 触发api请求的事件名 */
visibleEvent?: string;
/** 组件的v-model属性名默认为modelValue。部分组件可能为value */
modelPropName?: string;
/**
* 自动选择
* - `first`自动选择第一个选项
* - `last`自动选择最后一个选项
* - `one`: 当请求的结果只有一个选项时自动选择该选项
* - 函数自定义选择逻辑函数的参数为请求的结果数组返回值为选择的选项
* - false不自动选择(默认)
*/
autoSelect?:
| 'first'
| 'last'
| 'one'
| ((item: OptionsItem[]) => OptionsItem)
| false;
}
defineOptions({ name: 'ApiComponent', inheritAttrs: false });
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<ApiComponentProps>(), {
labelField: 'label',
valueField: 'value',
labelFn: undefined,
disabledField: 'disabled',
childrenField: '',
optionsPropName: 'options',
@ -88,6 +29,7 @@ const props = withDefaults(defineProps<Props>(), {
alwaysLoad: false,
loadingSlot: '',
beforeFetch: undefined,
shouldFetch: undefined,
afterFetch: undefined,
modelPropName: 'modelValue',
api: undefined,
@ -113,33 +55,37 @@ const hasPendingRequest = ref(false);
const getOptions = computed(() => {
const {
labelField,
labelFn,
valueField,
disabledField,
childrenField,
numberToString,
} = props;
const refOptionsData = unref(refOptions);
function transformData(data: OptionsItem[]): OptionsItem[] {
function transformData(data: OptionsItem[] = []): OptionsItem[] {
return data.map((item) => {
const value = get(item, valueField);
const disabled = get(item, disabledField);
const children = childrenField ? get(item, childrenField) : item.children;
return {
...objectOmit(item, [labelField, valueField, disabled, childrenField]),
label: get(item, labelField),
...objectOmit(item, [
labelField,
valueField,
disabledField,
...(childrenField ? [childrenField] : []),
]),
label: labelFn ? labelFn(item) : get(item, labelField),
value: numberToString ? `${value}` : value,
disabled: get(item, disabledField),
...(childrenField && item[childrenField]
? { children: transformData(item[childrenField]) }
...(Array.isArray(children) && children.length > 0
? { children: transformData(children) }
: {}),
};
});
}
const data: OptionsItem[] = transformData(refOptionsData);
const data = transformData(unref(refOptions));
return data.length > 0 ? data : props.options;
return data.length > 0 ? data : transformData(props.options);
});
const bindProps = computed(() => {
@ -159,7 +105,7 @@ const bindProps = computed(() => {
});
async function fetchApi() {
const { api, beforeFetch, afterFetch, resultField } = props;
const { api, beforeFetch, shouldFetch, afterFetch, resultField } = props;
if (!api || !isFunction(api)) {
return;
@ -178,6 +124,14 @@ async function fetchApi() {
if (beforeFetch && isFunction(beforeFetch)) {
finalParams = (await beforeFetch(cloneDeep(finalParams))) || finalParams;
}
//
if (
shouldFetch &&
isFunction(shouldFetch) &&
!(await shouldFetch(finalParams))
) {
return;
}
let res = await api(finalParams);
if (afterFetch && isFunction(afterFetch)) {
res = (await afterFetch(res)) || res;

View File

@ -1 +1,7 @@
export { default as ApiComponent } from './api-component.vue';
export type {
ApiComponentLabelFn,
ApiComponentOptionsItem,
ApiComponentProps,
ApiComponentSharedProps,
} from './types';

View File

@ -0,0 +1,72 @@
import type { Component } from 'vue';
import type { AnyPromiseFunction } from '@vben/types';
export type ApiComponentOptionsItem = {
[name: string]: any;
children?: ApiComponentOptionsItem[];
disabled?: boolean;
label?: string;
value?: number | string;
};
export type ApiComponentLabelFn = (item: ApiComponentOptionsItem) => string;
export interface ApiComponentProps {
/** 组件 */
component: Component;
/** 是否将value从数字转为string */
numberToString?: boolean;
/** 获取options数据的函数 */
api?: (arg?: any) => Promise<ApiComponentOptionsItem[] | Record<string, any>>;
/** 传递给api的参数 */
params?: Record<string, any>;
/** 从api返回的结果中提取options数组的字段名 */
resultField?: string;
/** label字段名 */
labelField?: string;
/** 通过选项数据自定义label */
labelFn?: ApiComponentLabelFn;
/** children字段名需要层级数据的组件可用 */
childrenField?: string;
/** value字段名 */
valueField?: string;
/** disabled字段名 */
disabledField?: string;
/** 组件接收options数据的属性名 */
optionsPropName?: string;
/** 是否立即调用api */
immediate?: boolean;
/** 每次`visibleEvent`事件发生时都重新请求数据 */
alwaysLoad?: boolean;
/** 在api请求之前的回调函数 */
beforeFetch?: AnyPromiseFunction<any, any>;
/** 在api请求之前的判断是否允许请求的回调函数 */
shouldFetch?: AnyPromiseFunction<any, boolean>;
/** 在api请求之后的回调函数 */
afterFetch?: AnyPromiseFunction<any, any>;
/** 直接传入选项数据也作为api返回空数据时的后备数据 */
options?: ApiComponentOptionsItem[];
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
loadingSlot?: string;
/** 触发api请求的事件名 */
visibleEvent?: string;
/** 组件的v-model属性名默认为modelValue。部分组件可能为value */
modelPropName?: string;
/**
*
* - `first`
* - `last`
* - `one`:
* -
* - false()
*/
autoSelect?:
| 'first'
| 'last'
| 'one'
| ((item: ApiComponentOptionsItem[]) => ApiComponentOptionsItem)
| false;
}
export type ApiComponentSharedProps = Omit<ApiComponentProps, 'component'>;

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import type { IconPickerProps } from './types';
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
@ -28,28 +28,7 @@ import { objectOmit, refDebounced, watchDebounced } from '@vueuse/core';
import { fetchIconsData } from './icons';
interface Props {
pageSize?: number;
/** 图标集的名字 */
prefix?: string;
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
autoFetchApi?: boolean;
/**
* 图标列表
*/
icons?: string[];
/** Input组件 */
inputComponent?: VNode;
/** 图标插槽名,预览图标将被渲染到此插槽中 */
iconSlot?: string;
/** input组件的值属性名称 */
modelValueProp?: string;
/** 图标样式 */
iconClass?: string;
type?: 'icon' | 'input';
}
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<IconPickerProps>(), {
prefix: 'ant-design',
pageSize: 36,
icons: () => [],

View File

@ -1 +1,2 @@
export { default as IconPicker } from './icon-picker.vue';
export type { IconPickerProps } from './types';

View File

@ -0,0 +1,22 @@
import type { VNode } from 'vue';
export interface IconPickerProps {
pageSize?: number;
/** 图标集的名字 */
prefix?: string;
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
autoFetchApi?: boolean;
/**
*
*/
icons?: string[];
/** Input组件 */
inputComponent?: VNode;
/** 图标插槽名,预览图标将被渲染到此插槽中 */
iconSlot?: string;
/** input组件的值属性名称 */
modelValueProp?: string;
/** 图标样式 */
iconClass?: string;
type?: 'icon' | 'input';
}

View File

@ -12,7 +12,7 @@ import type {
import { computed, useAttrs } from 'vue';
// @ts-expect-error - vue-json-viewer does not expose compatible typings for this import path
import VueJsonViewer from 'vue-json-viewer';
import VueJsonViewerImport from 'vue-json-viewer';
import { $t } from '@vben/locales';
@ -42,6 +42,11 @@ const emit = defineEmits<{
valueClick: [value: JsonViewerValue];
}>();
/** CJS/UMD 在 Vite 下解析为 { default: Component },需解包否则会出现 missing template or render */
const VueJsonViewer =
(VueJsonViewerImport as { default?: typeof VueJsonViewerImport }).default ??
VueJsonViewerImport;
const attrs: SetupContext['attrs'] = useAttrs();
function handleClick(event: MouseEvent) {

View File

@ -2,11 +2,11 @@ import type { Arrayable, MaybeElementRef } from '@vueuse/core';
import type { Ref } from 'vue';
import { computed, effectScope, onUnmounted, ref, unref, watch } from 'vue';
import { computed, effectScope, ref, unref, watch } from 'vue';
import { isFunction } from '@vben/utils';
import { useElementHover } from '@vueuse/core';
import { tryOnScopeDispose, useElementHover } from '@vueuse/core';
interface HoverDelayOptions {
/** 鼠标进入延迟时间 */
@ -151,7 +151,7 @@ export function useHoverToggle(
},
};
onUnmounted(() => {
tryOnScopeDispose(() => {
clearTimers();
// 停止监听器
stopWatcher();

View File

@ -47,6 +47,8 @@ function search(searchKey: string) {
searchResults.value = [];
return;
}
//
searchKey = searchKey.toLowerCase();
// 使
const reg = createSearchReg(searchKey);
@ -196,7 +198,7 @@ watch(
if (val) {
handleSearch(val);
} else {
searchResults.value = [...searchHistory.value];
searchResults.value = searchHistory.value;
}
},
);

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

@ -14,14 +14,18 @@
"**/*.css"
],
"exports": {
"./code-editor": {
"types": "./src/code-editor/index.ts",
"default": "./src/code-editor/index.ts"
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./echarts": {
"types": "./src/echarts/index.ts",
"default": "./src/echarts/index.ts"
},
"./tiptap": {
"types": "./src/tiptap/index.ts",
"default": "./src/tiptap/index.ts"
},
"./vxe-table": {
"types": "./src/vxe-table/index.ts",
"default": "./src/vxe-table/index.ts"
@ -37,12 +41,28 @@
"./tinyflow": {
"types": "./src/tinyflow/index.ts",
"default": "./src/tinyflow/index.ts"
},
"./code-editor": {
"types": "./src/code-editor/index.ts",
"default": "./src/code-editor/index.ts"
}
},
"dependencies": {
"@tinyflow-ai/vue": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-document": "catalog:",
"@tiptap/extension-highlight": "catalog:",
"@tiptap/extension-image": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-placeholder": "catalog:",
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-text-style": "catalog:",
"@tiptap/extension-underline": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@tiptap/vue-3": "catalog:",
"@vben-core/design": "workspace:*",
"@vben-core/form-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/hooks": "workspace:*",

View File

@ -0,0 +1,41 @@
# ECharts Plugin
ECharts 图表插件,预置常用组件和图表类型。
## 导出
| 导出 | 类型 | 说明 |
| ------------ | ---- | ------------ |
| `default` | 对象 | echarts 实例 |
| `EchartsUI` | 组件 | 图表容器组件 |
| `ECOption` | 类型 | 图表配置类型 |
| `useEcharts` | 函数 | 组合式函数 |
## 使用
```ts
import { EchartsUI, useEcharts, ECOption } from '@vben/plugins/echarts';
```
## 类型
```ts
import type { ECOption } from '@vben/plugins/echarts';
```
## 预置组件
- TitleComponent
- TooltipComponent
- GridComponent
- LegendComponent
- ToolboxComponent
- DatasetComponent
- TransformComponent
## 预置图表
- BarChart
- LineChart
- PieChart
- RadarChart

View File

@ -27,7 +27,6 @@ import {
RadarChart,
} from 'echarts/charts';
import {
// 数据集组件
DatasetComponent,
DataZoomComponent,
DataZoomInsideComponent,
@ -38,7 +37,6 @@ import {
TitleComponent,
ToolboxComponent,
TooltipComponent,
// 内置数据转换器组件 (filter, sort)
TransformComponent,
VisualMapComponent,
} from 'echarts/components';
@ -91,5 +89,6 @@ echarts.use([
MapChart,
GeoComponent,
]);
export type { ECOption } from './types';
export default echarts;

View File

@ -1,3 +1,4 @@
export * from './echarts';
export { default as EchartsUI } from './echarts-ui.vue';
export * from './types';
export * from './use-echarts';

View File

@ -0,0 +1,28 @@
import type {
BarSeriesOption,
LineSeriesOption,
PieSeriesOption,
RadarSeriesOption,
} from 'echarts/charts';
import type {
DatasetComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
ToolboxComponentOption,
TooltipComponentOption,
} from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
export type ECOption = ComposeOption<
| BarSeriesOption
| DatasetComponentOption
| GridComponentOption
| LegendComponentOption
| LineSeriesOption
| PieSeriesOption
| RadarSeriesOption
| TitleComponentOption
| ToolboxComponentOption
| TooltipComponentOption
>;

View File

@ -0,0 +1,2 @@
export * from './plugins-context';
export * from './types';

View File

@ -0,0 +1,26 @@
# Motion Plugin
基于 @vueuse/motion 的动画插件。
## 导出
| 导出 | 类型 | 说明 |
| ----------------- | ---- | ---------- |
| `Motion` | 组件 | 动画组件 |
| `MotionGroup` | 组件 | 动画组组件 |
| `MotionDirective` | 指令 | 动画指令 |
| `MotionPlugin` | 插件 | Vue 插件 |
## 使用
```ts
import { MotionPlugin, Motion, MotionDirective } from '@vben/plugins/motion';
app.use(MotionPlugin);
```
## 类型
```ts
import type { MotionOptions, MotionVariants } from '@vben/plugins/motion';
```

View File

@ -0,0 +1,39 @@
import type { VbenPluginsOptions } from './types';
let globalPluginsOptions: null | VbenPluginsOptions = null;
export function providePluginsOptions(options: VbenPluginsOptions) {
if (!globalPluginsOptions) {
globalPluginsOptions = options;
return;
}
globalPluginsOptions = {
...globalPluginsOptions,
...options,
form:
globalPluginsOptions.form && options.form
? { ...globalPluginsOptions.form, ...options.form }
: globalPluginsOptions.form || options.form,
modal:
globalPluginsOptions.modal && options.modal
? { ...globalPluginsOptions.modal, ...options.modal }
: globalPluginsOptions.modal || options.modal,
message:
globalPluginsOptions.message && options.message
? { ...globalPluginsOptions.message, ...options.message }
: globalPluginsOptions.message || options.message,
components: {
...globalPluginsOptions.components,
...options.components,
},
};
}
export function injectPluginsOptions() {
return globalPluginsOptions;
}
export function resetPluginsOptions() {
globalPluginsOptions = null;
}

View File

@ -0,0 +1,55 @@
import type { Extensions } from '@tiptap/vue-3';
import type { VbenTiptapExtensionOptions } from './types';
import { $t } from '@vben/locales';
import Document from '@tiptap/extension-document';
import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { Color, TextStyle } from '@tiptap/extension-text-style';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
export function createDefaultTiptapExtensions(
options: VbenTiptapExtensionOptions = {},
): Extensions {
return [
Document,
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
TextStyle,
Color.configure({
types: ['textStyle'],
}),
Highlight.configure({
multicolor: true,
}),
Link.configure({
autolink: true,
defaultProtocol: 'https',
enableClickSelection: true,
openOnClick: false,
protocols: ['mailto', { optionalSlashes: true, scheme: 'tel' }],
}),
Image.configure({
allowBase64: true,
HTMLAttributes: {
class: 'vben-tiptap__image',
},
}),
Placeholder.configure({
placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
}),
];
}

View File

@ -0,0 +1,4 @@
export { default as VbenTiptapPreview } from './preview.vue';
export { default as VbenTiptap } from './tiptap.vue';
export * from './types';

Some files were not shown because too many files have changed in this diff Show More