物业代码生成
This commit is contained in:
160
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
160
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw, unref, watch } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, triggerWindowResize } 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 {
|
||||
content: `${$t.value('reset')}`,
|
||||
show: true,
|
||||
...unref(rootProps).resetButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const submitButtonOptions = computed(() => {
|
||||
return {
|
||||
content: `${$t.value('submit')}`,
|
||||
show: true,
|
||||
...unref(rootProps).submitButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
// const isQueryForm = computed(() => {
|
||||
// return !!unref(rootProps).showCollapseButton;
|
||||
// });
|
||||
|
||||
const queryFormStyle = computed(() => {
|
||||
if (!unref(rootProps).actionWrapperClass) {
|
||||
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;
|
||||
}
|
||||
|
||||
const values = toRaw(await unref(rootProps).formApi?.getValues());
|
||||
await unref(rootProps).handleSubmit?.(values);
|
||||
}
|
||||
|
||||
async function handleReset(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const props = unref(rootProps);
|
||||
|
||||
const values = toRaw(await props.formApi?.getValues());
|
||||
|
||||
if (isFunction(props.handleReset)) {
|
||||
await props.handleReset?.(values);
|
||||
} else {
|
||||
form.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => collapsed.value,
|
||||
() => {
|
||||
const props = unref(rootProps);
|
||||
if (props.collapseTriggerResize) {
|
||||
triggerWindowResize();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
handleReset,
|
||||
handleSubmit,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full w-full text-right',
|
||||
rootProps.compact ? 'pb-2' : 'pb-6',
|
||||
rootProps.actionWrapperClass,
|
||||
)
|
||||
"
|
||||
:style="queryFormStyle"
|
||||
>
|
||||
<template v-if="rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 重置按钮前 -->
|
||||
<slot name="reset-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.DefaultButton"
|
||||
v-if="resetButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleReset"
|
||||
v-bind="resetButtonOptions"
|
||||
>
|
||||
{{ resetButtonOptions.content }}
|
||||
</component>
|
||||
|
||||
<template v-if="!rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
class="ml-3"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 展开按钮前 -->
|
||||
<slot name="expand-before"></slot>
|
||||
|
||||
<VbenExpandableArrow
|
||||
v-if="rootProps.showCollapseButton"
|
||||
v-model:model-value="collapsed"
|
||||
class="ml-2"
|
||||
>
|
||||
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
|
||||
</VbenExpandableArrow>
|
||||
|
||||
<!-- 展开按钮后 -->
|
||||
<slot name="expand-after"></slot>
|
||||
</div>
|
||||
</template>
|
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
87
packages/@core/ui-kit/form-ui/src/config.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type {
|
||||
BaseFormComponentType,
|
||||
FormCommonConfig,
|
||||
VbenFormAdapterOptions,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
VbenButton,
|
||||
VbenCheckbox,
|
||||
Input as VbenInput,
|
||||
VbenInputCaptcha,
|
||||
VbenInputPassword,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { defineRule } from 'vee-validate';
|
||||
import { h } from 'vue';
|
||||
|
||||
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
|
||||
|
||||
export const DEFAULT_FORM_COMMON_CONFIG: FormCommonConfig = {};
|
||||
|
||||
export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
|
||||
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
|
||||
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
|
||||
VbenCheckbox,
|
||||
VbenInput,
|
||||
VbenInputCaptcha,
|
||||
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 { config, defineRules } = options;
|
||||
|
||||
const {
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
} = (config || {}) as FormCommonConfig;
|
||||
|
||||
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
596
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
596
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import type {
|
||||
FormState,
|
||||
GenericObject,
|
||||
ResetFormOpts,
|
||||
ValidationOptions,
|
||||
} from 'vee-validate';
|
||||
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { FormActions, FormSchema, VbenFormProps } from './types';
|
||||
|
||||
import { isRef, toRaw } from 'vue';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
createMerge,
|
||||
formatDate,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
isFunction,
|
||||
isObject,
|
||||
mergeWithArrayOverride,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
collapseTriggerResize: false,
|
||||
commonConfig: {},
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
handleValuesChange: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
submitOnChange: false,
|
||||
submitOnEnter: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
};
|
||||
}
|
||||
|
||||
export class FormApi {
|
||||
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
|
||||
public form = {} as FormActions;
|
||||
isMounted = false;
|
||||
|
||||
public state: null | VbenFormProps = null;
|
||||
stateHandler: StateHandler;
|
||||
|
||||
public store: Store<VbenFormProps>;
|
||||
|
||||
/**
|
||||
* 组件实例映射
|
||||
*/
|
||||
private componentRefMap: Map<string, unknown> = new Map();
|
||||
|
||||
// 最后一次点击提交时的表单值
|
||||
private latestSubmissionValues: null | Recordable<any> = null;
|
||||
|
||||
private prevState: null | VbenFormProps = null;
|
||||
|
||||
constructor(options: VbenFormProps = {}) {
|
||||
const { ...storeState } = options;
|
||||
|
||||
const defaultState = getDefaultState();
|
||||
|
||||
this.store = new Store<VbenFormProps>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
this.prevState = this.state;
|
||||
this.state = this.store.state;
|
||||
this.updateState();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.state = this.store.state;
|
||||
this.stateHandler = new StateHandler();
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段组件实例
|
||||
* @param fieldName 字段名
|
||||
* @returns 组件实例
|
||||
*/
|
||||
getFieldComponentRef<T = ComponentPublicInstance>(
|
||||
fieldName: string,
|
||||
): T | undefined {
|
||||
let target = this.componentRefMap.has(fieldName)
|
||||
? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
|
||||
: undefined;
|
||||
if (
|
||||
target &&
|
||||
target.$.type.name === 'AsyncComponentWrapper' &&
|
||||
target.$.subTree.ref
|
||||
) {
|
||||
if (Array.isArray(target.$.subTree.ref)) {
|
||||
if (
|
||||
target.$.subTree.ref.length > 0 &&
|
||||
isRef(target.$.subTree.ref[0]?.r)
|
||||
) {
|
||||
target = target.$.subTree.ref[0]?.r.value as ComponentPublicInstance;
|
||||
}
|
||||
} else if (isRef(target.$.subTree.ref.r)) {
|
||||
target = target.$.subTree.ref.r.value as ComponentPublicInstance;
|
||||
}
|
||||
}
|
||||
return target as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前聚焦的字段,如果没有聚焦的字段则返回undefined
|
||||
*/
|
||||
getFocusedField() {
|
||||
for (const fieldName of this.componentRefMap.keys()) {
|
||||
const ref = this.getFieldComponentRef(fieldName);
|
||||
if (ref) {
|
||||
let el: HTMLElement | null = null;
|
||||
if (ref instanceof HTMLElement) {
|
||||
el = ref;
|
||||
} else if (ref.$el instanceof HTMLElement) {
|
||||
el = ref.$el;
|
||||
}
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
el === document.activeElement ||
|
||||
el.contains(document.activeElement)
|
||||
) {
|
||||
return fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getLatestSubmissionValues() {
|
||||
return this.latestSubmissionValues || {};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async getValues<T = Recordable<any>>() {
|
||||
const form = await this.getForm();
|
||||
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
|
||||
}
|
||||
|
||||
async isFieldValid(fieldName: string) {
|
||||
const form = await this.getForm();
|
||||
return form.isFieldValid(fieldName);
|
||||
}
|
||||
|
||||
merge(formApi: FormApi) {
|
||||
const chain = [this, formApi];
|
||||
const proxy = new Proxy(formApi, {
|
||||
get(target: any, prop: any) {
|
||||
if (prop === 'merge') {
|
||||
return (nextFormApi: FormApi) => {
|
||||
chain.push(nextFormApi);
|
||||
return proxy;
|
||||
};
|
||||
}
|
||||
if (prop === 'submitAllForm') {
|
||||
return async (needMerge: boolean = true) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
chain.map(async (api) => {
|
||||
const validateResult = await api.validate();
|
||||
if (!validateResult.valid) {
|
||||
return;
|
||||
}
|
||||
const rawValues = toRaw((await api.getValues()) || {});
|
||||
return rawValues;
|
||||
}),
|
||||
);
|
||||
if (needMerge) {
|
||||
const mergedResults = Object.assign({}, ...results);
|
||||
return mergedResults;
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
|
||||
if (!this.isMounted) {
|
||||
Object.assign(this.form, formActions);
|
||||
this.stateHandler.setConditionTrue();
|
||||
this.setLatestSubmissionValues({
|
||||
...toRaw(this.handleRangeTimeValue(this.form.values)),
|
||||
});
|
||||
this.componentRefMap = componentRefMap;
|
||||
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);
|
||||
}
|
||||
|
||||
setLatestSubmissionValues(values: null | Recordable<any>) {
|
||||
this.latestSubmissionValues = { ...toRaw(values) };
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: VbenFormProps) => Partial<VbenFormProps>)
|
||||
| Partial<VbenFormProps>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState((prev) => {
|
||||
return mergeWithArrayOverride(stateOrFn(prev), prev);
|
||||
});
|
||||
} else {
|
||||
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单值
|
||||
* @param fields record
|
||||
* @param filterFields 过滤不在schema中定义的字段 默认为true
|
||||
* @param shouldValidate
|
||||
*/
|
||||
async setValues(
|
||||
fields: Record<string, any>,
|
||||
filterFields: boolean = true,
|
||||
shouldValidate: boolean = false,
|
||||
) {
|
||||
const form = await this.getForm();
|
||||
if (!filterFields) {
|
||||
form.setValues(fields, shouldValidate);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并算法有待改进,目前的算法不支持object类型的值。
|
||||
* antd的日期时间相关组件的值类型为dayjs对象
|
||||
* element-plus的日期时间相关组件的值类型可能为Date对象
|
||||
* 以上两种类型需要排除深度合并
|
||||
*/
|
||||
const fieldMergeFn = createMerge((obj, key, value) => {
|
||||
if (key in obj) {
|
||||
obj[key] =
|
||||
!Array.isArray(obj[key]) &&
|
||||
isObject(obj[key]) &&
|
||||
!isDayjsObject(obj[key]) &&
|
||||
!isDate(obj[key])
|
||||
? fieldMergeFn(obj[key], value)
|
||||
: value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const filteredFields = fieldMergeFn(fields, form.values);
|
||||
this.handleStringToArrayFields(filteredFields);
|
||||
form.setValues(filteredFields, shouldValidate);
|
||||
}
|
||||
|
||||
async submitForm(e?: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const form = await this.getForm();
|
||||
await form.submitForm();
|
||||
const rawValues = toRaw(await this.getValues());
|
||||
this.handleArrayToStringFields(rawValues);
|
||||
await this.state?.handleSubmit?.(rawValues);
|
||||
|
||||
return rawValues;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.form?.resetForm?.();
|
||||
// this.state = null;
|
||||
this.latestSubmissionValues = null;
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
}
|
||||
|
||||
updateSchema(schema: Partial<FormSchema>[]) {
|
||||
const updated: Partial<FormSchema>[] = [...schema];
|
||||
const hasField = updated.every(
|
||||
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
|
||||
);
|
||||
|
||||
if (!hasField) {
|
||||
console.error(
|
||||
'All items in the schema array must have a valid `fieldName` property to be updated',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentSchema = [...(this.state?.schema ?? [])];
|
||||
|
||||
const updatedMap: Record<string, any> = {};
|
||||
|
||||
updated.forEach((item) => {
|
||||
if (item.fieldName) {
|
||||
updatedMap[item.fieldName] = item;
|
||||
}
|
||||
});
|
||||
|
||||
currentSchema.forEach((schema, index) => {
|
||||
const updatedData = updatedMap[schema.fieldName];
|
||||
if (updatedData) {
|
||||
currentSchema[index] = mergeWithArrayOverride(
|
||||
updatedData,
|
||||
schema,
|
||||
) as FormSchema;
|
||||
}
|
||||
});
|
||||
this.setState({ schema: currentSchema });
|
||||
}
|
||||
|
||||
async validate(opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
|
||||
const validateResult = await form.validate(opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
async validateAndSubmitForm() {
|
||||
const form = await this.getForm();
|
||||
const { valid } = await form.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
return await this.submitForm();
|
||||
}
|
||||
|
||||
async validateField(fieldName: string, opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
const validateResult = await form.validateField(fieldName, opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private handleArrayToStringFields = (originValues: Record<string, any>) => {
|
||||
const arrayToStringFields = this.state?.arrayToStringFields;
|
||||
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processFields = (fields: string[], separator: string = ',') => {
|
||||
this.processFields(fields, separator, originValues, (value, sep) =>
|
||||
Array.isArray(value) ? value.join(sep) : value,
|
||||
);
|
||||
};
|
||||
|
||||
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
|
||||
if (arrayToStringFields.every((item) => typeof item === 'string')) {
|
||||
const lastItem =
|
||||
arrayToStringFields[arrayToStringFields.length - 1] || '';
|
||||
const fields =
|
||||
lastItem.length === 1
|
||||
? arrayToStringFields.slice(0, -1)
|
||||
: arrayToStringFields;
|
||||
const separator = lastItem.length === 1 ? lastItem : ',';
|
||||
processFields(fields, separator);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理嵌套数组格式 [['field1'], ';']
|
||||
arrayToStringFields.forEach((fieldConfig) => {
|
||||
if (Array.isArray(fieldConfig)) {
|
||||
const [fields, separator = ','] = fieldConfig;
|
||||
// 根据类型定义,fields 应该始终是字符串数组
|
||||
if (!Array.isArray(fields)) {
|
||||
console.warn(
|
||||
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
processFields(fields, separator);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private handleRangeTimeValue = (originValues: Record<string, any>) => {
|
||||
const values = { ...originValues };
|
||||
const fieldMappingTime = this.state?.fieldMappingTime;
|
||||
|
||||
this.handleStringToArrayFields(values);
|
||||
|
||||
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
fieldMappingTime.forEach(
|
||||
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
|
||||
if (startTimeKey && endTimeKey && values[field] === null) {
|
||||
Reflect.deleteProperty(values, startTimeKey);
|
||||
Reflect.deleteProperty(values, endTimeKey);
|
||||
// delete values[startTimeKey];
|
||||
// delete values[endTimeKey];
|
||||
}
|
||||
|
||||
if (!values[field]) {
|
||||
Reflect.deleteProperty(values, field);
|
||||
// delete values[field];
|
||||
return;
|
||||
}
|
||||
|
||||
const [startTime, endTime] = values[field];
|
||||
if (format === null) {
|
||||
values[startTimeKey] = startTime;
|
||||
values[endTimeKey] = endTime;
|
||||
} else if (isFunction(format)) {
|
||||
values[startTimeKey] = format(startTime, startTimeKey);
|
||||
values[endTimeKey] = format(endTime, endTimeKey);
|
||||
} else {
|
||||
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
|
||||
? format
|
||||
: [format, format];
|
||||
|
||||
values[startTimeKey] = startTime
|
||||
? formatDate(startTime, startTimeFormat)
|
||||
: undefined;
|
||||
values[endTimeKey] = endTime
|
||||
? formatDate(endTime, endTimeFormat)
|
||||
: undefined;
|
||||
}
|
||||
// delete values[field];
|
||||
Reflect.deleteProperty(values, field);
|
||||
},
|
||||
);
|
||||
return values;
|
||||
};
|
||||
|
||||
private handleStringToArrayFields = (originValues: Record<string, any>) => {
|
||||
const arrayToStringFields = this.state?.arrayToStringFields;
|
||||
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processFields = (fields: string[], separator: string = ',') => {
|
||||
this.processFields(fields, separator, originValues, (value, sep) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
// 处理空字符串的情况
|
||||
if (value === '') {
|
||||
return [];
|
||||
}
|
||||
// 处理复杂分隔符的情况
|
||||
const escapedSeparator = sep.replaceAll(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
return value.split(new RegExp(escapedSeparator));
|
||||
});
|
||||
};
|
||||
|
||||
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
|
||||
if (arrayToStringFields.every((item) => typeof item === 'string')) {
|
||||
const lastItem =
|
||||
arrayToStringFields[arrayToStringFields.length - 1] || '';
|
||||
const fields =
|
||||
lastItem.length === 1
|
||||
? arrayToStringFields.slice(0, -1)
|
||||
: arrayToStringFields;
|
||||
const separator = lastItem.length === 1 ? lastItem : ',';
|
||||
processFields(fields, separator);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理嵌套数组格式 [['field1'], ';']
|
||||
arrayToStringFields.forEach((fieldConfig) => {
|
||||
if (Array.isArray(fieldConfig)) {
|
||||
const [fields, separator = ','] = fieldConfig;
|
||||
if (Array.isArray(fields)) {
|
||||
processFields(fields, separator);
|
||||
} else if (typeof originValues[fields] === 'string') {
|
||||
const value = originValues[fields];
|
||||
if (value === '') {
|
||||
originValues[fields] = [];
|
||||
} else {
|
||||
const escapedSeparator = separator.replaceAll(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
originValues[fields] = value.split(new RegExp(escapedSeparator));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private processFields = (
|
||||
fields: string[],
|
||||
separator: string,
|
||||
originValues: Record<string, any>,
|
||||
transformFn: (value: any, separator: string) => any,
|
||||
) => {
|
||||
fields.forEach((field) => {
|
||||
const value = originValues[field];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
originValues[field] = transformFn(value, separator);
|
||||
});
|
||||
};
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
// 进行了删除schema操作
|
||||
if (currentSchema.length < prevSchema.length) {
|
||||
const currentFields = new Set(
|
||||
currentSchema.map((item) => item.fieldName),
|
||||
);
|
||||
const deletedSchema = prevSchema.filter(
|
||||
(item) => !currentFields.has(item.fieldName),
|
||||
);
|
||||
for (const schema of deletedSchema) {
|
||||
this.form?.setFieldValue?.(schema.fieldName, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
24
packages/@core/ui-kit/form-ui/src/form-render/context.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
124
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
FormItemDependencies,
|
||||
FormSchemaRuleType,
|
||||
MaybeComponentProps,
|
||||
} from '../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { isBoolean, 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;
|
||||
} else if (isBoolean(whenIf)) {
|
||||
isIf.value = whenIf;
|
||||
if (!isIf.value) return;
|
||||
}
|
||||
|
||||
// 2. 判断show,如果show为false,则隐藏
|
||||
if (isFunction(show)) {
|
||||
isShow.value = !!(await show(formValues, formApi));
|
||||
if (!isShow.value) return;
|
||||
} else if (isBoolean(show)) {
|
||||
isShow.value = show;
|
||||
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));
|
||||
} else if (isBoolean(disabled)) {
|
||||
isDisabled.value = disabled;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
105
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
105
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FormRenderProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints,
|
||||
useElementVisibility,
|
||||
} from '@vueuse/core';
|
||||
|
||||
/**
|
||||
* 动态计算行数
|
||||
*/
|
||||
export function useExpandable(props: FormRenderProps) {
|
||||
const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
|
||||
const isVisible = useElementVisibility(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 || 1;
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.showCollapseButton,
|
||||
() => breakpoints.active().value,
|
||||
() => props.schema?.length,
|
||||
() => isVisible.value,
|
||||
],
|
||||
async ([val]) => {
|
||||
if (val) {
|
||||
await nextTick();
|
||||
rowMapping.value = {};
|
||||
isCalculated.value = false;
|
||||
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 };
|
||||
}
|
394
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
394
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<script setup lang="ts">
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import type { FormSchema, MaybeComponentProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { CircleAlert } from '@vben-core/icons';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
VbenRenderContent,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { useFieldError, useFormValues } from 'vee-validate';
|
||||
|
||||
import { injectComponentRefMap } from '../use-form-context';
|
||||
import { injectRenderFormProps, useFormContext } from './context';
|
||||
import useDependencies from './dependencies';
|
||||
import FormLabel from './form-label.vue';
|
||||
import { isEventObjectLike } from './helper';
|
||||
|
||||
interface Props extends FormSchema {}
|
||||
|
||||
const {
|
||||
colon,
|
||||
commonComponentProps,
|
||||
component,
|
||||
componentProps,
|
||||
dependencies,
|
||||
description,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
fieldName,
|
||||
formFieldProps,
|
||||
label,
|
||||
labelClass,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
renderComponentContent,
|
||||
rules,
|
||||
} = defineProps<
|
||||
Props & {
|
||||
commonComponentProps: MaybeComponentProps;
|
||||
}
|
||||
>();
|
||||
|
||||
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
const values = useFormValues();
|
||||
const errors = useFieldError(fieldName);
|
||||
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
|
||||
const formApi = formRenderProps.form;
|
||||
const compact = formRenderProps.compact;
|
||||
const isInValid = computed(() => errors.value?.length > 0);
|
||||
|
||||
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 visible = computed(() => {
|
||||
return isIf.value && isShow.value;
|
||||
});
|
||||
|
||||
const shouldRequired = computed(() => {
|
||||
if (!visible.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentRules.value) {
|
||||
return isRequired.value;
|
||||
}
|
||||
|
||||
if (isRequired.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isString(currentRules.value)) {
|
||||
return ['required', 'selectRequired'].includes(currentRules.value);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!visible.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
...commonComponentProps,
|
||||
...finalComponentProps,
|
||||
...dynamicComponentProps.value,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => computedProps.value?.autofocus,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
nextTick(() => {
|
||||
autofocus();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
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: isString(label) ? label : '',
|
||||
...(rules ? { rules } : {}),
|
||||
...(formFieldProps as Record<string, any>),
|
||||
};
|
||||
});
|
||||
|
||||
function fieldBindEvent(slotProps: Record<string, any>) {
|
||||
const modelValue = slotProps.componentField.modelValue;
|
||||
const handler = slotProps.componentField['onUpdate:modelValue'];
|
||||
|
||||
const bindEventField =
|
||||
modelPropName ||
|
||||
(isString(component) ? componentBindEventMap.value?.[component] : null);
|
||||
|
||||
let value = modelValue;
|
||||
// antd design 的一些组件会传递一个 event 对象
|
||||
if (modelValue && isObject(modelValue) && bindEventField) {
|
||||
value = isEventObjectLike(modelValue)
|
||||
? modelValue?.target?.[bindEventField]
|
||||
: (modelValue?.[bindEventField] ?? modelValue);
|
||||
}
|
||||
|
||||
if (bindEventField) {
|
||||
return {
|
||||
[`onUpdate:${bindEventField}`]: handler,
|
||||
[bindEventField]: value === undefined ? emptyStateValue : value,
|
||||
onChange: disabledOnChangeListener
|
||||
? undefined
|
||||
: (e: Record<string, any>) => {
|
||||
const shouldUnwrap = isEventObjectLike(e);
|
||||
const onChange = slotProps?.componentField?.onChange;
|
||||
if (!shouldUnwrap) {
|
||||
return onChange?.(e);
|
||||
}
|
||||
|
||||
return onChange?.(e?.target?.[bindEventField] ?? e);
|
||||
},
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
...(disabledOnChangeListener ? { onChange: undefined } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createComponentProps(slotProps: Record<string, any>) {
|
||||
const bindEvents = fieldBindEvent(slotProps);
|
||||
|
||||
const binds = {
|
||||
...slotProps.componentField,
|
||||
...computedProps.value,
|
||||
...bindEvents,
|
||||
...(Reflect.has(computedProps.value, 'onChange')
|
||||
? { onChange: computedProps.value.onChange }
|
||||
: {}),
|
||||
...(Reflect.has(computedProps.value, 'onInput')
|
||||
? { onInput: computedProps.value.onInput }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return binds;
|
||||
}
|
||||
|
||||
function autofocus() {
|
||||
if (
|
||||
fieldComponentRef.value &&
|
||||
isFunction(fieldComponentRef.value.focus) &&
|
||||
// 检查当前是否有元素被聚焦
|
||||
document.activeElement !== fieldComponentRef.value
|
||||
) {
|
||||
fieldComponentRef.value?.focus?.();
|
||||
}
|
||||
}
|
||||
const componentRefMap = injectComponentRefMap();
|
||||
watch(fieldComponentRef, (componentRef) => {
|
||||
componentRefMap?.set(fieldName, componentRef);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (componentRefMap?.has(fieldName)) {
|
||||
componentRefMap.delete(fieldName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField
|
||||
v-if="isIf"
|
||||
v-bind="fieldProps"
|
||||
v-slot="slotProps"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem
|
||||
v-show="isShow"
|
||||
:class="{
|
||||
'form-valid-error': isInValid,
|
||||
'form-is-required': shouldRequired,
|
||||
'flex-col': isVertical,
|
||||
'flex-row items-center': !isVertical,
|
||||
'pb-6': !compact,
|
||||
'pb-2': compact,
|
||||
}"
|
||||
class="relative flex"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<FormLabel
|
||||
v-if="!hideLabel"
|
||||
:class="
|
||||
cn(
|
||||
'flex leading-6',
|
||||
{
|
||||
'mr-2 flex-shrink-0 justify-end': !isVertical,
|
||||
'mb-1 flex-row': isVertical,
|
||||
},
|
||||
labelClass,
|
||||
)
|
||||
"
|
||||
:help="help"
|
||||
:colon="colon"
|
||||
:label="label"
|
||||
:required="shouldRequired && !hideRequiredMark"
|
||||
:style="labelStyle"
|
||||
>
|
||||
<template v-if="label">
|
||||
<VbenRenderContent :content="label" />
|
||||
</template>
|
||||
</FormLabel>
|
||||
<div class="flex-auto overflow-hidden p-[1px]">
|
||||
<div :class="cn('relative flex w-full items-center', wrapperClass)">
|
||||
<FormControl :class="cn(controlClass)">
|
||||
<slot
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
...createComponentProps(slotProps),
|
||||
disabled: shouldDisabled,
|
||||
isInValid,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive focus:border-destructive hover:border-destructive/80 focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
isInValid,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
>
|
||||
<template
|
||||
v-for="name in renderContentKey"
|
||||
:key="name"
|
||||
#[name]="renderSlotProps"
|
||||
>
|
||||
<VbenRenderContent
|
||||
:content="customContentRender[name]"
|
||||
v-bind="{ ...renderSlotProps, formContext: slotProps }"
|
||||
/>
|
||||
</template>
|
||||
<!-- <slot></slot> -->
|
||||
</component>
|
||||
<VbenTooltip
|
||||
v-if="compact && isInValid"
|
||||
:delay-duration="300"
|
||||
side="left"
|
||||
>
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<CircleAlert
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground/80 hover:text-foreground inline-flex size-5 cursor-pointer',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<FormMessage />
|
||||
</VbenTooltip>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<!-- 自定义后缀 -->
|
||||
<div v-if="suffix" class="ml-1">
|
||||
<VbenRenderContent :content="suffix" />
|
||||
</div>
|
||||
<FormDescription v-if="description" class="ml-1">
|
||||
<VbenRenderContent :content="description" />
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up" v-if="!compact">
|
||||
<FormMessage class="absolute bottom-1" />
|
||||
</Transition>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { CustomRenderType } from '../types';
|
||||
|
||||
import { FormLabel, VbenHelpTooltip } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
colon?: boolean;
|
||||
help?: CustomRenderType;
|
||||
label?: CustomRenderType;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel :class="cn('flex items-center', props.class)">
|
||||
<span v-if="required" class="text-destructive mr-[2px]">*</span>
|
||||
<slot></slot>
|
||||
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
|
||||
<!-- 可通过\n换行 -->
|
||||
<span class="whitespace-pre-line">
|
||||
{{ help }}
|
||||
</span>
|
||||
<!-- <VbenRenderContent :content="help" /> -->
|
||||
</VbenHelpTooltip>
|
||||
<span v-if="colon && label" class="ml-[2px]">:</span>
|
||||
</FormLabel>
|
||||
</template>
|
165
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
165
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type {
|
||||
FormCommonConfig,
|
||||
FormRenderProps,
|
||||
FormSchema,
|
||||
FormShape,
|
||||
} from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Form } from '@vben-core/shadcn-ui';
|
||||
import { cn, isString, mergeWithArrayOverride } 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 & { globalCommonConfig?: FormCommonConfig }>(),
|
||||
{
|
||||
collapsedRows: 1,
|
||||
commonConfig: () => ({}),
|
||||
globalCommonConfig: () => ({}),
|
||||
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(
|
||||
(): (Omit<FormSchema, 'formFieldProps'> & {
|
||||
commonComponentProps: Record<string, any>;
|
||||
formFieldProps: Record<string, any>;
|
||||
})[] => {
|
||||
const {
|
||||
colon = false,
|
||||
componentProps = {},
|
||||
controlClass = '',
|
||||
disabled,
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
formFieldProps = {},
|
||||
formItemClass = '',
|
||||
hideLabel = false,
|
||||
hideRequiredMark = false,
|
||||
labelClass = '',
|
||||
labelWidth = 100,
|
||||
modelPropName = '',
|
||||
wrapperClass = '',
|
||||
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
|
||||
return (props.schema || []).map((schema, index) => {
|
||||
const keepIndex = keepFormItemIndex.value;
|
||||
|
||||
const hidden =
|
||||
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
|
||||
props.showCollapseButton && !!formCollapsed.value && keepIndex
|
||||
? keepIndex <= index
|
||||
: false;
|
||||
|
||||
return {
|
||||
colon,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
hideLabel,
|
||||
hideRequiredMark,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
wrapperClass,
|
||||
...schema,
|
||||
commonComponentProps: componentProps,
|
||||
componentProps: schema.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>
|
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts
Normal file
@@ -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');
|
||||
}
|
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
3
packages/@core/ui-kit/form-ui/src/form-render/index.ts
Normal file
@@ -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';
|
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
12
packages/@core/ui-kit/form-ui/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { setupVbenForm } from './config';
|
||||
|
||||
export type {
|
||||
BaseFormComponentType,
|
||||
ExtendedFormApi,
|
||||
FormSchema as VbenFormSchema,
|
||||
VbenFormProps,
|
||||
} from './types';
|
||||
|
||||
export * from './use-vben-form';
|
||||
// export { default as VbenForm } from './vben-form.vue';
|
||||
export * as z from 'zod';
|
442
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
442
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import type { FieldOptions, FormContext, GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
|
||||
|
||||
import type { VbenButtonProps } from '@vben-core/shadcn-ui';
|
||||
import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
|
||||
|
||||
import type { FormApi } from './form-api';
|
||||
|
||||
export type FormLayout = 'horizontal' | 'vertical';
|
||||
|
||||
export type BaseFormComponentType =
|
||||
| 'DefaultButton'
|
||||
| 'PrimaryButton'
|
||||
| '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 type FormFieldOptions = Partial<
|
||||
FieldOptions & {
|
||||
validateOnBlur?: boolean;
|
||||
validateOnChange?: boolean;
|
||||
validateOnInput?: boolean;
|
||||
validateOnModelUpdate?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
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'
|
||||
| 'selectRequired'
|
||||
| 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?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否渲染(删除dom)
|
||||
* @returns 是否渲染
|
||||
*/
|
||||
if?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否必填
|
||||
* @returns 是否必填
|
||||
*/
|
||||
required?: FormItemDependenciesCondition;
|
||||
/**
|
||||
* 字段规则
|
||||
*/
|
||||
rules?: FormItemDependenciesConditionWithRules;
|
||||
/**
|
||||
* 是否隐藏(Css)
|
||||
* @returns 是否隐藏
|
||||
*/
|
||||
show?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 任意触发都会执行
|
||||
*/
|
||||
trigger?: FormItemDependenciesCondition<void>;
|
||||
/**
|
||||
* 触发字段
|
||||
*/
|
||||
triggerFields: string[];
|
||||
}
|
||||
|
||||
type ComponentProps =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => MaybeComponentProps)
|
||||
| MaybeComponentProps;
|
||||
|
||||
export interface FormCommonConfig {
|
||||
/**
|
||||
* 在Label后显示一个冒号
|
||||
*/
|
||||
colon?: boolean;
|
||||
/**
|
||||
* 所有表单项的props
|
||||
*/
|
||||
componentProps?: ComponentProps;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
*/
|
||||
controlClass?: string;
|
||||
/**
|
||||
* 所有表单项的禁用状态
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的change事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnChangeListener?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的input事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnInputListener?: boolean;
|
||||
/**
|
||||
* 所有表单项的空状态值,默认都是undefined,naive-ui的空状态值是null
|
||||
*/
|
||||
emptyStateValue?: null | undefined;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
* @default {}
|
||||
*/
|
||||
formFieldProps?: FormFieldOptions;
|
||||
/**
|
||||
* 所有表单项的栅格布局
|
||||
* @default ""
|
||||
*/
|
||||
formItemClass?: string;
|
||||
/**
|
||||
* 隐藏所有表单项label
|
||||
* @default false
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* 是否隐藏必填标记
|
||||
* @default false
|
||||
*/
|
||||
hideRequiredMark?: boolean;
|
||||
/**
|
||||
* 所有表单项的label样式
|
||||
* @default ""
|
||||
*/
|
||||
labelClass?: string;
|
||||
/**
|
||||
* 所有表单项的label宽度
|
||||
*/
|
||||
labelWidth?: number;
|
||||
/**
|
||||
* 所有表单项的model属性名
|
||||
* @default "modelValue"
|
||||
*/
|
||||
modelPropName?: string;
|
||||
/**
|
||||
* 所有表单项的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 type FieldMappingTime = [
|
||||
string,
|
||||
[string, string],
|
||||
(
|
||||
| ((value: any, fieldName: string) => any)
|
||||
| [string, string]
|
||||
| null
|
||||
| string
|
||||
)?,
|
||||
][];
|
||||
|
||||
export type ArrayToStringFields = Array<
|
||||
| [string[], string?] // 嵌套数组格式,可选分隔符
|
||||
| string // 单个字段,使用默认分隔符
|
||||
| string[] // 简单数组格式,最后一个元素可以是分隔符
|
||||
>;
|
||||
|
||||
export interface FormSchema<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends FormCommonConfig {
|
||||
/** 组件 */
|
||||
component: Component | T;
|
||||
/** 组件参数 */
|
||||
componentProps?: ComponentProps;
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
/** 依赖 */
|
||||
dependencies?: FormItemDependencies;
|
||||
/** 描述 */
|
||||
description?: CustomRenderType;
|
||||
/** 字段名 */
|
||||
fieldName: string;
|
||||
/** 帮助信息 */
|
||||
help?: CustomRenderType;
|
||||
/** 表单项 */
|
||||
label?: CustomRenderType;
|
||||
// 自定义组件内部渲染
|
||||
renderComponentContent?: RenderComponentContentType;
|
||||
/** 字段规则 */
|
||||
rules?: FormSchemaRuleType;
|
||||
/** 后缀 */
|
||||
suffix?: CustomRenderType;
|
||||
}
|
||||
|
||||
export interface FormFieldProps extends FormSchema {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface FormRenderProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
/**
|
||||
* 是否展开,在showCollapseButton=true下生效
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* 折叠时保持行数
|
||||
* @default 1
|
||||
*/
|
||||
collapsedRows?: number;
|
||||
/**
|
||||
* 是否触发resize事件
|
||||
* @default false
|
||||
*/
|
||||
collapseTriggerResize?: boolean;
|
||||
/**
|
||||
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
|
||||
*/
|
||||
commonConfig?: FormCommonConfig;
|
||||
/**
|
||||
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* 组件v-model事件绑定
|
||||
*/
|
||||
componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>;
|
||||
/**
|
||||
* 组件集合
|
||||
*/
|
||||
componentMap: Record<BaseFormComponentType, Component>;
|
||||
/**
|
||||
* 表单字段映射到时间格式
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单实例
|
||||
*/
|
||||
form?: FormContext<GenericObject>;
|
||||
/**
|
||||
* 表单项布局
|
||||
*/
|
||||
layout?: FormLayout;
|
||||
/**
|
||||
* 表单定义
|
||||
*/
|
||||
schema?: FormSchema<T>[];
|
||||
|
||||
/**
|
||||
* 是否显示展开/折叠
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表单栅格布局
|
||||
* @default "grid-cols-1"
|
||||
*/
|
||||
wrapperClass?: WrapperClassType;
|
||||
}
|
||||
|
||||
export interface ActionButtonOptions extends VbenButtonProps {
|
||||
[key: string]: any;
|
||||
content?: MaybeComputedRef<string>;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export interface VbenFormProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends Omit<
|
||||
FormRenderProps<T>,
|
||||
'componentBindEventMap' | 'componentMap' | 'form'
|
||||
> {
|
||||
/**
|
||||
* 操作按钮是否反转(提交按钮前置)
|
||||
*/
|
||||
actionButtonsReverse?: boolean;
|
||||
/**
|
||||
* 表单操作区域class
|
||||
*/
|
||||
actionWrapperClass?: ClassType;
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
|
||||
/**
|
||||
* 表单字段映射
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单重置回调
|
||||
*/
|
||||
handleReset?: HandleResetFn;
|
||||
/**
|
||||
* 表单提交回调
|
||||
*/
|
||||
handleSubmit?: HandleSubmitFn;
|
||||
/**
|
||||
* 表单值变化回调
|
||||
*/
|
||||
handleValuesChange?: (
|
||||
values: Record<string, any>,
|
||||
fieldsChanged: string[],
|
||||
) => void;
|
||||
/**
|
||||
* 重置按钮参数
|
||||
*/
|
||||
resetButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 是否显示默认操作按钮
|
||||
* @default true
|
||||
*/
|
||||
showDefaultActions?: boolean;
|
||||
|
||||
/**
|
||||
* 提交按钮参数
|
||||
*/
|
||||
submitButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 是否在字段值改变时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnChange?: boolean;
|
||||
|
||||
/**
|
||||
* 是否在回车时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedFormApi = FormApi & {
|
||||
useStore: <T = NoInfer<VbenFormProps>>(
|
||||
selector?: (state: NoInfer<VbenFormProps>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface VbenFormAdapterOptions<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
config?: {
|
||||
baseModelPropName?: string;
|
||||
disabledOnChangeListener?: boolean;
|
||||
disabledOnInputListener?: boolean;
|
||||
emptyStateValue?: null | undefined;
|
||||
modelPropNameMap?: Partial<Record<T, string>>;
|
||||
};
|
||||
defineRules?: {
|
||||
required?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
selectRequired?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
};
|
||||
}
|
109
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
109
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ZodRawShape } from 'zod';
|
||||
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
|
||||
|
||||
import { computed, unref, useSlots } from 'vue';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useForm } from 'vee-validate';
|
||||
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
|
||||
import { getDefaultsForSchema } from 'zod-defaults';
|
||||
|
||||
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
|
||||
|
||||
export const [injectFormProps, provideFormProps] =
|
||||
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
|
||||
'VbenFormProps',
|
||||
);
|
||||
|
||||
export const [injectComponentRefMap, provideComponentRefMap] =
|
||||
createContext<Map<string, unknown>>('ComponentRefMap');
|
||||
|
||||
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')) {
|
||||
set(initialValues, item.fieldName, item.defaultValue);
|
||||
} else if (item.rules && !isString(item.rules)) {
|
||||
// 检查规则是否适合提取默认值
|
||||
const customDefaultValue = getCustomDefaultValue(item.rules);
|
||||
zodObject[item.fieldName] = item.rules;
|
||||
if (customDefaultValue !== undefined) {
|
||||
initialValues[item.fieldName] = customDefaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const schemaInitialValues = getDefaultsForSchema(object(zodObject));
|
||||
|
||||
const zodDefaults: Record<string, any> = {};
|
||||
for (const key in schemaInitialValues) {
|
||||
set(zodDefaults, key, schemaInitialValues[key]);
|
||||
}
|
||||
return mergeWithArrayOverride(initialValues, zodDefaults);
|
||||
}
|
||||
// 自定义默认值提取逻辑
|
||||
function getCustomDefaultValue(rule: any): any {
|
||||
if (rule instanceof ZodString) {
|
||||
return ''; // 默认为空字符串
|
||||
} else if (rule instanceof ZodNumber) {
|
||||
return null; // 默认为 null(避免显示 0)
|
||||
} else if (rule instanceof ZodObject) {
|
||||
// 递归提取嵌套对象的默认值
|
||||
const defaultValues: Record<string, any> = {};
|
||||
for (const [key, valueSchema] of Object.entries(rule.shape)) {
|
||||
defaultValues[key] = getCustomDefaultValue(valueSchema);
|
||||
}
|
||||
return defaultValues;
|
||||
} else if (rule instanceof ZodIntersection) {
|
||||
// 对于交集类型,从schema 提取默认值
|
||||
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
|
||||
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
|
||||
|
||||
// 如果左右两边都能提取默认值,合并它们
|
||||
if (
|
||||
typeof leftDefaultValue === 'object' &&
|
||||
typeof rightDefaultValue === 'object'
|
||||
) {
|
||||
return { ...leftDefaultValue, ...rightDefaultValue };
|
||||
}
|
||||
|
||||
// 否则优先使用左边的默认值
|
||||
return leftDefaultValue ?? rightDefaultValue;
|
||||
} else {
|
||||
return undefined; // 其他类型不提供默认值
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
delegatedSlots,
|
||||
form,
|
||||
};
|
||||
}
|
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
50
packages/@core/ui-kit/form-ui/src/use-vben-form.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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.unmount();
|
||||
});
|
||||
api.setState({ ...props, ...attrs });
|
||||
return () =>
|
||||
h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
|
||||
},
|
||||
{
|
||||
name: 'VbenUseForm',
|
||||
inheritAttrs: false,
|
||||
},
|
||||
);
|
||||
// Add reactivity support
|
||||
if (IS_REACTIVE) {
|
||||
watch(
|
||||
() => options.schema,
|
||||
() => {
|
||||
api.setState({ schema: options.schema });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
return [Form, extendedApi] as const;
|
||||
}
|
77
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
77
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<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,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} 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"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<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>
|
148
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
148
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { ExtendedFormApi, VbenFormProps } from './types';
|
||||
|
||||
// import { toRaw, watch } from 'vue';
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { useForwardPriorityValues } from '@vben-core/composables';
|
||||
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
import FormActions from './components/form-actions.vue';
|
||||
import {
|
||||
COMPONENT_BIND_EVENT_MAP,
|
||||
COMPONENT_MAP,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} from './config';
|
||||
import { Form } from './form-render';
|
||||
import {
|
||||
provideComponentRefMap,
|
||||
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 componentRefMap = new Map<string, unknown>();
|
||||
|
||||
const { delegatedSlots, form } = useFormInitial(forward);
|
||||
|
||||
provideFormProps([forward, form]);
|
||||
provideComponentRefMap(componentRefMap);
|
||||
|
||||
props.formApi?.mount?.(form, componentRefMap);
|
||||
|
||||
const handleUpdateCollapsed = (value: boolean) => {
|
||||
props.formApi?.setState({ collapsed: !!value });
|
||||
};
|
||||
|
||||
function handleKeyDownEnter(event: KeyboardEvent) {
|
||||
if (!state.value.submitOnEnter || !forward.value.formApi?.isMounted) {
|
||||
return;
|
||||
}
|
||||
// 如果是 textarea 不阻止默认行为,否则会导致无法换行。
|
||||
// 跳过 textarea 的回车提交处理
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
forward.value.formApi.validateAndSubmitForm();
|
||||
}
|
||||
|
||||
const handleValuesChangeDebounced = useDebounceFn(async () => {
|
||||
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
|
||||
}, 300);
|
||||
|
||||
const valuesCache: Recordable<any> = {};
|
||||
|
||||
onMounted(async () => {
|
||||
// 只在挂载后开始监听,form.values会有一个初始化的过程
|
||||
await nextTick();
|
||||
watch(
|
||||
() => form.values,
|
||||
async (newVal) => {
|
||||
if (forward.value.handleValuesChange) {
|
||||
const fields = state.value.schema?.map((item) => {
|
||||
return item.fieldName;
|
||||
});
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
const changedFields: string[] = [];
|
||||
fields.forEach((field) => {
|
||||
const newFieldValue = get(newVal, field);
|
||||
const oldFieldValue = get(valuesCache, field);
|
||||
if (!isEqual(newFieldValue, oldFieldValue)) {
|
||||
changedFields.push(field);
|
||||
set(valuesCache, field, newFieldValue);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
// 调用handleValuesChange回调,传入所有表单值的深拷贝和变更的字段列表
|
||||
forward.value.handleValuesChange(
|
||||
cloneDeep(await forward.value.formApi.getValues()),
|
||||
changedFields,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleValuesChangeDebounced();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
@keydown.enter="handleKeyDownEnter"
|
||||
v-bind="forward"
|
||||
:collapsed="state.collapsed"
|
||||
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
|
||||
:component-map="COMPONENT_MAP"
|
||||
:form="form"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<template #reset-before="resetSlotProps">
|
||||
<slot name="reset-before" v-bind="resetSlotProps"></slot>
|
||||
</template>
|
||||
<template #submit-before="submitSlotProps">
|
||||
<slot name="submit-before" v-bind="submitSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-before="expandBeforeSlotProps">
|
||||
<slot name="expand-before" v-bind="expandBeforeSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-after="expandAfterSlotProps">
|
||||
<slot name="expand-after" v-bind="expandAfterSlotProps"></slot>
|
||||
</template>
|
||||
</FormActions>
|
||||
</slot>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
Reference in New Issue
Block a user