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> |
|||
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'; |
|||
|
|||
defineOptions({ name: 'Login' }); |
|||
|
|||
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> |
|||
|
|||
<template> |
|||
<AuthenticationLogin |
|||
:form-schema="formSchema" |
|||
:loading="authStore.loginLoading" |
|||
password-placeholder="123456" |
|||
username-placeholder="vben" |
|||
@submit="authStore.authLogin" |
|||
/> |
|||
</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> |
|||
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'; |
|||
|
|||
defineOptions({ name: 'Login' }); |
|||
|
|||
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> |
|||
|
|||
<template> |
|||
<AuthenticationLogin |
|||
:form-schema="formSchema" |
|||
:loading="authStore.loginLoading" |
|||
password-placeholder="123456" |
|||
username-placeholder="vben" |
|||
@submit="authStore.authLogin" |
|||
/> |
|||
</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> |
|||
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'; |
|||
|
|||
defineOptions({ name: 'Login' }); |
|||
|
|||
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> |
|||
|
|||
<template> |
|||
<AuthenticationLogin |
|||
:form-schema="formSchema" |
|||
:loading="authStore.loginLoading" |
|||
password-placeholder="123456" |
|||
username-placeholder="vben" |
|||
@submit="authStore.authLogin" |
|||
/> |
|||
</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 VbenIconButton } from './icon-button.vue'; |
|||
|
|||
@ -1,24 +1,26 @@ |
|||
<script setup lang="ts"> |
|||
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'; |
|||
|
|||
import { useId } from 'vue'; |
|||
|
|||
import { useForwardPropsEmits } from 'radix-vue'; |
|||
|
|||
import { Checkbox } from '../ui/checkbox'; |
|||
|
|||
const props = defineProps< |
|||
{ |
|||
name: string; |
|||
} & CheckboxRootProps |
|||
>(); |
|||
const props = defineProps<CheckboxRootProps>(); |
|||
|
|||
const emits = defineEmits<CheckboxRootEmits>(); |
|||
|
|||
const checked = defineModel<boolean>('checked'); |
|||
|
|||
const forwarded = useForwardPropsEmits(props, emits); |
|||
|
|||
const id = useId(); |
|||
</script> |
|||
|
|||
<template> |
|||
<Checkbox v-bind="forwarded" :id="name" v-model:checked="checked" /> |
|||
<label :for="name" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label> |
|||
<div class="flex items-center"> |
|||
<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> |
|||
|
|||
@ -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 { |
|||
/** |
|||
* 发送验证码按钮loading |
|||
*/ |
|||
btnLoading?: boolean; |
|||
/** |
|||
* 发送验证码按钮文本 |
|||
*/ |
|||
btnText?: string; |
|||
class?: any; |
|||
/** |
|||
* 验证码长度 |
|||
*/ |
|||
codeLength?: number; |
|||
/** |
|||
* 错误提示信息 |
|||
* 发送验证码按钮文本 |
|||
*/ |
|||
errorTip?: string; |
|||
createText?: (countdown: number) => string; |
|||
/** |
|||
* 自定义验证码发送逻辑 |
|||
* @returns |
|||
*/ |
|||
handleSendCode?: () => Promise<void>; |
|||
/** |
|||
* 输入框的 label |
|||
*/ |
|||
label: string; |
|||
/** |
|||
* 输入框的 name |
|||
* 发送验证码按钮loading |
|||
*/ |
|||
name: string; |
|||
loading?: boolean; |
|||
/** |
|||
* 输入框的校验状态 |
|||
* 最大重试时间 |
|||
*/ |
|||
status?: 'default' | 'error'; |
|||
maxTime?: number; |
|||
} |
|||
|
|||
export type { PinInputProps }; |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue