Browse Source
* feat: add form component * fix: build error * feat: add form adapter * feat: add some component * feat: add some component * feat: add example * feat: suppoer custom action button * chore: update * feat: add example * feat: add formModel,formDrawer demo * fix: build error * fix: typo * fix: ci error --------- Co-authored-by: jinmao <jinmao88@qq.com> Co-authored-by: likui628 <90845831+likui628@users.noreply.github.com>pull/4353/head
committed by
GitHub
271 changed files with 5974 additions and 1247 deletions
@ -0,0 +1,114 @@ |
|||||
|
import type { |
||||
|
BaseFormComponentType, |
||||
|
VbenFormSchema as FormSchema, |
||||
|
VbenFormProps, |
||||
|
} from '@vben/common-ui'; |
||||
|
|
||||
|
import { h } from 'vue'; |
||||
|
|
||||
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
|
import { |
||||
|
AutoComplete, |
||||
|
Button, |
||||
|
Checkbox, |
||||
|
CheckboxGroup, |
||||
|
DatePicker, |
||||
|
Divider, |
||||
|
Input, |
||||
|
InputNumber, |
||||
|
InputPassword, |
||||
|
Mentions, |
||||
|
Radio, |
||||
|
RadioGroup, |
||||
|
RangePicker, |
||||
|
Rate, |
||||
|
Select, |
||||
|
Space, |
||||
|
Switch, |
||||
|
TimePicker, |
||||
|
TreeSelect, |
||||
|
Upload, |
||||
|
} from 'ant-design-vue'; |
||||
|
|
||||
|
// 业务表单组件适配
|
||||
|
|
||||
|
export type FormComponentType = |
||||
|
| 'AutoComplete' |
||||
|
| 'Checkbox' |
||||
|
| 'CheckboxGroup' |
||||
|
| 'DatePicker' |
||||
|
| 'Divider' |
||||
|
| 'Input' |
||||
|
| 'InputNumber' |
||||
|
| 'InputPassword' |
||||
|
| 'Mentions' |
||||
|
| 'Radio' |
||||
|
| 'RadioGroup' |
||||
|
| 'RangePicker' |
||||
|
| 'Rate' |
||||
|
| 'Select' |
||||
|
| 'Space' |
||||
|
| 'Switch' |
||||
|
| 'TimePicker' |
||||
|
| 'TreeSelect' |
||||
|
| 'Upload' |
||||
|
| BaseFormComponentType; |
||||
|
|
||||
|
// 初始化表单组件,并注册到form组件内部
|
||||
|
setupVbenForm<FormComponentType>({ |
||||
|
components: { |
||||
|
AutoComplete, |
||||
|
Checkbox, |
||||
|
CheckboxGroup, |
||||
|
DatePicker, |
||||
|
// 自定义默认的重置按钮
|
||||
|
DefaultResetActionButton: (props, { attrs, slots }) => { |
||||
|
return h(Button, { ...props, attrs, type: 'default' }, slots); |
||||
|
}, |
||||
|
// 自定义默认的提交按钮
|
||||
|
DefaultSubmitActionButton: (props, { attrs, slots }) => { |
||||
|
return h(Button, { ...props, attrs, type: 'primary' }, slots); |
||||
|
}, |
||||
|
Divider, |
||||
|
Input, |
||||
|
InputNumber, |
||||
|
InputPassword, |
||||
|
Mentions, |
||||
|
Radio, |
||||
|
RadioGroup, |
||||
|
RangePicker, |
||||
|
Rate, |
||||
|
Select, |
||||
|
Space, |
||||
|
Switch, |
||||
|
TimePicker, |
||||
|
TreeSelect, |
||||
|
Upload, |
||||
|
}, |
||||
|
config: { |
||||
|
baseModelPropName: 'value', |
||||
|
modelPropNameMap: { |
||||
|
Checkbox: 'checked', |
||||
|
Radio: 'checked', |
||||
|
Switch: 'checked', |
||||
|
Upload: 'fileList', |
||||
|
}, |
||||
|
}, |
||||
|
defineRules: { |
||||
|
required: (value, _params, ctx) => { |
||||
|
if ((!value && value !== 0) || value.length === 0) { |
||||
|
return $t('formRules.required', [ctx.label]); |
||||
|
} |
||||
|
return true; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const useVbenForm = useForm<FormComponentType>; |
||||
|
|
||||
|
export { useVbenForm, z }; |
||||
|
|
||||
|
export type VbenFormSchema = FormSchema<FormComponentType>; |
||||
|
export type { VbenFormProps }; |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './form'; |
||||
@ -1,18 +1,91 @@ |
|||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||
import { AuthenticationLogin } from '@vben/common-ui'; |
import type { VbenFormSchema } from '@vben/common-ui'; |
||||
|
import type { BasicOption } from '@vben/types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { AuthenticationLogin, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
import { useAuthStore } from '#/store'; |
import { useAuthStore } from '#/store'; |
||||
|
|
||||
defineOptions({ name: 'Login' }); |
defineOptions({ name: 'Login' }); |
||||
|
|
||||
const authStore = useAuthStore(); |
const authStore = useAuthStore(); |
||||
|
|
||||
|
const MOCK_USER_OPTIONS: BasicOption[] = [ |
||||
|
{ |
||||
|
label: '超级管理员', |
||||
|
value: 'vben', |
||||
|
}, |
||||
|
{ |
||||
|
label: '管理员', |
||||
|
value: 'admin', |
||||
|
}, |
||||
|
{ |
||||
|
label: '用户', |
||||
|
value: 'jack', |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const formSchema = computed((): VbenFormSchema[] => { |
||||
|
return [ |
||||
|
{ |
||||
|
component: 'VbenSelect', |
||||
|
componentProps: { |
||||
|
options: MOCK_USER_OPTIONS, |
||||
|
placeholder: $t('authentication.selectAccount'), |
||||
|
}, |
||||
|
fieldName: 'selectAccount', |
||||
|
label: $t('authentication.selectAccount'), |
||||
|
rules: z |
||||
|
.string() |
||||
|
.min(1, { message: $t('authentication.selectAccount') }) |
||||
|
.optional() |
||||
|
.default('vben'), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInput', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.usernameTip'), |
||||
|
}, |
||||
|
dependencies: { |
||||
|
trigger(values, form) { |
||||
|
if (values.selectAccount) { |
||||
|
const findUser = MOCK_USER_OPTIONS.find( |
||||
|
(item) => item.value === values.selectAccount, |
||||
|
); |
||||
|
if (findUser) { |
||||
|
form.setValues({ |
||||
|
password: '123456', |
||||
|
username: findUser.value, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
triggerFields: ['selectAccount'], |
||||
|
}, |
||||
|
fieldName: 'username', |
||||
|
label: $t('authentication.username'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.usernameTip') }), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInputPassword', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.password'), |
||||
|
}, |
||||
|
fieldName: 'password', |
||||
|
label: $t('authentication.password'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.passwordTip') }), |
||||
|
}, |
||||
|
]; |
||||
|
}); |
||||
</script> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<AuthenticationLogin |
<AuthenticationLogin |
||||
|
:form-schema="formSchema" |
||||
:loading="authStore.loginLoading" |
:loading="authStore.loginLoading" |
||||
password-placeholder="123456" |
|
||||
username-placeholder="vben" |
|
||||
@submit="authStore.authLogin" |
@submit="authStore.authLogin" |
||||
/> |
/> |
||||
</template> |
</template> |
||||
|
|||||
@ -0,0 +1,89 @@ |
|||||
|
import type { |
||||
|
BaseFormComponentType, |
||||
|
VbenFormSchema as FormSchema, |
||||
|
VbenFormProps, |
||||
|
} from '@vben/common-ui'; |
||||
|
|
||||
|
import { h } from 'vue'; |
||||
|
|
||||
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
|
import { |
||||
|
ElButton, |
||||
|
ElCheckbox, |
||||
|
ElCheckboxGroup, |
||||
|
ElDivider, |
||||
|
ElInput, |
||||
|
ElInputNumber, |
||||
|
ElRadioGroup, |
||||
|
ElSelect, |
||||
|
ElSpace, |
||||
|
ElSwitch, |
||||
|
ElTimePicker, |
||||
|
ElTreeSelect, |
||||
|
ElUpload, |
||||
|
} from 'element-plus'; |
||||
|
// 业务表单组件适配
|
||||
|
|
||||
|
export type FormComponentType = |
||||
|
| 'Checkbox' |
||||
|
| 'CheckboxGroup' |
||||
|
| 'DatePicker' |
||||
|
| 'Divider' |
||||
|
| 'Input' |
||||
|
| 'InputNumber' |
||||
|
| 'RadioGroup' |
||||
|
| 'Select' |
||||
|
| 'Space' |
||||
|
| 'Switch' |
||||
|
| 'TimePicker' |
||||
|
| 'TreeSelect' |
||||
|
| 'Upload' |
||||
|
| BaseFormComponentType; |
||||
|
|
||||
|
// 初始化表单组件,并注册到form组件内部
|
||||
|
setupVbenForm<FormComponentType>({ |
||||
|
components: { |
||||
|
Checkbox: ElCheckbox, |
||||
|
CheckboxGroup: ElCheckboxGroup, |
||||
|
// 自定义默认的重置按钮
|
||||
|
DefaultResetActionButton: (props, { attrs, slots }) => { |
||||
|
return h(ElButton, { ...props, attrs, type: 'info' }, slots); |
||||
|
}, |
||||
|
// 自定义默认的提交按钮
|
||||
|
DefaultSubmitActionButton: (props, { attrs, slots }) => { |
||||
|
return h(ElButton, { ...props, attrs, type: 'primary' }, slots); |
||||
|
}, |
||||
|
Divider: ElDivider, |
||||
|
Input: ElInput, |
||||
|
InputNumber: ElInputNumber, |
||||
|
RadioGroup: ElRadioGroup, |
||||
|
Select: ElSelect, |
||||
|
Space: ElSpace, |
||||
|
Switch: ElSwitch, |
||||
|
TimePicker: ElTimePicker, |
||||
|
TreeSelect: ElTreeSelect, |
||||
|
Upload: ElUpload, |
||||
|
}, |
||||
|
config: { |
||||
|
modelPropNameMap: { |
||||
|
Upload: 'fileList', |
||||
|
}, |
||||
|
}, |
||||
|
defineRules: { |
||||
|
required: (value, _params, ctx) => { |
||||
|
if ((!value && value !== 0) || value.length === 0) { |
||||
|
return $t('formRules.required', [ctx.label]); |
||||
|
} |
||||
|
return true; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const useVbenForm = useForm<FormComponentType>; |
||||
|
|
||||
|
export { useVbenForm, z }; |
||||
|
|
||||
|
export type VbenFormSchema = FormSchema<FormComponentType>; |
||||
|
export type { VbenFormProps }; |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './form'; |
||||
@ -1,18 +1,91 @@ |
|||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||
import { AuthenticationLogin } from '@vben/common-ui'; |
import type { VbenFormSchema } from '@vben/common-ui'; |
||||
|
import type { BasicOption } from '@vben/types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { AuthenticationLogin, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
import { useAuthStore } from '#/store'; |
import { useAuthStore } from '#/store'; |
||||
|
|
||||
defineOptions({ name: 'Login' }); |
defineOptions({ name: 'Login' }); |
||||
|
|
||||
const authStore = useAuthStore(); |
const authStore = useAuthStore(); |
||||
|
|
||||
|
const MOCK_USER_OPTIONS: BasicOption[] = [ |
||||
|
{ |
||||
|
label: '超级管理员', |
||||
|
value: 'vben', |
||||
|
}, |
||||
|
{ |
||||
|
label: '管理员', |
||||
|
value: 'admin', |
||||
|
}, |
||||
|
{ |
||||
|
label: '用户', |
||||
|
value: 'jack', |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const formSchema = computed((): VbenFormSchema[] => { |
||||
|
return [ |
||||
|
{ |
||||
|
component: 'VbenSelect', |
||||
|
componentProps: { |
||||
|
options: MOCK_USER_OPTIONS, |
||||
|
placeholder: $t('authentication.selectAccount'), |
||||
|
}, |
||||
|
fieldName: 'selectAccount', |
||||
|
label: $t('authentication.selectAccount'), |
||||
|
rules: z |
||||
|
.string() |
||||
|
.min(1, { message: $t('authentication.selectAccount') }) |
||||
|
.optional() |
||||
|
.default('vben'), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInput', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.usernameTip'), |
||||
|
}, |
||||
|
dependencies: { |
||||
|
trigger(values, form) { |
||||
|
if (values.selectAccount) { |
||||
|
const findUser = MOCK_USER_OPTIONS.find( |
||||
|
(item) => item.value === values.selectAccount, |
||||
|
); |
||||
|
if (findUser) { |
||||
|
form.setValues({ |
||||
|
password: '123456', |
||||
|
username: findUser.value, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
triggerFields: ['selectAccount'], |
||||
|
}, |
||||
|
fieldName: 'username', |
||||
|
label: $t('authentication.username'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.usernameTip') }), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInputPassword', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.password'), |
||||
|
}, |
||||
|
fieldName: 'password', |
||||
|
label: $t('authentication.password'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.passwordTip') }), |
||||
|
}, |
||||
|
]; |
||||
|
}); |
||||
</script> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<AuthenticationLogin |
<AuthenticationLogin |
||||
|
:form-schema="formSchema" |
||||
:loading="authStore.loginLoading" |
:loading="authStore.loginLoading" |
||||
password-placeholder="123456" |
|
||||
username-placeholder="vben" |
|
||||
@submit="authStore.authLogin" |
@submit="authStore.authLogin" |
||||
/> |
/> |
||||
</template> |
</template> |
||||
|
|||||
@ -0,0 +1,98 @@ |
|||||
|
import type { |
||||
|
BaseFormComponentType, |
||||
|
VbenFormSchema as FormSchema, |
||||
|
VbenFormProps, |
||||
|
} from '@vben/common-ui'; |
||||
|
|
||||
|
import { h } from 'vue'; |
||||
|
|
||||
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
|
import { |
||||
|
NButton, |
||||
|
NCheckbox, |
||||
|
NCheckboxGroup, |
||||
|
NDatePicker, |
||||
|
NDivider, |
||||
|
NInput, |
||||
|
NInputNumber, |
||||
|
NRadioGroup, |
||||
|
NSelect, |
||||
|
NSpace, |
||||
|
NSwitch, |
||||
|
NTimePicker, |
||||
|
NTreeSelect, |
||||
|
NUpload, |
||||
|
} from 'naive-ui'; |
||||
|
// 业务表单组件适配
|
||||
|
|
||||
|
export type FormComponentType = |
||||
|
| 'Checkbox' |
||||
|
| 'CheckboxGroup' |
||||
|
| 'DatePicker' |
||||
|
| 'Divider' |
||||
|
| 'Input' |
||||
|
| 'InputNumber' |
||||
|
| 'RadioGroup' |
||||
|
| 'Select' |
||||
|
| 'Space' |
||||
|
| 'Switch' |
||||
|
| 'TimePicker' |
||||
|
| 'TreeSelect' |
||||
|
| 'Upload' |
||||
|
| BaseFormComponentType; |
||||
|
|
||||
|
// 初始化表单组件,并注册到form组件内部
|
||||
|
setupVbenForm<FormComponentType>({ |
||||
|
components: { |
||||
|
Checkbox: NCheckbox, |
||||
|
CheckboxGroup: NCheckboxGroup, |
||||
|
DatePicker: NDatePicker, |
||||
|
// 自定义默认的重置按钮
|
||||
|
DefaultResetActionButton: (props, { attrs, slots }) => { |
||||
|
return h(NButton, { ...props, attrs, text: false, type: 'info' }, slots); |
||||
|
}, |
||||
|
// 自定义默认的提交按钮
|
||||
|
DefaultSubmitActionButton: (props, { attrs, slots }) => { |
||||
|
return h( |
||||
|
NButton, |
||||
|
{ ...props, attrs, text: false, type: 'primary' }, |
||||
|
slots, |
||||
|
); |
||||
|
}, |
||||
|
Divider: NDivider, |
||||
|
Input: NInput, |
||||
|
InputNumber: NInputNumber, |
||||
|
RadioGroup: NRadioGroup, |
||||
|
Select: NSelect, |
||||
|
Space: NSpace, |
||||
|
Switch: NSwitch, |
||||
|
TimePicker: NTimePicker, |
||||
|
TreeSelect: NTreeSelect, |
||||
|
Upload: NUpload, |
||||
|
}, |
||||
|
config: { |
||||
|
baseModelPropName: 'value', |
||||
|
modelPropNameMap: { |
||||
|
Checkbox: 'checked', |
||||
|
Radio: 'checked', |
||||
|
Upload: 'fileList', |
||||
|
}, |
||||
|
}, |
||||
|
defineRules: { |
||||
|
required: (value, _params, ctx) => { |
||||
|
if ((!value && value !== 0) || value.length === 0) { |
||||
|
return $t('formRules.required', [ctx.label]); |
||||
|
} |
||||
|
return true; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const useVbenForm = useForm<FormComponentType>; |
||||
|
|
||||
|
export { useVbenForm, z }; |
||||
|
|
||||
|
export type VbenFormSchema = FormSchema<FormComponentType>; |
||||
|
export type { VbenFormProps }; |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './form'; |
||||
@ -1,18 +1,91 @@ |
|||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||
import { AuthenticationLogin } from '@vben/common-ui'; |
import type { VbenFormSchema } from '@vben/common-ui'; |
||||
|
import type { BasicOption } from '@vben/types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { AuthenticationLogin, z } from '@vben/common-ui'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
|
||||
import { useAuthStore } from '#/store'; |
import { useAuthStore } from '#/store'; |
||||
|
|
||||
defineOptions({ name: 'Login' }); |
defineOptions({ name: 'Login' }); |
||||
|
|
||||
const authStore = useAuthStore(); |
const authStore = useAuthStore(); |
||||
|
|
||||
|
const MOCK_USER_OPTIONS: BasicOption[] = [ |
||||
|
{ |
||||
|
label: '超级管理员', |
||||
|
value: 'vben', |
||||
|
}, |
||||
|
{ |
||||
|
label: '管理员', |
||||
|
value: 'admin', |
||||
|
}, |
||||
|
{ |
||||
|
label: '用户', |
||||
|
value: 'jack', |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const formSchema = computed((): VbenFormSchema[] => { |
||||
|
return [ |
||||
|
{ |
||||
|
component: 'VbenSelect', |
||||
|
componentProps: { |
||||
|
options: MOCK_USER_OPTIONS, |
||||
|
placeholder: $t('authentication.selectAccount'), |
||||
|
}, |
||||
|
fieldName: 'selectAccount', |
||||
|
label: $t('authentication.selectAccount'), |
||||
|
rules: z |
||||
|
.string() |
||||
|
.min(1, { message: $t('authentication.selectAccount') }) |
||||
|
.optional() |
||||
|
.default('vben'), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInput', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.usernameTip'), |
||||
|
}, |
||||
|
dependencies: { |
||||
|
trigger(values, form) { |
||||
|
if (values.selectAccount) { |
||||
|
const findUser = MOCK_USER_OPTIONS.find( |
||||
|
(item) => item.value === values.selectAccount, |
||||
|
); |
||||
|
if (findUser) { |
||||
|
form.setValues({ |
||||
|
password: '123456', |
||||
|
username: findUser.value, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
triggerFields: ['selectAccount'], |
||||
|
}, |
||||
|
fieldName: 'username', |
||||
|
label: $t('authentication.username'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.usernameTip') }), |
||||
|
}, |
||||
|
{ |
||||
|
component: 'VbenInputPassword', |
||||
|
componentProps: { |
||||
|
placeholder: $t('authentication.password'), |
||||
|
}, |
||||
|
fieldName: 'password', |
||||
|
label: $t('authentication.password'), |
||||
|
rules: z.string().min(1, { message: $t('authentication.passwordTip') }), |
||||
|
}, |
||||
|
]; |
||||
|
}); |
||||
</script> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<AuthenticationLogin |
<AuthenticationLogin |
||||
|
:form-schema="formSchema" |
||||
:loading="authStore.loginLoading" |
:loading="authStore.loginLoading" |
||||
password-placeholder="123456" |
|
||||
username-placeholder="vben" |
|
||||
@submit="authStore.authLogin" |
@submit="authStore.authLogin" |
||||
/> |
/> |
||||
</template> |
</template> |
||||
|
|||||
@ -0,0 +1,11 @@ |
|||||
|
--- |
||||
|
outline: deep |
||||
|
--- |
||||
|
|
||||
|
# Vben Form 表单 |
||||
|
|
||||
|
框架提供的表单组件,可适配 `Element Plus`、`Ant Design Vue`、`Naive`UI 框架。 |
||||
|
|
||||
|
# 使用 |
||||
|
|
||||
|
TODO |
||||
@ -1,5 +0,0 @@ |
|||||
export * from './cache'; |
|
||||
export * from './color'; |
|
||||
export * from './constants'; |
|
||||
export * from './store'; |
|
||||
export * from './utils'; |
|
||||
@ -0,0 +1,60 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { StateHandler } from '../state-handler'; |
||||
|
|
||||
|
describe('stateHandler', () => { |
||||
|
it('should resolve when condition is set to true', async () => { |
||||
|
const handler = new StateHandler(); |
||||
|
|
||||
|
// 模拟异步设置 condition 为 true
|
||||
|
setTimeout(() => { |
||||
|
handler.setConditionTrue(); // 明确触发 condition 为 true
|
||||
|
}, 10); |
||||
|
|
||||
|
// 等待条件被设置为 true
|
||||
|
await handler.waitForCondition(); |
||||
|
expect(handler.isConditionTrue()).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve immediately if condition is already true', async () => { |
||||
|
const handler = new StateHandler(); |
||||
|
handler.setConditionTrue(); // 提前设置为 true
|
||||
|
|
||||
|
// 立即 resolve,因为 condition 已经是 true
|
||||
|
await handler.waitForCondition(); |
||||
|
expect(handler.isConditionTrue()).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should reject when condition is set to false after waiting', async () => { |
||||
|
const handler = new StateHandler(); |
||||
|
|
||||
|
// 模拟异步设置 condition 为 false
|
||||
|
setTimeout(() => { |
||||
|
handler.setConditionFalse(); // 明确触发 condition 为 false
|
||||
|
}, 10); |
||||
|
|
||||
|
// 等待过程中,期望 Promise 被 reject
|
||||
|
await expect(handler.waitForCondition()).rejects.toThrow(); |
||||
|
expect(handler.isConditionTrue()).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('should reset condition to false', () => { |
||||
|
const handler = new StateHandler(); |
||||
|
handler.setConditionTrue(); // 设置为 true
|
||||
|
handler.reset(); // 重置为 false
|
||||
|
|
||||
|
expect(handler.isConditionTrue()).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('should resolve when condition is set to true after reset', async () => { |
||||
|
const handler = new StateHandler(); |
||||
|
handler.reset(); // 确保初始为 false
|
||||
|
|
||||
|
setTimeout(() => { |
||||
|
handler.setConditionTrue(); // 重置后设置为 true
|
||||
|
}, 10); |
||||
|
|
||||
|
await handler.waitForCondition(); |
||||
|
expect(handler.isConditionTrue()).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,80 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { bindMethods } from '../util'; |
||||
|
|
||||
|
class TestClass { |
||||
|
public value: string; |
||||
|
|
||||
|
constructor(value: string) { |
||||
|
this.value = value; |
||||
|
bindMethods(this); // 调用通用方法
|
||||
|
} |
||||
|
|
||||
|
getValue() { |
||||
|
return this.value; |
||||
|
} |
||||
|
|
||||
|
setValue(newValue: string) { |
||||
|
this.value = newValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe('bindMethods', () => { |
||||
|
it('should bind methods to the instance correctly', () => { |
||||
|
const instance = new TestClass('initial'); |
||||
|
|
||||
|
// 解构方法
|
||||
|
const { getValue } = instance; |
||||
|
|
||||
|
// 检查 getValue 是否能正确调用,并且 this 绑定了 instance
|
||||
|
expect(getValue()).toBe('initial'); |
||||
|
}); |
||||
|
|
||||
|
it('should bind multiple methods', () => { |
||||
|
const instance = new TestClass('initial'); |
||||
|
|
||||
|
const { getValue, setValue } = instance; |
||||
|
|
||||
|
// 检查 getValue 和 setValue 方法是否正确绑定了 this
|
||||
|
setValue('newValue'); |
||||
|
expect(getValue()).toBe('newValue'); |
||||
|
}); |
||||
|
|
||||
|
it('should not bind non-function properties', () => { |
||||
|
const instance = new TestClass('initial'); |
||||
|
|
||||
|
// 检查普通属性是否保持原样
|
||||
|
expect(instance.value).toBe('initial'); |
||||
|
}); |
||||
|
|
||||
|
it('should not bind constructor method', () => { |
||||
|
const instance = new TestClass('test'); |
||||
|
|
||||
|
// 检查 constructor 是否没有被绑定
|
||||
|
expect(instance.constructor.name).toBe('TestClass'); |
||||
|
}); |
||||
|
|
||||
|
it('should not bind getter/setter properties', () => { |
||||
|
class TestWithGetterSetter { |
||||
|
private _value: string = 'test'; |
||||
|
|
||||
|
constructor() { |
||||
|
bindMethods(this); |
||||
|
} |
||||
|
|
||||
|
get value() { |
||||
|
return this._value; |
||||
|
} |
||||
|
|
||||
|
set value(newValue: string) { |
||||
|
this._value = newValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const instance = new TestWithGetterSetter(); |
||||
|
const { value } = instance; |
||||
|
|
||||
|
// Getter 和 setter 不应被绑定
|
||||
|
expect(value).toBe('test'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,50 @@ |
|||||
|
export class StateHandler { |
||||
|
private condition: boolean = false; |
||||
|
private rejectCondition: (() => void) | null = null; |
||||
|
private resolveCondition: (() => void) | null = null; |
||||
|
|
||||
|
// 清理 resolve/reject 函数
|
||||
|
private clearPromises() { |
||||
|
this.resolveCondition = null; |
||||
|
this.rejectCondition = null; |
||||
|
} |
||||
|
|
||||
|
isConditionTrue(): boolean { |
||||
|
return this.condition; |
||||
|
} |
||||
|
|
||||
|
reset() { |
||||
|
this.condition = false; |
||||
|
this.clearPromises(); |
||||
|
} |
||||
|
|
||||
|
// 触发状态为 false 时,reject
|
||||
|
setConditionFalse() { |
||||
|
this.condition = false; |
||||
|
if (this.rejectCondition) { |
||||
|
this.rejectCondition(); |
||||
|
this.clearPromises(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 触发状态为 true 时,resolve
|
||||
|
setConditionTrue() { |
||||
|
this.condition = true; |
||||
|
if (this.resolveCondition) { |
||||
|
this.resolveCondition(); |
||||
|
this.clearPromises(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 返回一个 Promise,等待 condition 变为 true
|
||||
|
waitForCondition(): Promise<void> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
if (this.condition) { |
||||
|
resolve(); // 如果 condition 已经为 true,立即 resolve
|
||||
|
} else { |
||||
|
this.resolveCondition = resolve; |
||||
|
this.rejectCondition = reject; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
export function bindMethods<T extends object>(instance: T): void { |
||||
|
const prototype = Object.getPrototypeOf(instance); |
||||
|
const propertyNames = Object.getOwnPropertyNames(prototype); |
||||
|
|
||||
|
propertyNames.forEach((propertyName) => { |
||||
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName); |
||||
|
const propertyValue = instance[propertyName as keyof T]; |
||||
|
|
||||
|
if ( |
||||
|
typeof propertyValue === 'function' && |
||||
|
propertyName !== 'constructor' && |
||||
|
descriptor && |
||||
|
!descriptor.get && |
||||
|
!descriptor.set |
||||
|
) { |
||||
|
instance[propertyName as keyof T] = propertyValue.bind(instance); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import { defineBuildConfig } from 'unbuild'; |
||||
|
|
||||
|
export default defineBuildConfig({ |
||||
|
clean: true, |
||||
|
declaration: true, |
||||
|
entries: [ |
||||
|
{ |
||||
|
builder: 'mkdist', |
||||
|
input: './src', |
||||
|
loaders: ['vue'], |
||||
|
pattern: ['**/*.vue'], |
||||
|
}, |
||||
|
{ |
||||
|
builder: 'mkdist', |
||||
|
format: 'esm', |
||||
|
input: './src', |
||||
|
loaders: ['js'], |
||||
|
pattern: ['**/*.ts'], |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
@ -0,0 +1,50 @@ |
|||||
|
{ |
||||
|
"name": "@vben-core/form-ui", |
||||
|
"version": "5.2.1", |
||||
|
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
||||
|
"directory": "packages/@vben-core/uikit/form-ui" |
||||
|
}, |
||||
|
"license": "MIT", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"build": "pnpm unbuild", |
||||
|
"prepublishOnly": "npm run build" |
||||
|
}, |
||||
|
"files": [ |
||||
|
"dist" |
||||
|
], |
||||
|
"sideEffects": [ |
||||
|
"**/*.css" |
||||
|
], |
||||
|
"main": "./dist/index.mjs", |
||||
|
"module": "./dist/index.mjs", |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./src/index.ts", |
||||
|
"development": "./src/index.ts", |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
}, |
||||
|
"publishConfig": { |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vben-core/composables": "workspace:*", |
||||
|
"@vben-core/shadcn-ui": "workspace:*", |
||||
|
"@vben-core/shared": "workspace:*", |
||||
|
"@vee-validate/zod": "^4.13.2", |
||||
|
"@vueuse/core": "^11.0.3", |
||||
|
"vee-validate": "^4.13.2", |
||||
|
"vue": "^3.5.3", |
||||
|
"zod": "^3.23.8", |
||||
|
"zod-defaults": "^0.1.3" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export { default } from '@vben/tailwind-config/postcss'; |
||||
@ -0,0 +1,103 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { computed, toRaw, unref } from 'vue'; |
||||
|
|
||||
|
import { useSimpleLocale } from '@vben-core/composables'; |
||||
|
import { VbenExpandableArrow } from '@vben-core/shadcn-ui'; |
||||
|
import { cn, isFunction } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { COMPONENT_MAP } from '../config'; |
||||
|
import { injectFormProps } from '../use-form-context'; |
||||
|
|
||||
|
const { $t } = useSimpleLocale(); |
||||
|
|
||||
|
const [rootProps, form] = injectFormProps(); |
||||
|
|
||||
|
const collapsed = defineModel({ default: false }); |
||||
|
|
||||
|
const resetButtonOptions = computed(() => { |
||||
|
return { |
||||
|
show: true, |
||||
|
text: `${$t.value('reset')}`, |
||||
|
...unref(rootProps).resetButtonOptions, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const submitButtonOptions = computed(() => { |
||||
|
return { |
||||
|
show: true, |
||||
|
text: `${$t.value('submit')}`, |
||||
|
...unref(rootProps).submitButtonOptions, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const isQueryForm = computed(() => { |
||||
|
return !!unref(rootProps).showCollapseButton; |
||||
|
}); |
||||
|
|
||||
|
const queryFormStyle = computed(() => { |
||||
|
if (isQueryForm.value) { |
||||
|
return { |
||||
|
'grid-column': `-2 / -1`, |
||||
|
marginLeft: 'auto', |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return {}; |
||||
|
}); |
||||
|
|
||||
|
async function handleSubmit(e: Event) { |
||||
|
e?.preventDefault(); |
||||
|
e?.stopPropagation(); |
||||
|
const { valid } = await form.validate(); |
||||
|
if (!valid) { |
||||
|
return; |
||||
|
} |
||||
|
await unref(rootProps).handleSubmit?.(toRaw(form.values)); |
||||
|
} |
||||
|
|
||||
|
async function handleReset(e: Event) { |
||||
|
e?.preventDefault(); |
||||
|
e?.stopPropagation(); |
||||
|
const props = unref(rootProps); |
||||
|
if (isFunction(props.handleReset)) { |
||||
|
await props.handleReset?.(form.values); |
||||
|
} else { |
||||
|
form.resetForm(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<div |
||||
|
:class="cn('col-span-full w-full text-right', rootProps.actionWrapperClass)" |
||||
|
:style="queryFormStyle" |
||||
|
> |
||||
|
<component |
||||
|
:is="COMPONENT_MAP.DefaultResetActionButton" |
||||
|
v-if="resetButtonOptions.show" |
||||
|
class="mr-3" |
||||
|
type="button" |
||||
|
@click="handleReset" |
||||
|
v-bind="resetButtonOptions" |
||||
|
> |
||||
|
{{ resetButtonOptions.text }} |
||||
|
</component> |
||||
|
|
||||
|
<component |
||||
|
:is="COMPONENT_MAP.DefaultSubmitActionButton" |
||||
|
v-if="submitButtonOptions.show" |
||||
|
type="button" |
||||
|
@click="handleSubmit" |
||||
|
v-bind="submitButtonOptions" |
||||
|
> |
||||
|
{{ submitButtonOptions.text }} |
||||
|
</component> |
||||
|
|
||||
|
<VbenExpandableArrow |
||||
|
v-if="rootProps.showCollapseButton" |
||||
|
v-model:model-value="collapsed" |
||||
|
class="ml-2" |
||||
|
> |
||||
|
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span> |
||||
|
</VbenExpandableArrow> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1,65 @@ |
|||||
|
import type { BaseFormComponentType, VbenFormAdapterOptions } from './types'; |
||||
|
|
||||
|
import type { Component } from 'vue'; |
||||
|
import { h } from 'vue'; |
||||
|
|
||||
|
import { |
||||
|
VbenButton, |
||||
|
VbenCheckbox, |
||||
|
Input as VbenInput, |
||||
|
VbenInputPassword, |
||||
|
VbenPinInput, |
||||
|
VbenSelect, |
||||
|
} from '@vben-core/shadcn-ui'; |
||||
|
|
||||
|
import { defineRule } from 'vee-validate'; |
||||
|
|
||||
|
const DEFAULT_MODEL_PROP_NAME = 'modelValue'; |
||||
|
|
||||
|
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = { |
||||
|
DefaultResetActionButton: h(VbenButton, { size: 'sm', variant: 'outline' }), |
||||
|
DefaultSubmitActionButton: h(VbenButton, { size: 'sm', variant: 'default' }), |
||||
|
VbenCheckbox, |
||||
|
VbenInput, |
||||
|
VbenInputPassword, |
||||
|
VbenPinInput, |
||||
|
VbenSelect, |
||||
|
}; |
||||
|
|
||||
|
export const COMPONENT_BIND_EVENT_MAP: Partial< |
||||
|
Record<BaseFormComponentType, string> |
||||
|
> = { |
||||
|
VbenCheckbox: 'checked', |
||||
|
}; |
||||
|
|
||||
|
export function setupVbenForm< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
>(options: VbenFormAdapterOptions<T>) { |
||||
|
const { components, config, defineRules } = options; |
||||
|
|
||||
|
if (defineRules) { |
||||
|
for (const key of Object.keys(defineRules)) { |
||||
|
defineRule(key, defineRules[key as never]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const baseModelPropName = |
||||
|
config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME; |
||||
|
const modelPropNameMap = config?.modelPropNameMap as |
||||
|
| Record<BaseFormComponentType, string> |
||||
|
| undefined; |
||||
|
|
||||
|
for (const component of Object.keys(components)) { |
||||
|
const key = component as BaseFormComponentType; |
||||
|
COMPONENT_MAP[key] = components[component as never]; |
||||
|
|
||||
|
if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) { |
||||
|
COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName; |
||||
|
} |
||||
|
|
||||
|
// 覆盖特殊组件的modelPropName
|
||||
|
if (modelPropNameMap && modelPropNameMap[key]) { |
||||
|
COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key]; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,175 @@ |
|||||
|
import type { |
||||
|
FormState, |
||||
|
GenericObject, |
||||
|
ResetFormOpts, |
||||
|
ValidationOptions, |
||||
|
} from 'vee-validate'; |
||||
|
|
||||
|
import type { FormActions, VbenFormProps } from './types'; |
||||
|
|
||||
|
import { toRaw } from 'vue'; |
||||
|
|
||||
|
import { Store } from '@vben-core/shared/store'; |
||||
|
import { bindMethods, isFunction, StateHandler } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
function getDefaultState(): VbenFormProps { |
||||
|
return { |
||||
|
actionWrapperClass: '', |
||||
|
collapsed: false, |
||||
|
collapsedRows: 1, |
||||
|
commonConfig: {}, |
||||
|
handleReset: undefined, |
||||
|
handleSubmit: undefined, |
||||
|
layout: 'horizontal', |
||||
|
resetButtonOptions: {}, |
||||
|
schema: [], |
||||
|
showCollapseButton: false, |
||||
|
showDefaultActions: true, |
||||
|
submitButtonOptions: {}, |
||||
|
wrapperClass: 'grid-cols-1', |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export class FormApi { |
||||
|
// private prevState!: ModalState;
|
||||
|
private state: null | VbenFormProps = null; |
||||
|
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
|
||||
|
public form = {} as FormActions; |
||||
|
|
||||
|
isMounted = false; |
||||
|
|
||||
|
stateHandler: StateHandler; |
||||
|
|
||||
|
public store: Store<VbenFormProps>; |
||||
|
|
||||
|
constructor(options: VbenFormProps = {}) { |
||||
|
const { ...storeState } = options; |
||||
|
|
||||
|
const defaultState = getDefaultState(); |
||||
|
|
||||
|
this.store = new Store<VbenFormProps>( |
||||
|
{ |
||||
|
...defaultState, |
||||
|
...storeState, |
||||
|
}, |
||||
|
{ |
||||
|
onUpdate: () => { |
||||
|
this.state = this.store.state; |
||||
|
}, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
this.state = this.store.state; |
||||
|
this.stateHandler = new StateHandler(); |
||||
|
bindMethods(this); |
||||
|
} |
||||
|
|
||||
|
private async getForm() { |
||||
|
if (!this.isMounted) { |
||||
|
// 等待form挂载
|
||||
|
await this.stateHandler.waitForCondition(); |
||||
|
} |
||||
|
if (!this.form?.meta) { |
||||
|
throw new Error('<VbenForm /> is not mounted'); |
||||
|
} |
||||
|
return this.form; |
||||
|
} |
||||
|
|
||||
|
// 如果需要多次更新状态,可以使用 batch 方法
|
||||
|
batchStore(cb: () => void) { |
||||
|
this.store.batch(cb); |
||||
|
} |
||||
|
|
||||
|
async getValues() { |
||||
|
const form = await this.getForm(); |
||||
|
return form.values; |
||||
|
} |
||||
|
|
||||
|
mount(formActions: FormActions) { |
||||
|
if (!this.isMounted) { |
||||
|
Object.assign(this.form, formActions); |
||||
|
this.stateHandler.setConditionTrue(); |
||||
|
this.isMounted = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据字段名移除表单项 |
||||
|
* @param fields |
||||
|
*/ |
||||
|
async removeSchemaByFields(fields: string[]) { |
||||
|
const fieldSet = new Set(fields); |
||||
|
const schema = this.state?.schema ?? []; |
||||
|
|
||||
|
const filterSchema = schema.filter((item) => fieldSet.has(item.fieldName)); |
||||
|
|
||||
|
this.setState({ |
||||
|
schema: filterSchema, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重置表单 |
||||
|
*/ |
||||
|
async resetForm( |
||||
|
state?: Partial<FormState<GenericObject>> | undefined, |
||||
|
opts?: Partial<ResetFormOpts>, |
||||
|
) { |
||||
|
const form = await this.getForm(); |
||||
|
return form.resetForm(state, opts); |
||||
|
} |
||||
|
|
||||
|
async resetValidate() { |
||||
|
const form = await this.getForm(); |
||||
|
const fields = Object.keys(form.errors.value); |
||||
|
fields.forEach((field) => { |
||||
|
form.setFieldError(field, undefined); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async setFieldValue(field: string, value: any, shouldValidate?: boolean) { |
||||
|
const form = await this.getForm(); |
||||
|
form.setFieldValue(field, value, shouldValidate); |
||||
|
} |
||||
|
|
||||
|
setState( |
||||
|
stateOrFn: |
||||
|
| ((prev: VbenFormProps) => Partial<VbenFormProps>) |
||||
|
| Partial<VbenFormProps>, |
||||
|
) { |
||||
|
if (isFunction(stateOrFn)) { |
||||
|
this.store.setState(stateOrFn as (prev: VbenFormProps) => VbenFormProps); |
||||
|
} else { |
||||
|
this.store.setState((prev) => ({ ...prev, ...stateOrFn })); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async setValues( |
||||
|
fields: Record<string, any>, |
||||
|
shouldValidate: boolean = false, |
||||
|
) { |
||||
|
const form = await this.getForm(); |
||||
|
form.setValues(fields, shouldValidate); |
||||
|
} |
||||
|
|
||||
|
async submitForm(e?: Event) { |
||||
|
e?.preventDefault(); |
||||
|
e?.stopPropagation(); |
||||
|
const form = await this.getForm(); |
||||
|
await form.submitForm(); |
||||
|
const rawValues = toRaw(form.values || {}); |
||||
|
await this.state?.handleSubmit?.(rawValues); |
||||
|
return rawValues; |
||||
|
} |
||||
|
|
||||
|
unmounted() { |
||||
|
this.state = null; |
||||
|
this.isMounted = false; |
||||
|
this.stateHandler.reset(); |
||||
|
} |
||||
|
|
||||
|
async validate(opts?: Partial<ValidationOptions>) { |
||||
|
const form = await this.getForm(); |
||||
|
return await form.validate(opts); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
import type { FormRenderProps } from '../types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { createContext } from '@vben-core/shadcn-ui'; |
||||
|
|
||||
|
export const [injectRenderFormProps, provideFormRenderProps] = |
||||
|
createContext<FormRenderProps>('FormRenderProps'); |
||||
|
|
||||
|
export const useFormContext = () => { |
||||
|
const formRenderProps = injectRenderFormProps(); |
||||
|
|
||||
|
const isVertical = computed(() => formRenderProps.layout === 'vertical'); |
||||
|
|
||||
|
const componentMap = computed(() => formRenderProps.componentMap); |
||||
|
const componentBindEventMap = computed( |
||||
|
() => formRenderProps.componentBindEventMap, |
||||
|
); |
||||
|
return { |
||||
|
componentBindEventMap, |
||||
|
componentMap, |
||||
|
isVertical, |
||||
|
}; |
||||
|
}; |
||||
@ -0,0 +1,116 @@ |
|||||
|
import type { |
||||
|
FormItemDependencies, |
||||
|
FormSchemaRuleType, |
||||
|
MaybeComponentProps, |
||||
|
} from '../types'; |
||||
|
|
||||
|
import { computed, ref, watch } from 'vue'; |
||||
|
|
||||
|
import { isFunction } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { useFormValues } from 'vee-validate'; |
||||
|
|
||||
|
import { injectRenderFormProps } from './context'; |
||||
|
|
||||
|
export default function useDependencies( |
||||
|
getDependencies: () => FormItemDependencies | undefined, |
||||
|
) { |
||||
|
const values = useFormValues(); |
||||
|
|
||||
|
const formRenderProps = injectRenderFormProps(); |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
const formApi = formRenderProps.form!; |
||||
|
|
||||
|
if (!values) { |
||||
|
throw new Error('useDependencies should be used within <VbenForm>'); |
||||
|
} |
||||
|
|
||||
|
const isIf = ref(true); |
||||
|
const isDisabled = ref(false); |
||||
|
const isShow = ref(true); |
||||
|
const isRequired = ref(false); |
||||
|
const dynamicComponentProps = ref<MaybeComponentProps>({}); |
||||
|
const dynamicRules = ref<FormSchemaRuleType>(); |
||||
|
|
||||
|
const triggerFieldValues = computed(() => { |
||||
|
// 该字段可能会被多个字段触发
|
||||
|
const triggerFields = getDependencies()?.triggerFields ?? []; |
||||
|
return triggerFields.map((dep) => { |
||||
|
return values.value[dep]; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
const resetConditionState = () => { |
||||
|
isDisabled.value = false; |
||||
|
isIf.value = true; |
||||
|
isShow.value = true; |
||||
|
isRequired.value = false; |
||||
|
dynamicRules.value = undefined; |
||||
|
dynamicComponentProps.value = {}; |
||||
|
}; |
||||
|
|
||||
|
watch( |
||||
|
[triggerFieldValues, getDependencies], |
||||
|
async ([_values, dependencies]) => { |
||||
|
if (!dependencies || !dependencies?.triggerFields?.length) { |
||||
|
return; |
||||
|
} |
||||
|
resetConditionState(); |
||||
|
const { |
||||
|
componentProps, |
||||
|
disabled, |
||||
|
if: whenIf, |
||||
|
required, |
||||
|
rules, |
||||
|
show, |
||||
|
trigger, |
||||
|
} = dependencies; |
||||
|
|
||||
|
// 1. 优先判断if,如果if为false,则不渲染dom,后续判断也不再执行
|
||||
|
const formValues = values.value; |
||||
|
|
||||
|
if (isFunction(whenIf)) { |
||||
|
isIf.value = !!(await whenIf(formValues, formApi)); |
||||
|
// 不渲染
|
||||
|
if (!isIf.value) return; |
||||
|
} |
||||
|
|
||||
|
// 2. 判断show,如果show为false,则隐藏
|
||||
|
if (isFunction(show)) { |
||||
|
isShow.value = !!(await show(formValues, formApi)); |
||||
|
if (!isShow.value) return; |
||||
|
} |
||||
|
|
||||
|
if (isFunction(componentProps)) { |
||||
|
dynamicComponentProps.value = await componentProps(formValues, formApi); |
||||
|
} |
||||
|
|
||||
|
if (isFunction(rules)) { |
||||
|
dynamicRules.value = await rules(formValues, formApi); |
||||
|
} |
||||
|
|
||||
|
if (isFunction(disabled)) { |
||||
|
isDisabled.value = !!(await disabled(formValues, formApi)); |
||||
|
} |
||||
|
|
||||
|
if (isFunction(required)) { |
||||
|
isRequired.value = !!(await required(formValues, formApi)); |
||||
|
} |
||||
|
|
||||
|
if (isFunction(trigger)) { |
||||
|
await trigger(formValues, formApi); |
||||
|
} |
||||
|
}, |
||||
|
{ deep: true, immediate: true }, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
dynamicComponentProps, |
||||
|
dynamicRules, |
||||
|
isDisabled, |
||||
|
isIf, |
||||
|
isRequired, |
||||
|
isShow, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
import type { FormRenderProps } from '../types'; |
||||
|
|
||||
|
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'; |
||||
|
|
||||
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'; |
||||
|
|
||||
|
/** |
||||
|
* 动态计算行数 |
||||
|
*/ |
||||
|
export function useExpandable(props: FormRenderProps) { |
||||
|
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef'); |
||||
|
const rowMapping = ref<Record<number, number>>({}); |
||||
|
// 是否已经计算过一次
|
||||
|
const isCalculated = ref(false); |
||||
|
|
||||
|
const breakpoints = useBreakpoints(breakpointsTailwind); |
||||
|
|
||||
|
const keepFormItemIndex = computed(() => { |
||||
|
const rows = props.collapsedRows ?? 1; |
||||
|
const mapping = rowMapping.value; |
||||
|
let maxItem = 0; |
||||
|
for (let index = 1; index <= rows; index++) { |
||||
|
maxItem += mapping?.[index] ?? 0; |
||||
|
} |
||||
|
return maxItem - 1; |
||||
|
}); |
||||
|
|
||||
|
watch( |
||||
|
[ |
||||
|
() => props.showCollapseButton, |
||||
|
() => breakpoints.active().value, |
||||
|
() => props.schema?.length, |
||||
|
], |
||||
|
async ([val]) => { |
||||
|
if (val) { |
||||
|
await nextTick(); |
||||
|
rowMapping.value = {}; |
||||
|
await calculateRowMapping(); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
async function calculateRowMapping() { |
||||
|
if (!props.showCollapseButton) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
await nextTick(); |
||||
|
if (!wrapperRef.value) { |
||||
|
return; |
||||
|
} |
||||
|
// 小屏幕不计算
|
||||
|
if (breakpoints.smaller('sm').value) { |
||||
|
// 保持一行
|
||||
|
rowMapping.value = { 1: 2 }; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const formItems = [...wrapperRef.value.children]; |
||||
|
|
||||
|
const container = wrapperRef.value; |
||||
|
const containerStyles = window.getComputedStyle(container); |
||||
|
const rowHeights = containerStyles |
||||
|
.getPropertyValue('grid-template-rows') |
||||
|
.split(' '); |
||||
|
|
||||
|
const containerRect = container?.getBoundingClientRect(); |
||||
|
|
||||
|
formItems.forEach((el) => { |
||||
|
const itemRect = el.getBoundingClientRect(); |
||||
|
|
||||
|
// 计算元素在第几行
|
||||
|
const itemTop = itemRect.top - containerRect.top; |
||||
|
let rowStart = 0; |
||||
|
let cumulativeHeight = 0; |
||||
|
|
||||
|
for (const [i, rowHeight] of rowHeights.entries()) { |
||||
|
cumulativeHeight += Number.parseFloat(rowHeight); |
||||
|
if (itemTop < cumulativeHeight) { |
||||
|
rowStart = i + 1; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
if (rowStart > (props?.collapsedRows ?? 1)) { |
||||
|
return; |
||||
|
} |
||||
|
rowMapping.value[rowStart] = (rowMapping.value[rowStart] ?? 0) + 1; |
||||
|
isCalculated.value = true; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
|
calculateRowMapping(); |
||||
|
}); |
||||
|
|
||||
|
return { isCalculated, keepFormItemIndex, wrapperRef }; |
||||
|
} |
||||
@ -0,0 +1,283 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { ZodType } from 'zod'; |
||||
|
|
||||
|
import type { FormSchema } from '../types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { |
||||
|
FormControl, |
||||
|
FormDescription, |
||||
|
FormField, |
||||
|
FormItem, |
||||
|
FormMessage, |
||||
|
VbenRenderContent, |
||||
|
} from '@vben-core/shadcn-ui'; |
||||
|
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { toTypedSchema } from '@vee-validate/zod'; |
||||
|
import { useFormValues } from 'vee-validate'; |
||||
|
|
||||
|
import { injectRenderFormProps, useFormContext } from './context'; |
||||
|
import useDependencies from './dependencies'; |
||||
|
import FormLabel from './form-label.vue'; |
||||
|
import { isEventObjectLike } from './helper'; |
||||
|
|
||||
|
interface Props extends FormSchema {} |
||||
|
|
||||
|
const { |
||||
|
component, |
||||
|
componentProps, |
||||
|
dependencies, |
||||
|
description, |
||||
|
disabled, |
||||
|
fieldName, |
||||
|
formFieldProps, |
||||
|
label, |
||||
|
labelClass, |
||||
|
labelWidth, |
||||
|
renderComponentContent, |
||||
|
rules, |
||||
|
} = defineProps<Props>(); |
||||
|
|
||||
|
const { componentBindEventMap, componentMap, isVertical } = useFormContext(); |
||||
|
const formRenderProps = injectRenderFormProps(); |
||||
|
const values = useFormValues(); |
||||
|
const formApi = formRenderProps.form; |
||||
|
|
||||
|
const fieldComponent = computed(() => { |
||||
|
const finalComponent = isString(component) |
||||
|
? componentMap.value[component] |
||||
|
: component; |
||||
|
if (!finalComponent) { |
||||
|
// 组件未注册 |
||||
|
console.warn(`Component ${component} is not registered`); |
||||
|
} |
||||
|
return finalComponent; |
||||
|
}); |
||||
|
|
||||
|
const { |
||||
|
dynamicComponentProps, |
||||
|
dynamicRules, |
||||
|
isDisabled, |
||||
|
isIf, |
||||
|
isRequired, |
||||
|
isShow, |
||||
|
} = useDependencies(() => dependencies); |
||||
|
|
||||
|
const labelStyle = computed(() => { |
||||
|
return labelClass?.includes('w-') || isVertical.value |
||||
|
? {} |
||||
|
: { |
||||
|
width: `${labelWidth}px`, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const currentRules = computed(() => { |
||||
|
return dynamicRules.value || rules; |
||||
|
}); |
||||
|
|
||||
|
const shouldRequired = computed(() => { |
||||
|
if (!currentRules.value) { |
||||
|
return isRequired.value; |
||||
|
} |
||||
|
|
||||
|
if (isRequired.value) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (isString(currentRules.value)) { |
||||
|
return currentRules.value === 'required'; |
||||
|
} |
||||
|
|
||||
|
let isOptional = currentRules?.value?.isOptional?.(); |
||||
|
|
||||
|
// 如果有设置默认值,则不是必填,需要特殊处理 |
||||
|
const typeName = currentRules?.value?._def?.typeName; |
||||
|
if (typeName === 'ZodDefault') { |
||||
|
const innerType = currentRules?.value?._def.innerType; |
||||
|
if (innerType) { |
||||
|
isOptional = innerType.isOptional?.(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return !isOptional; |
||||
|
}); |
||||
|
|
||||
|
const fieldRules = computed(() => { |
||||
|
let rules = currentRules.value; |
||||
|
if (!rules) { |
||||
|
return isRequired.value ? 'required' : null; |
||||
|
} |
||||
|
|
||||
|
if (isString(rules)) { |
||||
|
return rules; |
||||
|
} |
||||
|
|
||||
|
const isOptional = !shouldRequired.value; |
||||
|
if (!isOptional) { |
||||
|
const unwrappedRules = (rules as any)?.unwrap?.(); |
||||
|
if (unwrappedRules) { |
||||
|
rules = unwrappedRules; |
||||
|
} |
||||
|
} |
||||
|
return toTypedSchema(rules as ZodType); |
||||
|
}); |
||||
|
|
||||
|
const computedProps = computed(() => { |
||||
|
const finalComponentProps = isFunction(componentProps) |
||||
|
? componentProps(values.value, formApi!) |
||||
|
: componentProps; |
||||
|
|
||||
|
return { |
||||
|
...finalComponentProps, |
||||
|
...dynamicComponentProps.value, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const shouldDisabled = computed(() => { |
||||
|
return isDisabled.value || disabled || computedProps.value?.disabled; |
||||
|
}); |
||||
|
|
||||
|
const customContentRender = computed(() => { |
||||
|
if (!isFunction(renderComponentContent)) { |
||||
|
return {}; |
||||
|
} |
||||
|
return renderComponentContent(values.value, formApi!); |
||||
|
}); |
||||
|
|
||||
|
const renderContentKey = computed(() => { |
||||
|
return Object.keys(customContentRender.value); |
||||
|
}); |
||||
|
|
||||
|
const fieldProps = computed(() => { |
||||
|
const rules = fieldRules.value; |
||||
|
return { |
||||
|
keepValue: true, |
||||
|
label, |
||||
|
...(rules ? { rules } : {}), |
||||
|
...formFieldProps, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
function fieldBindEvent(slotProps: Record<string, any>) { |
||||
|
const modelValue = slotProps.componentField.modelValue; |
||||
|
const handler = slotProps.componentField['onUpdate:modelValue']; |
||||
|
|
||||
|
const bindEventField = isString(component) |
||||
|
? componentBindEventMap.value?.[component] |
||||
|
: null; |
||||
|
|
||||
|
let value = modelValue; |
||||
|
// antd design 的一些组件会传递一个 event 对象 |
||||
|
if (modelValue && isObject(modelValue) && bindEventField) { |
||||
|
value = isEventObjectLike(modelValue) |
||||
|
? modelValue?.target?.[bindEventField] |
||||
|
: modelValue; |
||||
|
} |
||||
|
if (bindEventField) { |
||||
|
return { |
||||
|
[`onUpdate:${bindEventField}`]: handler, |
||||
|
[bindEventField]: value, |
||||
|
onChange: (e: Record<string, any>) => { |
||||
|
const shouldUnwrap = isEventObjectLike(e); |
||||
|
const onChange = slotProps?.componentField?.onChange; |
||||
|
if (!shouldUnwrap) { |
||||
|
return onChange?.(e); |
||||
|
} |
||||
|
|
||||
|
return onChange?.(e?.target?.[bindEventField] ?? e); |
||||
|
}, |
||||
|
onInput: () => {}, |
||||
|
}; |
||||
|
} |
||||
|
return {}; |
||||
|
} |
||||
|
|
||||
|
function createComponentProps(slotProps: Record<string, any>) { |
||||
|
const bindEvents = fieldBindEvent(slotProps); |
||||
|
|
||||
|
const binds = { |
||||
|
...slotProps.componentField, |
||||
|
...computedProps.value, |
||||
|
...bindEvents, |
||||
|
}; |
||||
|
|
||||
|
return binds; |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<FormField |
||||
|
v-if="isIf" |
||||
|
v-bind="fieldProps" |
||||
|
v-slot="slotProps" |
||||
|
:name="fieldName" |
||||
|
> |
||||
|
<FormItem |
||||
|
v-show="isShow" |
||||
|
:class="{ |
||||
|
'flex-col': isVertical, |
||||
|
'flex-row items-center': !isVertical, |
||||
|
}" |
||||
|
class="flex pb-6" |
||||
|
v-bind="$attrs" |
||||
|
> |
||||
|
<FormLabel |
||||
|
v-if="!hideLabel" |
||||
|
:class=" |
||||
|
cn( |
||||
|
'flex leading-6', |
||||
|
{ |
||||
|
'mr-2 flex-shrink-0': !isVertical, |
||||
|
'flex-row': isVertical, |
||||
|
}, |
||||
|
!isVertical && labelClass, |
||||
|
) |
||||
|
" |
||||
|
:help="help" |
||||
|
:required="shouldRequired && !hideRequiredMark" |
||||
|
:style="labelStyle" |
||||
|
> |
||||
|
{{ label }} |
||||
|
</FormLabel> |
||||
|
<div :class="cn('relative flex w-full items-center', wrapperClass)"> |
||||
|
<FormControl :class="cn(controlClass)"> |
||||
|
<slot |
||||
|
v-bind="{ |
||||
|
...slotProps, |
||||
|
...createComponentProps(slotProps), |
||||
|
disabled: shouldDisabled, |
||||
|
}" |
||||
|
> |
||||
|
<component |
||||
|
:is="fieldComponent" |
||||
|
v-bind="createComponentProps(slotProps)" |
||||
|
:disabled="shouldDisabled" |
||||
|
> |
||||
|
<template v-for="name in renderContentKey" :key="name" #[name]> |
||||
|
<VbenRenderContent |
||||
|
:content="customContentRender[name]" |
||||
|
v-bind="slotProps" |
||||
|
/> |
||||
|
</template> |
||||
|
<!-- <slot></slot> --> |
||||
|
</component> |
||||
|
</slot> |
||||
|
</FormControl> |
||||
|
<!-- 自定义后缀 --> |
||||
|
<div v-if="suffix" class="ml-1"> |
||||
|
<VbenRenderContent :content="suffix" /> |
||||
|
</div> |
||||
|
|
||||
|
<FormDescription v-if="description"> |
||||
|
<VbenRenderContent :content="description" /> |
||||
|
</FormDescription> |
||||
|
|
||||
|
<Transition name="slide-up"> |
||||
|
<FormMessage class="absolute -bottom-[22px]" /> |
||||
|
</Transition> |
||||
|
</div> |
||||
|
</FormItem> |
||||
|
</FormField> |
||||
|
</template> |
||||
@ -0,0 +1,20 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { FormLabel, VbenHelpTooltip } from '@vben-core/shadcn-ui'; |
||||
|
|
||||
|
interface Props { |
||||
|
help?: string; |
||||
|
required?: boolean; |
||||
|
} |
||||
|
|
||||
|
defineProps<Props>(); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<FormLabel class="flex flex-row-reverse items-center"> |
||||
|
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1"> |
||||
|
{{ help }} |
||||
|
</VbenHelpTooltip> |
||||
|
<slot></slot> |
||||
|
<span v-if="required" class="text-destructive mr-[2px]">*</span> |
||||
|
</FormLabel> |
||||
|
</template> |
||||
@ -0,0 +1,140 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { GenericObject } from 'vee-validate'; |
||||
|
import type { ZodTypeAny } from 'zod'; |
||||
|
|
||||
|
import type { FormRenderProps, FormSchema, FormShape } from '../types'; |
||||
|
|
||||
|
import { computed } from 'vue'; |
||||
|
|
||||
|
import { Form } from '@vben-core/shadcn-ui'; |
||||
|
import { cn, isString, merge } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { provideFormRenderProps } from './context'; |
||||
|
import { useExpandable } from './expandable'; |
||||
|
import FormField from './form-field.vue'; |
||||
|
import { getBaseRules, getDefaultValueInZodStack } from './helper'; |
||||
|
|
||||
|
interface Props extends FormRenderProps {} |
||||
|
|
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
collapsedRows: 1, |
||||
|
commonConfig: () => ({}), |
||||
|
showCollapseButton: false, |
||||
|
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3', |
||||
|
}); |
||||
|
|
||||
|
const emits = defineEmits<{ |
||||
|
submit: [event: any]; |
||||
|
}>(); |
||||
|
|
||||
|
provideFormRenderProps(props); |
||||
|
|
||||
|
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props); |
||||
|
|
||||
|
const shapes = computed(() => { |
||||
|
const resultShapes: FormShape[] = []; |
||||
|
props.schema?.forEach((schema) => { |
||||
|
const { fieldName } = schema; |
||||
|
const rules = schema.rules as ZodTypeAny; |
||||
|
|
||||
|
let typeName = ''; |
||||
|
if (rules && !isString(rules)) { |
||||
|
typeName = rules._def.typeName; |
||||
|
} |
||||
|
|
||||
|
const baseRules = getBaseRules(rules) as ZodTypeAny; |
||||
|
|
||||
|
resultShapes.push({ |
||||
|
default: getDefaultValueInZodStack(rules), |
||||
|
fieldName, |
||||
|
required: !['ZodNullable', 'ZodOptional'].includes(typeName), |
||||
|
rules: baseRules, |
||||
|
}); |
||||
|
}); |
||||
|
return resultShapes; |
||||
|
}); |
||||
|
|
||||
|
const formComponent = computed(() => (props.form ? 'form' : Form)); |
||||
|
|
||||
|
const formComponentProps = computed(() => { |
||||
|
return props.form |
||||
|
? { |
||||
|
onSubmit: props.form.handleSubmit((val) => emits('submit', val)), |
||||
|
} |
||||
|
: { |
||||
|
onSubmit: (val: GenericObject) => emits('submit', val), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const formCollapsed = computed(() => { |
||||
|
return props.collapsed && isCalculated.value; |
||||
|
}); |
||||
|
|
||||
|
const computedSchema = computed((): FormSchema[] => { |
||||
|
const { |
||||
|
componentProps = {}, |
||||
|
controlClass = '', |
||||
|
disabled, |
||||
|
formFieldProps = {}, |
||||
|
formItemClass = '', |
||||
|
hideLabel = false, |
||||
|
hideRequiredMark = false, |
||||
|
labelClass = '', |
||||
|
labelWidth = 100, |
||||
|
wrapperClass = '', |
||||
|
} = props.commonConfig; |
||||
|
return (props.schema || []).map((schema, index): FormSchema => { |
||||
|
const keepIndex = keepFormItemIndex.value; |
||||
|
|
||||
|
const hidden = |
||||
|
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引 |
||||
|
props.showCollapseButton && !!formCollapsed.value && keepIndex |
||||
|
? keepIndex <= index |
||||
|
: false; |
||||
|
|
||||
|
return { |
||||
|
disabled, |
||||
|
hideLabel, |
||||
|
hideRequiredMark, |
||||
|
labelWidth, |
||||
|
wrapperClass, |
||||
|
...schema, |
||||
|
componentProps: merge({}, schema.componentProps, componentProps), |
||||
|
controlClass: cn(controlClass, schema.controlClass), |
||||
|
formFieldProps: { |
||||
|
...formFieldProps, |
||||
|
...schema.formFieldProps, |
||||
|
}, |
||||
|
formItemClass: cn( |
||||
|
'flex-shrink-0', |
||||
|
{ hidden }, |
||||
|
formItemClass, |
||||
|
schema.formItemClass, |
||||
|
), |
||||
|
labelClass: cn(labelClass, schema.labelClass), |
||||
|
}; |
||||
|
}); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<component :is="formComponent" v-bind="formComponentProps"> |
||||
|
<div ref="wrapperRef" :class="wrapperClass" class="grid"> |
||||
|
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName"> |
||||
|
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass"> |
||||
|
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot> |
||||
|
</div> --> |
||||
|
<FormField |
||||
|
v-bind="cSchema" |
||||
|
:class="cSchema.formItemClass" |
||||
|
:rules="cSchema.rules" |
||||
|
> |
||||
|
<template #default="slotProps"> |
||||
|
<slot v-bind="slotProps" :name="cSchema.fieldName"> </slot> |
||||
|
</template> |
||||
|
</FormField> |
||||
|
</template> |
||||
|
<slot :shapes="shapes"></slot> |
||||
|
</div> |
||||
|
</component> |
||||
|
</template> |
||||
@ -0,0 +1,60 @@ |
|||||
|
import type { |
||||
|
AnyZodObject, |
||||
|
ZodDefault, |
||||
|
ZodEffects, |
||||
|
ZodNumber, |
||||
|
ZodString, |
||||
|
ZodTypeAny, |
||||
|
} from 'zod'; |
||||
|
|
||||
|
import { isObject, isString } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
/** |
||||
|
* Get the lowest level Zod type. |
||||
|
* This will unpack optionals, refinements, etc. |
||||
|
*/ |
||||
|
export function getBaseRules< |
||||
|
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny, |
||||
|
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null { |
||||
|
if (!schema || isString(schema)) return null; |
||||
|
if ('innerType' in schema._def) |
||||
|
return getBaseRules(schema._def.innerType as ChildType); |
||||
|
|
||||
|
if ('schema' in schema._def) |
||||
|
return getBaseRules(schema._def.schema as ChildType); |
||||
|
|
||||
|
return schema as ChildType; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Search for a "ZodDefault" in the Zod stack and return its value. |
||||
|
*/ |
||||
|
export function getDefaultValueInZodStack(schema: ZodTypeAny): any { |
||||
|
if (!schema || isString(schema)) { |
||||
|
return; |
||||
|
} |
||||
|
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>; |
||||
|
|
||||
|
if (typedSchema._def.typeName === 'ZodDefault') |
||||
|
return typedSchema._def.defaultValue(); |
||||
|
|
||||
|
if ('innerType' in typedSchema._def) { |
||||
|
return getDefaultValueInZodStack( |
||||
|
typedSchema._def.innerType as unknown as ZodTypeAny, |
||||
|
); |
||||
|
} |
||||
|
if ('schema' in typedSchema._def) { |
||||
|
return getDefaultValueInZodStack( |
||||
|
(typedSchema._def as any).schema as ZodTypeAny, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
export function isEventObjectLike(obj: any) { |
||||
|
if (!obj || !isObject(obj)) { |
||||
|
return false; |
||||
|
} |
||||
|
return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation'); |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
export { default as Form } from './form.vue'; |
||||
|
export { default as FormField } from './form-field.vue'; |
||||
|
export { default as FormLabel } from './form-label.vue'; |
||||
@ -0,0 +1,11 @@ |
|||||
|
export { setupVbenForm } from './config'; |
||||
|
export type { |
||||
|
BaseFormComponentType, |
||||
|
FormSchema as VbenFormSchema, |
||||
|
VbenFormProps, |
||||
|
} from './types'; |
||||
|
|
||||
|
export * from './use-vben-form'; |
||||
|
|
||||
|
// export { default as VbenForm } from './vben-form.vue';
|
||||
|
export * as z from 'zod'; |
||||
@ -0,0 +1,327 @@ |
|||||
|
import type { VbenButtonProps } from '@vben-core/shadcn-ui'; |
||||
|
import type { Field, FormContext, GenericObject } from 'vee-validate'; |
||||
|
import type { ZodTypeAny } from 'zod'; |
||||
|
|
||||
|
import type { FormApi } from './form-api'; |
||||
|
|
||||
|
import type { Component, HtmlHTMLAttributes, Ref } from 'vue'; |
||||
|
|
||||
|
export type FormLayout = 'horizontal' | 'vertical'; |
||||
|
|
||||
|
export type BaseFormComponentType = |
||||
|
| 'DefaultResetActionButton' |
||||
|
| 'DefaultSubmitActionButton' |
||||
|
| 'VbenCheckbox' |
||||
|
| 'VbenInput' |
||||
|
| 'VbenInputPassword' |
||||
|
| 'VbenPinInput' |
||||
|
| 'VbenSelect' |
||||
|
| (Record<never, never> & string); |
||||
|
|
||||
|
type Breakpoints = '' | '2xl:' | '3xl:' | 'lg:' | 'md:' | 'sm:' | 'xl:'; |
||||
|
|
||||
|
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; |
||||
|
|
||||
|
export type WrapperClassType = |
||||
|
| `${Breakpoints}grid-cols-${GridCols}` |
||||
|
| (Record<never, never> & string); |
||||
|
|
||||
|
export type FormItemClassType = |
||||
|
| `${Breakpoints}cols-end-${'auto' | GridCols}` |
||||
|
| `${Breakpoints}cols-span-${'auto' | 'full' | GridCols}` |
||||
|
| `${Breakpoints}cols-start-${'auto' | GridCols}` |
||||
|
| (Record<never, never> & string) |
||||
|
| WrapperClassType; |
||||
|
|
||||
|
export interface FormShape { |
||||
|
/** 默认值 */ |
||||
|
default?: any; |
||||
|
/** 字段名 */ |
||||
|
fieldName: string; |
||||
|
/** 是否必填 */ |
||||
|
required?: boolean; |
||||
|
rules?: ZodTypeAny; |
||||
|
} |
||||
|
|
||||
|
export type MaybeComponentPropKey = |
||||
|
| 'options' |
||||
|
| 'placeholder' |
||||
|
| 'title' |
||||
|
| keyof HtmlHTMLAttributes |
||||
|
| (Record<never, never> & string); |
||||
|
|
||||
|
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any }; |
||||
|
|
||||
|
export type FormActions = FormContext<GenericObject>; |
||||
|
|
||||
|
export type CustomRenderType = (() => Component | string) | string; |
||||
|
|
||||
|
export type FormSchemaRuleType = |
||||
|
| 'required' |
||||
|
| null |
||||
|
| (Record<never, never> & string) |
||||
|
| ZodTypeAny; |
||||
|
|
||||
|
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = ( |
||||
|
value: Partial<Record<string, any>>, |
||||
|
actions: FormActions, |
||||
|
) => T; |
||||
|
|
||||
|
type FormItemDependenciesConditionWithRules = ( |
||||
|
value: Partial<Record<string, any>>, |
||||
|
actions: FormActions, |
||||
|
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>; |
||||
|
|
||||
|
type FormItemDependenciesConditionWithProps = ( |
||||
|
value: Partial<Record<string, any>>, |
||||
|
actions: FormActions, |
||||
|
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>; |
||||
|
|
||||
|
export interface FormItemDependencies { |
||||
|
/** |
||||
|
* 组件参数 |
||||
|
* @returns 组件参数 |
||||
|
*/ |
||||
|
componentProps?: FormItemDependenciesConditionWithProps; |
||||
|
/** |
||||
|
* 是否禁用 |
||||
|
* @returns 是否禁用 |
||||
|
*/ |
||||
|
disabled?: FormItemDependenciesCondition; |
||||
|
/** |
||||
|
* 是否渲染(删除dom) |
||||
|
* @returns 是否渲染 |
||||
|
*/ |
||||
|
if?: FormItemDependenciesCondition; |
||||
|
/** |
||||
|
* 是否必填 |
||||
|
* @returns 是否必填 |
||||
|
*/ |
||||
|
required?: FormItemDependenciesCondition; |
||||
|
/** |
||||
|
* 字段规则 |
||||
|
*/ |
||||
|
rules?: FormItemDependenciesConditionWithRules; |
||||
|
/** |
||||
|
* 是否隐藏(Css) |
||||
|
* @returns 是否隐藏 |
||||
|
*/ |
||||
|
show?: FormItemDependenciesCondition; |
||||
|
/** |
||||
|
* 任意触发都会执行 |
||||
|
*/ |
||||
|
trigger?: FormItemDependenciesCondition<void>; |
||||
|
/** |
||||
|
* 触发字段 |
||||
|
*/ |
||||
|
triggerFields: string[]; |
||||
|
} |
||||
|
|
||||
|
type ComponentProps = |
||||
|
| (( |
||||
|
value: Partial<Record<string, any>>, |
||||
|
actions: FormActions, |
||||
|
) => MaybeComponentProps) |
||||
|
| MaybeComponentProps; |
||||
|
|
||||
|
export interface FormCommonConfig { |
||||
|
/** |
||||
|
* 所有表单项的props |
||||
|
*/ |
||||
|
componentProps?: ComponentProps; |
||||
|
/** |
||||
|
* 所有表单项的控件样式 |
||||
|
*/ |
||||
|
controlClass?: string; |
||||
|
/** |
||||
|
* 所有表单项的禁用状态 |
||||
|
* @default false |
||||
|
*/ |
||||
|
disabled?: boolean; |
||||
|
/** |
||||
|
* 所有表单项的控件样式 |
||||
|
* @default "" |
||||
|
*/ |
||||
|
formFieldProps?: Partial<typeof Field>; |
||||
|
/** |
||||
|
* 所有表单项的栅格布局 |
||||
|
* @default "" |
||||
|
*/ |
||||
|
formItemClass?: string; |
||||
|
/** |
||||
|
* 隐藏所有表单项label |
||||
|
* @default false |
||||
|
*/ |
||||
|
hideLabel?: boolean; |
||||
|
/** |
||||
|
* 是否隐藏必填标记 |
||||
|
* @default false |
||||
|
*/ |
||||
|
hideRequiredMark?: boolean; |
||||
|
/** |
||||
|
* 所有表单项的label样式 |
||||
|
* @default "w-[100px]" |
||||
|
*/ |
||||
|
labelClass?: string; |
||||
|
/** |
||||
|
* 所有表单项的label宽度 |
||||
|
*/ |
||||
|
labelWidth?: number; |
||||
|
/** |
||||
|
* 所有表单项的wrapper样式 |
||||
|
*/ |
||||
|
wrapperClass?: string; |
||||
|
} |
||||
|
|
||||
|
type RenderComponentContentType = ( |
||||
|
value: Partial<Record<string, any>>, |
||||
|
api: FormActions, |
||||
|
) => Record<string, any>; |
||||
|
|
||||
|
export type HandleSubmitFn = ( |
||||
|
values: Record<string, any>, |
||||
|
) => Promise<void> | void; |
||||
|
|
||||
|
export type HandleResetFn = ( |
||||
|
values: Record<string, any>, |
||||
|
) => Promise<void> | void; |
||||
|
|
||||
|
export interface FormSchema< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
> extends FormCommonConfig { |
||||
|
/** 组件 */ |
||||
|
component: Component | T; |
||||
|
/** 组件参数 */ |
||||
|
componentProps?: ComponentProps; |
||||
|
/** 默认值 */ |
||||
|
defaultValue?: any; |
||||
|
/** 依赖 */ |
||||
|
dependencies?: FormItemDependencies; |
||||
|
/** 描述 */ |
||||
|
description?: string; |
||||
|
/** 字段名 */ |
||||
|
fieldName: string; |
||||
|
/** 帮助信息 */ |
||||
|
help?: string; |
||||
|
/** 表单项 */ |
||||
|
label?: string; |
||||
|
// 自定义组件内部渲染
|
||||
|
renderComponentContent?: RenderComponentContentType; |
||||
|
/** 字段规则 */ |
||||
|
rules?: FormSchemaRuleType; |
||||
|
/** 后缀 */ |
||||
|
suffix?: CustomRenderType; |
||||
|
} |
||||
|
|
||||
|
export interface FormFieldProps extends FormSchema { |
||||
|
required?: boolean; |
||||
|
} |
||||
|
|
||||
|
export interface FormRenderProps< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
> { |
||||
|
/** |
||||
|
* 是否展开,在showCollapseButton=true下生效 |
||||
|
*/ |
||||
|
collapsed?: boolean; |
||||
|
/** |
||||
|
* 折叠时保持行数 |
||||
|
* @default 1 |
||||
|
*/ |
||||
|
collapsedRows?: number; |
||||
|
/** |
||||
|
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置 |
||||
|
*/ |
||||
|
commonConfig?: FormCommonConfig; |
||||
|
/** |
||||
|
* 组件v-model事件绑定 |
||||
|
*/ |
||||
|
componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>; |
||||
|
/** |
||||
|
* 组件集合 |
||||
|
*/ |
||||
|
componentMap: Record<BaseFormComponentType, Component>; |
||||
|
/** |
||||
|
* 表单实例 |
||||
|
*/ |
||||
|
form?: FormContext<GenericObject>; |
||||
|
/** |
||||
|
* 表单项布局 |
||||
|
*/ |
||||
|
layout?: FormLayout; |
||||
|
/** |
||||
|
* 表单定义 |
||||
|
*/ |
||||
|
schema?: FormSchema<T>[]; |
||||
|
/** |
||||
|
* 是否显示展开/折叠 |
||||
|
*/ |
||||
|
showCollapseButton?: boolean; |
||||
|
/** |
||||
|
* 表单栅格布局 |
||||
|
* @default "grid-cols-1" |
||||
|
*/ |
||||
|
wrapperClass?: WrapperClassType; |
||||
|
} |
||||
|
|
||||
|
export interface ActionButtonOptions extends VbenButtonProps { |
||||
|
show?: boolean; |
||||
|
text?: string; |
||||
|
} |
||||
|
|
||||
|
export interface VbenFormProps< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
> extends Omit< |
||||
|
FormRenderProps<T>, |
||||
|
'componentBindEventMap' | 'componentMap' | 'form' |
||||
|
> { |
||||
|
/** |
||||
|
* 表单操作区域class |
||||
|
*/ |
||||
|
actionWrapperClass?: any; |
||||
|
/** |
||||
|
* 表单重置回调 |
||||
|
*/ |
||||
|
handleReset?: HandleResetFn; |
||||
|
/** |
||||
|
* 表单提交回调 |
||||
|
*/ |
||||
|
handleSubmit?: HandleSubmitFn; |
||||
|
/** |
||||
|
* 重置按钮参数 |
||||
|
*/ |
||||
|
resetButtonOptions?: ActionButtonOptions; |
||||
|
|
||||
|
/** |
||||
|
* 是否显示默认操作按钮 |
||||
|
*/ |
||||
|
showDefaultActions?: boolean; |
||||
|
|
||||
|
/** |
||||
|
* 提交按钮参数 |
||||
|
*/ |
||||
|
submitButtonOptions?: ActionButtonOptions; |
||||
|
} |
||||
|
|
||||
|
export type ExtendedFormApi = { |
||||
|
useStore: <T = NoInfer<VbenFormProps>>( |
||||
|
selector?: (state: NoInfer<VbenFormProps>) => T, |
||||
|
) => Readonly<Ref<T>>; |
||||
|
} & FormApi; |
||||
|
|
||||
|
export interface VbenFormAdapterOptions< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
> { |
||||
|
components: Partial<Record<T, Component>>; |
||||
|
config?: { |
||||
|
baseModelPropName?: string; |
||||
|
modelPropNameMap?: Partial<Record<T, string>>; |
||||
|
}; |
||||
|
defineRules?: { |
||||
|
required?: ( |
||||
|
value: any, |
||||
|
params: any, |
||||
|
ctx: Record<string, any>, |
||||
|
) => boolean | string; |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,59 @@ |
|||||
|
import type { FormActions, VbenFormProps } from './types'; |
||||
|
|
||||
|
import { computed, type ComputedRef, unref, useSlots } from 'vue'; |
||||
|
|
||||
|
import { createContext } from '@vben-core/shadcn-ui'; |
||||
|
import { isString } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { useForm } from 'vee-validate'; |
||||
|
import { object, type ZodRawShape } from 'zod'; |
||||
|
import { getDefaultsForSchema } from 'zod-defaults'; |
||||
|
|
||||
|
export const [injectFormProps, provideFormProps] = |
||||
|
createContext<[ComputedRef<VbenFormProps> | VbenFormProps, FormActions]>( |
||||
|
'VbenFormProps', |
||||
|
); |
||||
|
|
||||
|
export function useFormInitial( |
||||
|
props: ComputedRef<VbenFormProps> | VbenFormProps, |
||||
|
) { |
||||
|
const slots = useSlots(); |
||||
|
const initialValues = generateInitialValues(); |
||||
|
|
||||
|
const form = useForm({ |
||||
|
...(Object.keys(initialValues)?.length ? { initialValues } : {}), |
||||
|
}); |
||||
|
|
||||
|
const delegatedSlots = computed(() => { |
||||
|
const resultSlots: string[] = []; |
||||
|
|
||||
|
for (const key of Object.keys(slots)) { |
||||
|
if (key !== 'default') { |
||||
|
resultSlots.push(key); |
||||
|
} |
||||
|
} |
||||
|
return resultSlots; |
||||
|
}); |
||||
|
|
||||
|
function generateInitialValues() { |
||||
|
const initialValues: Record<string, any> = {}; |
||||
|
|
||||
|
const zodObject: ZodRawShape = {}; |
||||
|
(unref(props).schema || []).forEach((item) => { |
||||
|
if (Reflect.has(item, 'defaultValue')) { |
||||
|
initialValues[item.fieldName] = item.defaultValue; |
||||
|
} else if (item.rules && !isString(item.rules)) { |
||||
|
zodObject[item.fieldName] = item.rules; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const schemaInitialValues = getDefaultsForSchema(object(zodObject)); |
||||
|
|
||||
|
return { ...initialValues, ...schemaInitialValues }; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
delegatedSlots, |
||||
|
form, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import type { |
||||
|
BaseFormComponentType, |
||||
|
ExtendedFormApi, |
||||
|
VbenFormProps, |
||||
|
} from './types'; |
||||
|
|
||||
|
import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue'; |
||||
|
|
||||
|
import { useStore } from '@vben-core/shared/store'; |
||||
|
|
||||
|
import { FormApi } from './form-api'; |
||||
|
import VbenUseForm from './vben-use-form.vue'; |
||||
|
|
||||
|
export function useVbenForm< |
||||
|
T extends BaseFormComponentType = BaseFormComponentType, |
||||
|
>(options: VbenFormProps<T>) { |
||||
|
const IS_REACTIVE = isReactive(options); |
||||
|
const api = new FormApi(options); |
||||
|
const extendedApi: ExtendedFormApi = api as never; |
||||
|
extendedApi.useStore = (selector) => { |
||||
|
return useStore(api.store, selector); |
||||
|
}; |
||||
|
|
||||
|
const Form = defineComponent( |
||||
|
(props: VbenFormProps, { attrs, slots }) => { |
||||
|
onBeforeUnmount(() => { |
||||
|
api.unmounted(); |
||||
|
}); |
||||
|
return () => |
||||
|
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots); |
||||
|
}, |
||||
|
{ |
||||
|
inheritAttrs: false, |
||||
|
name: 'VbenUseForm', |
||||
|
}, |
||||
|
); |
||||
|
// Add reactivity support
|
||||
|
if (IS_REACTIVE) { |
||||
|
watch( |
||||
|
() => options.schema, |
||||
|
() => { |
||||
|
api.setState({ schema: options.schema }); |
||||
|
}, |
||||
|
{ immediate: true }, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return [Form, extendedApi] as const; |
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { VbenFormProps } from './types'; |
||||
|
|
||||
|
import { ref, watchEffect } from 'vue'; |
||||
|
|
||||
|
import { useForwardPropsEmits } from '@vben-core/composables'; |
||||
|
|
||||
|
import FormActions from './components/form-actions.vue'; |
||||
|
import { COMPONENT_BIND_EVENT_MAP, COMPONENT_MAP } from './config'; |
||||
|
import { Form } from './form-render'; |
||||
|
import { provideFormProps, useFormInitial } from './use-form-context'; |
||||
|
|
||||
|
// 通过 extends 会导致热更新卡死 |
||||
|
interface Props extends VbenFormProps {} |
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
actionWrapperClass: '', |
||||
|
collapsed: false, |
||||
|
collapsedRows: 1, |
||||
|
commonConfig: () => ({}), |
||||
|
handleReset: undefined, |
||||
|
handleSubmit: undefined, |
||||
|
layout: 'horizontal', |
||||
|
resetButtonOptions: () => ({}), |
||||
|
showCollapseButton: false, |
||||
|
showDefaultActions: true, |
||||
|
submitButtonOptions: () => ({}), |
||||
|
wrapperClass: 'grid-cols-1', |
||||
|
}); |
||||
|
|
||||
|
const forward = useForwardPropsEmits(props); |
||||
|
|
||||
|
const currentCollapsed = ref(false); |
||||
|
|
||||
|
const { delegatedSlots, form } = useFormInitial(props); |
||||
|
|
||||
|
provideFormProps([props, form]); |
||||
|
|
||||
|
const handleUpdateCollapsed = (value: boolean) => { |
||||
|
currentCollapsed.value = !!value; |
||||
|
}; |
||||
|
|
||||
|
watchEffect(() => { |
||||
|
currentCollapsed.value = props.collapsed; |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Form |
||||
|
v-bind="forward" |
||||
|
:collapsed="currentCollapsed" |
||||
|
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP" |
||||
|
:component-map="COMPONENT_MAP" |
||||
|
:form="form" |
||||
|
> |
||||
|
<template |
||||
|
v-for="slotName in delegatedSlots" |
||||
|
:key="slotName" |
||||
|
#[slotName]="slotProps" |
||||
|
> |
||||
|
<slot :name="slotName" v-bind="slotProps"></slot> |
||||
|
</template> |
||||
|
<template #default="slotProps"> |
||||
|
<slot v-bind="slotProps"> |
||||
|
<FormActions |
||||
|
v-if="showDefaultActions" |
||||
|
:model-value="currentCollapsed" |
||||
|
@update:model-value="handleUpdateCollapsed" |
||||
|
/> |
||||
|
</slot> |
||||
|
</template> |
||||
|
</Form> |
||||
|
</template> |
||||
@ -0,0 +1,57 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { ExtendedFormApi, VbenFormProps } from './types'; |
||||
|
|
||||
|
import { useForwardPriorityValues } from '@vben-core/composables'; |
||||
|
|
||||
|
import FormActions from './components/form-actions.vue'; |
||||
|
import { COMPONENT_BIND_EVENT_MAP, COMPONENT_MAP } from './config'; |
||||
|
import { Form } from './form-render'; |
||||
|
import { provideFormProps, useFormInitial } from './use-form-context'; |
||||
|
|
||||
|
// 通过 extends 会导致热更新卡死,所以重复写了一遍 |
||||
|
interface Props extends VbenFormProps { |
||||
|
formApi: ExtendedFormApi; |
||||
|
} |
||||
|
|
||||
|
const props = defineProps<Props>(); |
||||
|
|
||||
|
const state = props.formApi?.useStore?.(); |
||||
|
|
||||
|
const forward = useForwardPriorityValues(props, state); |
||||
|
|
||||
|
const { delegatedSlots, form } = useFormInitial(forward); |
||||
|
|
||||
|
provideFormProps([forward, form]); |
||||
|
|
||||
|
props.formApi?.mount?.(form); |
||||
|
|
||||
|
const handleUpdateCollapsed = (value: boolean) => { |
||||
|
props.formApi?.setState({ collapsed: !!value }); |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Form |
||||
|
v-bind="forward" |
||||
|
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP" |
||||
|
:component-map="COMPONENT_MAP" |
||||
|
:form="form" |
||||
|
> |
||||
|
<template |
||||
|
v-for="slotName in delegatedSlots" |
||||
|
:key="slotName" |
||||
|
#[slotName]="slotProps" |
||||
|
> |
||||
|
<slot :name="slotName" v-bind="slotProps"></slot> |
||||
|
</template> |
||||
|
<template #default="slotProps"> |
||||
|
<slot v-bind="slotProps"> |
||||
|
<FormActions |
||||
|
v-if="forward.showDefaultActions" |
||||
|
:model-value="state.collapsed" |
||||
|
@update:model-value="handleUpdateCollapsed" |
||||
|
/> |
||||
|
</slot> |
||||
|
</template> |
||||
|
</Form> |
||||
|
</template> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default } from '@vben/tailwind-config'; |
||||
@ -0,0 +1,6 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/tsconfig", |
||||
|
"extends": "@vben/tsconfig/web.json", |
||||
|
"include": ["src"], |
||||
|
"exclude": ["node_modules"] |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
import type { AsTag } from 'radix-vue'; |
||||
|
|
||||
|
import type { ButtonVariants, ButtonVariantSize } from '../ui/button'; |
||||
|
|
||||
|
import type { Component } from 'vue'; |
||||
|
|
||||
|
export interface VbenButtonProps { |
||||
|
/** |
||||
|
* The element or component this component should render as. Can be overwrite by `asChild` |
||||
|
* @defaultValue "div" |
||||
|
*/ |
||||
|
as?: AsTag | Component; |
||||
|
/** |
||||
|
* Change the default rendered element for the one passed as a child, merging their props and behavior. |
||||
|
* |
||||
|
* Read our [Composition](https://www.radix-vue.com/guides/composition.html) guide for more details.
|
||||
|
*/ |
||||
|
asChild?: boolean; |
||||
|
class?: any; |
||||
|
disabled?: boolean; |
||||
|
loading?: boolean; |
||||
|
size?: ButtonVariantSize; |
||||
|
variant?: ButtonVariants; |
||||
|
} |
||||
@ -1,2 +1,3 @@ |
|||||
|
export type * from './button'; |
||||
export { default as VbenButton } from './button.vue'; |
export { default as VbenButton } from './button.vue'; |
||||
export { default as VbenIconButton } from './icon-button.vue'; |
export { default as VbenIconButton } from './icon-button.vue'; |
||||
|
|||||
@ -1,24 +1,26 @@ |
|||||
<script setup lang="ts"> |
<script setup lang="ts"> |
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'; |
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'; |
||||
|
|
||||
|
import { useId } from 'vue'; |
||||
|
|
||||
import { useForwardPropsEmits } from 'radix-vue'; |
import { useForwardPropsEmits } from 'radix-vue'; |
||||
|
|
||||
import { Checkbox } from '../ui/checkbox'; |
import { Checkbox } from '../ui/checkbox'; |
||||
|
|
||||
const props = defineProps< |
const props = defineProps<CheckboxRootProps>(); |
||||
{ |
|
||||
name: string; |
|
||||
} & CheckboxRootProps |
|
||||
>(); |
|
||||
|
|
||||
const emits = defineEmits<CheckboxRootEmits>(); |
const emits = defineEmits<CheckboxRootEmits>(); |
||||
|
|
||||
const checked = defineModel<boolean>('checked'); |
const checked = defineModel<boolean>('checked'); |
||||
|
|
||||
const forwarded = useForwardPropsEmits(props, emits); |
const forwarded = useForwardPropsEmits(props, emits); |
||||
|
|
||||
|
const id = useId(); |
||||
</script> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<Checkbox v-bind="forwarded" :id="name" v-model:checked="checked" /> |
<div class="flex items-center"> |
||||
<label :for="name" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label> |
<Checkbox v-bind="forwarded" :id="id" v-model:checked="checked" /> |
||||
|
<label :for="id" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label> |
||||
|
</div> |
||||
</template> |
</template> |
||||
|
|||||
@ -0,0 +1,36 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ChevronDown } from '@vben-core/icons'; |
||||
|
import { cn } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
const props = defineProps<{ |
||||
|
class?: string; |
||||
|
}>(); |
||||
|
|
||||
|
// 控制箭头展开/收起状态 |
||||
|
const collapsed = defineModel({ default: false }); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
:class=" |
||||
|
cn( |
||||
|
'text-primary hover:text-primary-hover inline-flex cursor-pointer items-center', |
||||
|
props.class, |
||||
|
) |
||||
|
" |
||||
|
@click="collapsed = !collapsed" |
||||
|
> |
||||
|
<slot :is-expanded="collapsed"> |
||||
|
{{ collapsed }} |
||||
|
<!-- <span>{{ isExpanded ? '收起' : '展开' }}</span> --> |
||||
|
</slot> |
||||
|
<div |
||||
|
:class="{ 'rotate-180': !collapsed }" |
||||
|
class="transition-transform duration-300" |
||||
|
> |
||||
|
<slot name="icon"> |
||||
|
<ChevronDown class="size-4" /> |
||||
|
</slot> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default as VbenExpandableArrow } from './expandable-arrow.vue'; |
||||
@ -1,2 +0,0 @@ |
|||||
export { default as VbenInput } from './input.vue'; |
|
||||
export type * from './types'; |
|
||||
@ -1,53 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import type { InputProps } from './types'; |
|
||||
|
|
||||
import { computed } from 'vue'; |
|
||||
|
|
||||
defineOptions({ |
|
||||
inheritAttrs: false, |
|
||||
}); |
|
||||
|
|
||||
const props = defineProps<InputProps>(); |
|
||||
|
|
||||
const modelValue = defineModel<number | string>(); |
|
||||
|
|
||||
const inputClass = computed(() => { |
|
||||
if (props.status === 'error') { |
|
||||
return 'border-destructive'; |
|
||||
} |
|
||||
return ''; |
|
||||
}); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<div class="relative mb-6"> |
|
||||
<label |
|
||||
v-if="!label" |
|
||||
:for="name" |
|
||||
class="mb-2 block text-sm font-medium dark:text-white" |
|
||||
> |
|
||||
{{ label }} |
|
||||
</label> |
|
||||
<input |
|
||||
:id="name" |
|
||||
v-model="modelValue" |
|
||||
:class="[props.class, inputClass]" |
|
||||
autocomplete="off" |
|
||||
class="border-input bg-input-background ring-offset-background placeholder:text-muted-foreground/60 focus-visible:ring-ring focus:border-primary flex h-10 w-full rounded-md border p-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" |
|
||||
required |
|
||||
type="text" |
|
||||
v-bind="$attrs" |
|
||||
/> |
|
||||
|
|
||||
<slot></slot> |
|
||||
|
|
||||
<Transition name="slide-up"> |
|
||||
<p |
|
||||
v-if="status === 'error'" |
|
||||
class="text-destructive bottom-130 absolute mt-1 text-xs" |
|
||||
> |
|
||||
{{ errorTip }} |
|
||||
</p> |
|
||||
</Transition> |
|
||||
</div> |
|
||||
</template> |
|
||||
@ -1,25 +0,0 @@ |
|||||
interface InputProps { |
|
||||
class?: any; |
|
||||
/** |
|
||||
* 错误提示信息 |
|
||||
*/ |
|
||||
errorTip?: string; |
|
||||
/** |
|
||||
* 输入框的 label |
|
||||
*/ |
|
||||
label?: string; |
|
||||
/** |
|
||||
* 输入框的 name |
|
||||
*/ |
|
||||
name?: string; |
|
||||
/** |
|
||||
* 是否显示密码强度 |
|
||||
*/ |
|
||||
passwordStrength?: boolean; |
|
||||
/** |
|
||||
* 输入框的校验状态 |
|
||||
*/ |
|
||||
status?: 'default' | 'error'; |
|
||||
} |
|
||||
|
|
||||
export type { InputProps }; |
|
||||
@ -1,38 +1,26 @@ |
|||||
interface PinInputProps { |
interface PinInputProps { |
||||
/** |
|
||||
* 发送验证码按钮loading |
|
||||
*/ |
|
||||
btnLoading?: boolean; |
|
||||
/** |
|
||||
* 发送验证码按钮文本 |
|
||||
*/ |
|
||||
btnText?: string; |
|
||||
class?: any; |
class?: any; |
||||
/** |
/** |
||||
* 验证码长度 |
* 验证码长度 |
||||
*/ |
*/ |
||||
codeLength?: number; |
codeLength?: number; |
||||
/** |
/** |
||||
* 错误提示信息 |
* 发送验证码按钮文本 |
||||
*/ |
*/ |
||||
errorTip?: string; |
createText?: (countdown: number) => string; |
||||
/** |
/** |
||||
* 自定义验证码发送逻辑 |
* 自定义验证码发送逻辑 |
||||
* @returns |
* @returns |
||||
*/ |
*/ |
||||
handleSendCode?: () => Promise<void>; |
handleSendCode?: () => Promise<void>; |
||||
/** |
/** |
||||
* 输入框的 label |
* 发送验证码按钮loading |
||||
*/ |
|
||||
label: string; |
|
||||
/** |
|
||||
* 输入框的 name |
|
||||
*/ |
*/ |
||||
name: string; |
loading?: boolean; |
||||
/** |
/** |
||||
* 输入框的校验状态 |
* 最大重试时间 |
||||
*/ |
*/ |
||||
status?: 'default' | 'error'; |
maxTime?: number; |
||||
} |
} |
||||
|
|
||||
export type { PinInputProps }; |
export type { PinInputProps }; |
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue