Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin
commit
a653e428f3
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ yarn.lock
|
|||
package-lock.json
|
||||
.VSCodeCounter
|
||||
**/backend-mock/data
|
||||
|
||||
.omx
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %>';
|
||||
|
|
|
|||
|
|
@ -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>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
// 生产环境下注入百度统计
|
||||
|
|
|
|||
|
|
@ -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>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>';
|
||||
|
|
|
|||
|
|
@ -3,9 +3,29 @@
|
|||
* 可用于 vben-form、vben-modal、vben-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>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
// 生产环境下注入百度统计
|
||||
|
|
|
|||
|
|
@ -3,9 +3,29 @@
|
|||
* 可用于 vben-form、vben-modal、vben-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>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
// 生产环境下注入百度统计
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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` 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 查看框架默认配置
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["node_modules", "src/__tests__"]
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type {
|
|||
NormalizedOutputOptions,
|
||||
OutputBundle,
|
||||
OutputChunk,
|
||||
} from 'rollup';
|
||||
} from 'rolldown';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
import { EOL } from 'node:os';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { defaultPreferences } from '../src/config';
|
||||
import { PreferenceManager } from '../src/preferences';
|
||||
import { isDarkTheme } from '../src/update-css-variables';
|
||||
|
||||
describe('preferences', () => {
|
||||
let preferenceManager: PreferenceManager;
|
||||
let PreferenceManager: typeof import('../src/preferences').PreferenceManager;
|
||||
let preferenceManager: InstanceType<
|
||||
typeof import('../src/preferences').PreferenceManager
|
||||
>;
|
||||
|
||||
// 模拟 window.matchMedia 方法
|
||||
vi.stubGlobal(
|
||||
|
|
@ -21,7 +23,36 @@ describe('preferences', () => {
|
|||
removeListener: vi.fn(), // Deprecated
|
||||
})),
|
||||
);
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
|
||||
vi.stubGlobal('sessionStorage', {
|
||||
clear: vi.fn(),
|
||||
getItem: vi.fn(() => null),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PreferenceManager } = await import('../src/preferences'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => null);
|
||||
vi.mocked(localStorage.removeItem).mockReset();
|
||||
vi.mocked(localStorage.setItem).mockReset();
|
||||
vi.mocked(sessionStorage.getItem).mockImplementation(() => null);
|
||||
vi.mocked(sessionStorage.removeItem).mockReset();
|
||||
vi.mocked(sessionStorage.setItem).mockReset();
|
||||
preferenceManager = new PreferenceManager();
|
||||
});
|
||||
|
||||
|
|
@ -214,7 +245,10 @@ describe('preferences', () => {
|
|||
},
|
||||
};
|
||||
|
||||
await preferenceManager.initPreferences(overrides);
|
||||
await preferenceManager.initPreferences({
|
||||
namespace: 'apply-updates',
|
||||
overrides,
|
||||
});
|
||||
|
||||
preferenceManager.updatePreferences({
|
||||
theme: { mode: 'light' },
|
||||
|
|
@ -222,6 +256,265 @@ describe('preferences', () => {
|
|||
|
||||
expect(preferenceManager.getPreferences().theme.mode).toBe('light');
|
||||
});
|
||||
|
||||
it('initializes custom preferences extension with default values', async () => {
|
||||
const extension = {
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableWorkbench',
|
||||
label: '启用工作台',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: '租户模式',
|
||||
options: [
|
||||
{ label: '单租户', value: 'single' },
|
||||
{ label: '多租户', value: 'multi' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
title: '业务偏好',
|
||||
} as const;
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension,
|
||||
namespace: 'custom-defaults',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
enableWorkbench: true,
|
||||
tenantMode: 'single',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose mutable custom preference baselines or extension schema', async () => {
|
||||
const extension = {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
title: '业务偏好',
|
||||
} as const;
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension,
|
||||
namespace: 'custom-readonly',
|
||||
});
|
||||
|
||||
const initialCustomPreferences =
|
||||
preferenceManager.getInitialCustomPreferences<{
|
||||
pageSize: number;
|
||||
}>() as { pageSize: number };
|
||||
const preferencesExtension = preferenceManager.getPreferencesExtension<{
|
||||
pageSize: number;
|
||||
}>() as {
|
||||
fields: Array<{ componentProps?: { max?: number }; label: string }>;
|
||||
};
|
||||
const [firstField] = preferencesExtension.fields;
|
||||
|
||||
initialCustomPreferences.pageSize = 8;
|
||||
expect(firstField).toBeDefined();
|
||||
expect(firstField?.componentProps).toBeDefined();
|
||||
|
||||
if (!firstField || !firstField.componentProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
firstField.label = '已修改';
|
||||
firstField.componentProps.max = 20;
|
||||
|
||||
expect(preferenceManager.getInitialCustomPreferences()).toEqual({
|
||||
pageSize: 4,
|
||||
});
|
||||
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
|
||||
});
|
||||
|
||||
it('updates and resets custom preferences correctly', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
defaultValue: 20,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
{
|
||||
component: 'input',
|
||||
defaultValue: '日报',
|
||||
key: 'reportTitle',
|
||||
label: '报表标题',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-reset',
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 50,
|
||||
reportTitle: '月报',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 50,
|
||||
reportTitle: '月报',
|
||||
});
|
||||
|
||||
preferenceManager.resetPreferences();
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 20,
|
||||
reportTitle: '日报',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid custom preferences updates', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableWorkbench',
|
||||
label: '启用工作台',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: '租户模式',
|
||||
options: [
|
||||
{ label: '单租户', value: 'single' },
|
||||
{ label: '多租户', value: 'multi' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-invalid',
|
||||
});
|
||||
|
||||
const originalCustomPreferences = preferenceManager.getCustomPreferences();
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
enableWorkbench: 'true' as unknown as boolean,
|
||||
tenantMode: 'unknown',
|
||||
unknownField: 'value',
|
||||
} as any);
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual(
|
||||
originalCustomPreferences,
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces custom number field min max and step constraints', async () => {
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-number-constraints',
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 1,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
|
||||
preferenceManager.updateCustomPreferences({
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters cached custom number values that violate field constraints', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation((key) => {
|
||||
if (key.endsWith('cache-preferences-custom')) {
|
||||
return JSON.stringify({
|
||||
value: {
|
||||
pageSize: 5,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await preferenceManager.initPreferences({
|
||||
extension: {
|
||||
fields: [
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 10,
|
||||
min: 2,
|
||||
step: 2,
|
||||
},
|
||||
defaultValue: 4,
|
||||
key: 'pageSize',
|
||||
label: '分页大小',
|
||||
},
|
||||
],
|
||||
tabLabel: '扩展',
|
||||
},
|
||||
namespace: 'custom-number-cache',
|
||||
});
|
||||
|
||||
expect(preferenceManager.getCustomPreferences()).toEqual({
|
||||
pageSize: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkTheme', () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { preferencesManager } from './preferences';
|
|||
|
||||
export const {
|
||||
getPreferences,
|
||||
getCustomPreferences,
|
||||
getInitialCustomPreferences,
|
||||
getPreferencesExtension,
|
||||
updatePreferences,
|
||||
updateCustomPreferences,
|
||||
resetPreferences,
|
||||
clearCache,
|
||||
initPreferences,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
import type { InitialOptions, Preferences } from './types';
|
||||
import type {
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesRecord,
|
||||
InitialOptions,
|
||||
Preferences,
|
||||
PreferencesExtension,
|
||||
} from './types';
|
||||
|
||||
import { markRaw, reactive, readonly, watch } from 'vue';
|
||||
|
||||
|
|
@ -17,6 +23,7 @@ import { defaultPreferences } from './config';
|
|||
import { updateCSSVariables } from './update-css-variables';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CUSTOM: 'preferences-custom',
|
||||
MAIN: 'preferences',
|
||||
LOCALE: 'preferences-locale',
|
||||
THEME: 'preferences-theme',
|
||||
|
|
@ -24,7 +31,10 @@ const STORAGE_KEYS = {
|
|||
|
||||
class PreferenceManager {
|
||||
private cache: StorageManager;
|
||||
private debouncedSave: (preference: Preferences) => void;
|
||||
private customPreferencesExtension: null | PreferencesExtension<any> = null;
|
||||
private customState = reactive<CustomPreferencesRecord>({});
|
||||
private debouncedSave: () => void;
|
||||
private initialCustomPreferences: CustomPreferencesRecord = {};
|
||||
private initialPreferences: Preferences = defaultPreferences;
|
||||
private isInitialized = false;
|
||||
private state: Preferences;
|
||||
|
|
@ -34,10 +44,7 @@ class PreferenceManager {
|
|||
this.state = reactive<Preferences>(
|
||||
this.loadFromCache() || { ...defaultPreferences },
|
||||
);
|
||||
this.debouncedSave = useDebounceFn(
|
||||
(preference) => this.saveToCache(preference),
|
||||
150,
|
||||
);
|
||||
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,6 +54,26 @@ class PreferenceManager {
|
|||
Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取扩展偏好设置
|
||||
*/
|
||||
getCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return readonly(this.customState) as Readonly<TCustomPreferences>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取初始化扩展偏好设置
|
||||
*/
|
||||
getInitialCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return this.cloneValue(
|
||||
this.initialCustomPreferences,
|
||||
) as Readonly<TCustomPreferences>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取初始化偏好设置
|
||||
*/
|
||||
|
|
@ -61,13 +88,32 @@ class PreferenceManager {
|
|||
return readonly(this.state);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取扩展偏好设置配置
|
||||
*/
|
||||
getPreferencesExtension = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>() => {
|
||||
return this.customPreferencesExtension
|
||||
? (this.cloneValue(this.customPreferencesExtension) as Readonly<
|
||||
PreferencesExtension<TCustomPreferences>
|
||||
>)
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化偏好设置
|
||||
* @param options - 初始化配置项
|
||||
* @param options.namespace - 命名空间,用于隔离不同应用的配置
|
||||
* @param options.overrides - 要覆盖的偏好设置
|
||||
*/
|
||||
initPreferences = async ({ namespace, overrides }: InitialOptions) => {
|
||||
initPreferences = async <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>({
|
||||
namespace,
|
||||
overrides,
|
||||
extension,
|
||||
}: InitialOptions<TCustomPreferences>) => {
|
||||
// 防止重复初始化
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
|
|
@ -78,6 +124,10 @@ class PreferenceManager {
|
|||
|
||||
// 合并初始偏好设置
|
||||
this.initialPreferences = merge({}, overrides, defaultPreferences);
|
||||
this.customPreferencesExtension = extension ?? null;
|
||||
this.initialCustomPreferences = this.resolveCustomPreferencesDefaults(
|
||||
this.customPreferencesExtension,
|
||||
);
|
||||
|
||||
// 加载缓存的偏好设置并与初始配置合并
|
||||
const cachedPreferences = this.loadFromCache() || {};
|
||||
|
|
@ -89,6 +139,14 @@ class PreferenceManager {
|
|||
|
||||
// 更新偏好设置
|
||||
this.updatePreferences(mergedPreference);
|
||||
this.replaceCustomPreferences(
|
||||
merge(
|
||||
{},
|
||||
this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}),
|
||||
this.initialCustomPreferences,
|
||||
),
|
||||
);
|
||||
this.saveToCache();
|
||||
|
||||
// 设置监听器
|
||||
this.setupWatcher();
|
||||
|
|
@ -105,14 +163,42 @@ class PreferenceManager {
|
|||
resetPreferences = () => {
|
||||
// 将状态重置为初始偏好设置
|
||||
Object.assign(this.state, this.initialPreferences);
|
||||
this.replaceCustomPreferences(this.initialCustomPreferences);
|
||||
|
||||
// 保存偏好设置至缓存
|
||||
this.saveToCache(this.state);
|
||||
this.saveToCache();
|
||||
|
||||
// 直接触发 UI 更新
|
||||
this.handleUpdates(this.state);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新扩展偏好设置
|
||||
* @param updates - 要更新的扩展偏好设置
|
||||
*/
|
||||
updateCustomPreferences = <
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
>(
|
||||
updates: DeepPartial<TCustomPreferences>,
|
||||
) => {
|
||||
if (!this.customPreferencesExtension) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedUpdates = this.sanitizeCustomPreferences(
|
||||
updates as DeepPartial<CustomPreferencesRecord>,
|
||||
);
|
||||
|
||||
if (Object.keys(sanitizedUpdates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.replaceCustomPreferences(
|
||||
merge({}, sanitizedUpdates, markRaw(this.customState)),
|
||||
);
|
||||
this.debouncedSave();
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新偏好设置
|
||||
* @param updates - 要更新的偏好设置
|
||||
|
|
@ -126,9 +212,25 @@ class PreferenceManager {
|
|||
this.handleUpdates(updates);
|
||||
|
||||
// 保存到缓存
|
||||
this.debouncedSave(this.state);
|
||||
this.debouncedSave();
|
||||
};
|
||||
|
||||
private cloneValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => this.cloneValue(item)) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(
|
||||
([key, nestedValue]) => [key, this.cloneValue(nestedValue)],
|
||||
),
|
||||
) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理更新
|
||||
* @param updates - 更新的偏好设置
|
||||
|
|
@ -158,6 +260,70 @@ class PreferenceManager {
|
|||
document.documentElement.dataset.platform = isMacOs() ? 'macOs' : 'window';
|
||||
}
|
||||
|
||||
private isAlmostInteger(value: number, epsilon = Number.EPSILON * 10) {
|
||||
return Math.abs(value - Math.round(value)) < epsilon;
|
||||
}
|
||||
|
||||
private isValidCustomPreferenceValue(
|
||||
field: CustomPreferencesField,
|
||||
value: unknown,
|
||||
) {
|
||||
switch (field.component) {
|
||||
case 'number': {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const max = this.resolveNumericConstraint(field.componentProps?.max);
|
||||
const min = this.resolveNumericConstraint(field.componentProps?.min);
|
||||
const step = this.resolveNumericConstraint(field.componentProps?.step);
|
||||
|
||||
if (min !== undefined && value < min) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (step !== undefined) {
|
||||
if (step <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stepBase = min ?? 0;
|
||||
const stepCount = (value - stepBase) / step;
|
||||
|
||||
if (!this.isAlmostInteger(stepCount)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case 'select': {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
field.options.some((option) => option.value === value)
|
||||
);
|
||||
}
|
||||
case 'switch': {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
default: {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存加载扩展偏好设置
|
||||
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
|
||||
*/
|
||||
private loadCustomFromCache(): CustomPreferencesRecord | null {
|
||||
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存加载偏好设置
|
||||
* @returns 缓存的偏好设置,如果不存在则返回 null
|
||||
|
|
@ -166,14 +332,72 @@ class PreferenceManager {
|
|||
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
|
||||
}
|
||||
|
||||
private replaceCustomPreferences(preferences: CustomPreferencesRecord) {
|
||||
Object.keys(this.customState).forEach((key) => {
|
||||
Reflect.deleteProperty(this.customState, key);
|
||||
});
|
||||
Object.assign(this.customState, preferences);
|
||||
}
|
||||
|
||||
private resolveCustomPreferencesDefaults(
|
||||
extension: null | PreferencesExtension<any>,
|
||||
) {
|
||||
if (!extension) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: CustomPreferencesRecord = {};
|
||||
|
||||
for (const field of extension.fields) {
|
||||
result[field.key] = field.defaultValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveNumericConstraint(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private sanitizeCustomPreferences(
|
||||
updates: DeepPartial<CustomPreferencesRecord>,
|
||||
) {
|
||||
if (!this.customPreferencesExtension) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: CustomPreferencesRecord = {};
|
||||
|
||||
for (const field of this.customPreferencesExtension.fields) {
|
||||
const value = updates[field.key];
|
||||
|
||||
if (
|
||||
value !== undefined &&
|
||||
this.isValidCustomPreferenceValue(field, value)
|
||||
) {
|
||||
result[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存偏好设置到缓存
|
||||
* @param preference - 要保存的偏好设置
|
||||
*/
|
||||
private saveToCache(preference: Preferences) {
|
||||
this.cache.setItem(STORAGE_KEYS.MAIN, preference);
|
||||
this.cache.setItem(STORAGE_KEYS.LOCALE, preference.app.locale);
|
||||
this.cache.setItem(STORAGE_KEYS.THEME, preference.theme.mode);
|
||||
private saveToCache() {
|
||||
this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
|
||||
this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
|
||||
this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
|
||||
|
||||
if (this.customPreferencesExtension) {
|
||||
this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState });
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.removeItem(STORAGE_KEYS.CUSTOM);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -17,6 +17,84 @@ import type {
|
|||
} from '@vben-core/typings';
|
||||
|
||||
type SupportedLanguagesType = 'en-US' | 'zh-CN';
|
||||
type CustomPreferencesValue = boolean | number | string;
|
||||
|
||||
interface CustomPreferencesOption<TValue extends string = string> {
|
||||
label: string;
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
interface BaseCustomPreferencesField<
|
||||
TKey extends string = string,
|
||||
TValue extends CustomPreferencesValue = CustomPreferencesValue,
|
||||
> {
|
||||
componentProps?: Record<string, any>;
|
||||
defaultValue: TValue;
|
||||
disabled?: boolean;
|
||||
key: TKey;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
interface CustomPreferencesInputField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, string> {
|
||||
component: 'input';
|
||||
}
|
||||
|
||||
interface CustomPreferencesNumberField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, number> {
|
||||
component: 'number';
|
||||
}
|
||||
|
||||
interface CustomPreferencesSelectField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, string> {
|
||||
component: 'select';
|
||||
options: CustomPreferencesOption[];
|
||||
}
|
||||
|
||||
interface CustomPreferencesSwitchField<
|
||||
TKey extends string = string,
|
||||
> extends BaseCustomPreferencesField<TKey, boolean> {
|
||||
component: 'switch';
|
||||
}
|
||||
|
||||
type CustomPreferencesRecord = Record<string, CustomPreferencesValue>;
|
||||
|
||||
type AnyCustomPreferencesField =
|
||||
| CustomPreferencesInputField
|
||||
| CustomPreferencesNumberField
|
||||
| CustomPreferencesSelectField
|
||||
| CustomPreferencesSwitchField;
|
||||
|
||||
type CustomPreferencesField<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> =
|
||||
string extends Extract<keyof TCustomPreferences, string>
|
||||
? AnyCustomPreferencesField
|
||||
: {
|
||||
[K in Extract<
|
||||
keyof TCustomPreferences,
|
||||
string
|
||||
>]: TCustomPreferences[K] extends boolean
|
||||
? CustomPreferencesSwitchField<K>
|
||||
: TCustomPreferences[K] extends number
|
||||
? CustomPreferencesNumberField<K>
|
||||
: TCustomPreferences[K] extends string
|
||||
? CustomPreferencesInputField<K> | CustomPreferencesSelectField<K>
|
||||
: never;
|
||||
}[Extract<keyof TCustomPreferences, string>];
|
||||
|
||||
interface PreferencesExtension<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> {
|
||||
fields: Array<CustomPreferencesField<TCustomPreferences>>;
|
||||
tabLabel: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface AppPreferences {
|
||||
/** 权限模式 */
|
||||
|
|
@ -324,19 +402,33 @@ interface Preferences {
|
|||
|
||||
type PreferencesKeys = keyof Preferences;
|
||||
|
||||
interface InitialOptions {
|
||||
interface InitialOptions<
|
||||
TCustomPreferences extends object = CustomPreferencesRecord,
|
||||
> {
|
||||
extension?: PreferencesExtension<TCustomPreferences>;
|
||||
namespace: string;
|
||||
overrides?: DeepPartial<Preferences>;
|
||||
}
|
||||
export type {
|
||||
AnyCustomPreferencesField,
|
||||
AppPreferences,
|
||||
BaseCustomPreferencesField,
|
||||
BreadcrumbPreferences,
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesInputField,
|
||||
CustomPreferencesNumberField,
|
||||
CustomPreferencesOption,
|
||||
CustomPreferencesRecord,
|
||||
CustomPreferencesSelectField,
|
||||
CustomPreferencesSwitchField,
|
||||
CustomPreferencesValue,
|
||||
FooterPreferences,
|
||||
HeaderPreferences,
|
||||
InitialOptions,
|
||||
LogoPreferences,
|
||||
NavigationPreferences,
|
||||
Preferences,
|
||||
PreferencesExtension,
|
||||
PreferencesKeys,
|
||||
ShortcutKeyPreferences,
|
||||
SidebarPreferences,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import { isDarkTheme } from './update-css-variables';
|
|||
|
||||
function usePreferences() {
|
||||
const preferences = preferencesManager.getPreferences();
|
||||
const customPreferences = preferencesManager.getCustomPreferences();
|
||||
const initialPreferences = preferencesManager.getInitialPreferences();
|
||||
const initialCustomPreferences =
|
||||
preferencesManager.getInitialCustomPreferences();
|
||||
const preferencesExtension = computed(() =>
|
||||
preferencesManager.getPreferencesExtension(),
|
||||
);
|
||||
/**
|
||||
* @zh_CN 计算偏好设置的变化
|
||||
*/
|
||||
|
|
@ -15,6 +21,10 @@ function usePreferences() {
|
|||
return diff(initialPreferences, preferences);
|
||||
});
|
||||
|
||||
const diffCustomPreference = computed(() => {
|
||||
return diff(initialCustomPreferences, customPreferences);
|
||||
});
|
||||
|
||||
const appPreferences = computed(() => preferences.app);
|
||||
|
||||
const shortcutKeysPreferences = computed(() => preferences.shortcutKeys);
|
||||
|
|
@ -228,7 +238,9 @@ function usePreferences() {
|
|||
authPanelLeft,
|
||||
authPanelRight,
|
||||
contentIsMaximize,
|
||||
customPreferences,
|
||||
diffPreference,
|
||||
diffCustomPreference,
|
||||
globalLockScreenShortcutKey,
|
||||
globalLogoutShortcutKey,
|
||||
globalSearchShortcutKey,
|
||||
|
|
@ -245,6 +257,7 @@ function usePreferences() {
|
|||
keepAlive,
|
||||
layout,
|
||||
locale,
|
||||
preferencesExtension,
|
||||
preferencesButtonPosition,
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -51,5 +51,8 @@
|
|||
"vue": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-defaults": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
> {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -47,5 +47,8 @@
|
|||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qs": "catalog:"
|
||||
"@types/qs": "catalog:",
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,5 +47,8 @@
|
|||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export interface DrawerProps {
|
|||
*/
|
||||
headerClass?: ClassType;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* 抽屉加载状态
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export class ModalApi {
|
|||
contentClass: '',
|
||||
destroyOnClose: true,
|
||||
draggable: false,
|
||||
overflow: false,
|
||||
footer: true,
|
||||
footerClass: '',
|
||||
fullscreen: false,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -47,5 +47,8 @@
|
|||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"unplugin-vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/json-bigint": "catalog:",
|
||||
"@types/qrcode": "catalog:"
|
||||
"@types/qrcode": "catalog:",
|
||||
"@vue/test-utils": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
export { default as ApiComponent } from './api-component.vue';
|
||||
export type {
|
||||
ApiComponentLabelFn,
|
||||
ApiComponentOptionsItem,
|
||||
ApiComponentProps,
|
||||
ApiComponentSharedProps,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -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'>;
|
||||
|
|
@ -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: () => [],
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { default as IconPicker } from './icon-picker.vue';
|
||||
export type { IconPickerProps } from './types';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
<script setup lang="ts">
|
||||
import type {
|
||||
CustomPreferencesField,
|
||||
CustomPreferencesRecord,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import InputItem from '../input-item.vue';
|
||||
import NumberFieldItem from '../number-field-item.vue';
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceCustomFields',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
fields: Array<CustomPreferencesField>;
|
||||
values: CustomPreferencesRecord;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [updates: CustomPreferencesRecord];
|
||||
}>();
|
||||
|
||||
function handleUpdate(key: string, value: boolean | number | string) {
|
||||
emit('update', { [key]: value });
|
||||
}
|
||||
|
||||
function handleBooleanUpdate(key: string, value: boolean | undefined) {
|
||||
handleUpdate(key, value ?? false);
|
||||
}
|
||||
|
||||
function resolveNumberValue(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function handleNumberUpdate(key: string, value: number | undefined) {
|
||||
const resolvedValue = resolveNumberValue(value);
|
||||
|
||||
if (resolvedValue !== undefined) {
|
||||
handleUpdate(key, resolvedValue);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStringUpdate(key: string, value: string | undefined) {
|
||||
handleUpdate(key, value ?? '');
|
||||
}
|
||||
|
||||
const resolvedFields = computed(() => {
|
||||
return props.fields.map((field) => {
|
||||
return {
|
||||
...field,
|
||||
label: $t(field.label),
|
||||
options:
|
||||
field.component === 'select'
|
||||
? field.options.map((option) => ({
|
||||
...option,
|
||||
label: $t(option.label),
|
||||
}))
|
||||
: undefined,
|
||||
placeholder: field.placeholder ? $t(field.placeholder) : '',
|
||||
tip: field.tip ? $t(field.tip) : '',
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="field in resolvedFields" :key="field.key">
|
||||
<SwitchItem
|
||||
v-if="field.component === 'switch'"
|
||||
:disabled="field.disabled"
|
||||
:model-value="Boolean(values[field.key])"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleBooleanUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</SwitchItem>
|
||||
<NumberFieldItem
|
||||
v-else-if="field.component === 'number'"
|
||||
:disabled="field.disabled"
|
||||
:model-value="resolveNumberValue(values[field.key])"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleNumberUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</NumberFieldItem>
|
||||
<SelectItem
|
||||
v-else-if="field.component === 'select'"
|
||||
:disabled="field.disabled"
|
||||
:items="field.options"
|
||||
:model-value="String(values[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleStringUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
<InputItem
|
||||
v-else
|
||||
:disabled="field.disabled"
|
||||
:model-value="String(values[field.key] ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
:tip="field.tip"
|
||||
v-bind="field.componentProps"
|
||||
@update:model-value="handleStringUpdate(field.key, $event)"
|
||||
>
|
||||
{{ field.label }}
|
||||
</InputItem>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as Block } from './block.vue';
|
||||
export { default as Custom } from './custom/custom.vue';
|
||||
export { default as Animation } from './general/animation.vue';
|
||||
export { default as General } from './general/general.vue';
|
||||
export { default as Breadcrumb } from './layout/breadcrumb.vue';
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ withDefaults(
|
|||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
tip: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
|
|
@ -32,7 +34,7 @@ const slots = useSlots();
|
|||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'hover:bg-accent': !(slots.tip || tip),
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
|
|
@ -40,11 +42,17 @@ const slots = useSlots();
|
|||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<div class="relative">
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ withDefaults(
|
|||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
tip: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
|
|
@ -39,7 +41,7 @@ const slots = useSlots();
|
|||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'hover:bg-accent': !(slots.tip || tip),
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
|
|
@ -47,11 +49,17 @@ const slots = useSlots();
|
|||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<Select v-model="selectValue">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { SupportedLanguagesType } from '@vben/locales';
|
||||
import type { CustomPreferencesRecord } from '@vben/preferences';
|
||||
import type {
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
clearCache,
|
||||
preferences,
|
||||
resetPreferences,
|
||||
updateCustomPreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
|
|
@ -43,6 +45,7 @@ import {
|
|||
ColorMode,
|
||||
Content,
|
||||
Copyright,
|
||||
Custom,
|
||||
FontSize,
|
||||
Footer,
|
||||
General,
|
||||
|
|
@ -177,12 +180,15 @@ const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
|
|||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
|
||||
const {
|
||||
customPreferences,
|
||||
diffCustomPreference,
|
||||
diffPreference,
|
||||
isDark,
|
||||
isFullContent,
|
||||
isHeaderNav,
|
||||
isHeaderSidebarNav,
|
||||
isMixedNav,
|
||||
preferencesExtension,
|
||||
isSideMixedNav,
|
||||
isSideMode,
|
||||
isSideNav,
|
||||
|
|
@ -193,8 +199,42 @@ const [Drawer] = useVbenDrawer();
|
|||
|
||||
const activeTab = ref('appearance');
|
||||
|
||||
const customPreferencesTab = computed(() => {
|
||||
return preferencesExtension.value;
|
||||
});
|
||||
|
||||
const customTabLabel = computed(() => {
|
||||
return customPreferencesTab.value?.tabLabel
|
||||
? $t(customPreferencesTab.value.tabLabel)
|
||||
: '';
|
||||
});
|
||||
|
||||
const customTabTitle = computed(() => {
|
||||
const title =
|
||||
customPreferencesTab.value?.title || customPreferencesTab.value?.tabLabel;
|
||||
return title ? $t(title) : '';
|
||||
});
|
||||
|
||||
const mergedDiffPreference = computed(() => {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (diffPreference.value) {
|
||||
Object.assign(result, diffPreference.value);
|
||||
}
|
||||
|
||||
if (diffCustomPreference.value) {
|
||||
result.custom = diffCustomPreference.value;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
});
|
||||
|
||||
const showCustomTab = computed(() => {
|
||||
return (customPreferencesTab.value?.fields.length ?? 0) > 0;
|
||||
});
|
||||
|
||||
const tabs = computed((): SegmentedItem[] => {
|
||||
return [
|
||||
const items: SegmentedItem[] = [
|
||||
{
|
||||
label: $t('preferences.appearance'),
|
||||
value: 'appearance',
|
||||
|
|
@ -212,6 +252,15 @@ const tabs = computed((): SegmentedItem[] => {
|
|||
value: 'general',
|
||||
},
|
||||
];
|
||||
|
||||
if (showCustomTab.value) {
|
||||
items.push({
|
||||
label: customTabLabel.value,
|
||||
value: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const showBreadcrumbConfig = computed(() => {
|
||||
|
|
@ -224,7 +273,7 @@ const showBreadcrumbConfig = computed(() => {
|
|||
});
|
||||
|
||||
async function handleCopy() {
|
||||
await copy(JSON.stringify(diffPreference.value, null, 2));
|
||||
await copy(JSON.stringify(mergedDiffPreference.value, null, 2));
|
||||
|
||||
message.copyPreferencesSuccess?.(
|
||||
$t('preferences.copyPreferencesSuccessTitle'),
|
||||
|
|
@ -239,12 +288,16 @@ async function handleClearCache() {
|
|||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!diffPreference.value) {
|
||||
if (!mergedDiffPreference.value) {
|
||||
return;
|
||||
}
|
||||
resetPreferences();
|
||||
await loadLocaleMessages(preferences.app.locale);
|
||||
}
|
||||
|
||||
function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
|
||||
updateCustomPreferences(updates);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -257,13 +310,13 @@ async function handleReset() {
|
|||
<template #extra>
|
||||
<div class="flex items-center">
|
||||
<VbenIconButton
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
:tooltip="$t('preferences.resetTip')"
|
||||
class="relative"
|
||||
@click="handleReset"
|
||||
>
|
||||
<span
|
||||
v-if="diffPreference"
|
||||
v-if="mergedDiffPreference"
|
||||
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
|
||||
></span>
|
||||
<RotateCw class="size-4" />
|
||||
|
|
@ -466,13 +519,22 @@ async function handleReset() {
|
|||
/>
|
||||
</Block>
|
||||
</template>
|
||||
<template #custom>
|
||||
<Block :title="customTabTitle">
|
||||
<Custom
|
||||
:fields="customPreferencesTab?.fields || []"
|
||||
:values="customPreferences"
|
||||
@update="handleCustomPreferencesUpdate"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
</VbenSegmented>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VbenButton
|
||||
v-if="appEnableCopyPreferences"
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
class="mx-4 w-full"
|
||||
size="sm"
|
||||
variant="default"
|
||||
|
|
@ -482,7 +544,7 @@ async function handleReset() {
|
|||
{{ $t('preferences.copyPreferences') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
:disabled="!diffPreference"
|
||||
:disabled="!mergedDiffPreference"
|
||||
class="mr-4 w-full"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './echarts';
|
||||
export { default as EchartsUI } from './echarts-ui.vue';
|
||||
export * from './types';
|
||||
export * from './use-echarts';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './plugins-context';
|
||||
export * from './types';
|
||||
|
|
@ -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';
|
||||
```
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue