14 changed files with 1088 additions and 66 deletions
@ -0,0 +1,17 @@ |
|||||
|
# http://editorconfig.org |
||||
|
root = true |
||||
|
|
||||
|
[*] |
||||
|
indent_style = space |
||||
|
indent_size = 4 |
||||
|
end_of_line = lf |
||||
|
charset = utf-8 |
||||
|
trim_trailing_whitespace = true |
||||
|
insert_final_newline = true |
||||
|
max_line_length = 240 |
||||
|
|
||||
|
[*.md] |
||||
|
trim_trailing_whitespace = false |
||||
|
|
||||
|
[Makefile] |
||||
|
indent_style = tab |
||||
@ -0,0 +1,283 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import lodash from "lodash"; |
||||
|
import { getCurrentInstance, reactive } from "vue"; |
||||
|
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"; |
||||
|
import { Expression, Typeof } from "@ease-forge/shared"; |
||||
|
import { calcBreakpointValue, toVNode } from "@/utils/Utils.ts"; |
||||
|
import { type DataFormData, type DataFormItem, type DataFormProps, type DataFormState, type FormData, type FormField } from "./DataFormTypes.ts"; |
||||
|
import { dataFormInputComponents } from "./DataFormConstant.ts"; |
||||
|
import { dataPathToNamePath } from "./DataFormUtils.tsx"; |
||||
|
|
||||
|
defineOptions({ |
||||
|
name: 'DataForm', |
||||
|
}); |
||||
|
|
||||
|
// 组件事件定义 |
||||
|
const emit = defineEmits<{ |
||||
|
/** loading变化事件 */ |
||||
|
loadingChange: [loading: boolean]; |
||||
|
/** 表单字段变化 */ |
||||
|
// fieldsChange: [changedFields: Array<FieldData>, allFields: Array<FieldData>]; |
||||
|
/** 表单值变化 */ |
||||
|
valuesChange: [changedValues: any, values: any]; |
||||
|
}>(); |
||||
|
|
||||
|
// 定义组件插槽 |
||||
|
const slots = defineSlots<{ |
||||
|
/** 提交区域插槽 */ |
||||
|
submit?: (props?: any) => Array<VueNode>; |
||||
|
}>(); |
||||
|
|
||||
|
// 当前组件对象 |
||||
|
const instance = getCurrentInstance(); |
||||
|
|
||||
|
// 读取组件 props 属性 |
||||
|
const props = withDefaults(defineProps<DataFormProps>(), { |
||||
|
name: () => lodash.uniqueId("data_form_"), |
||||
|
defColumnCount: 2, |
||||
|
layout: "onlyLabelFixed", |
||||
|
labelWidth: "120px", |
||||
|
formItemHeightMode: "mini", |
||||
|
components: () => dataFormInputComponents, |
||||
|
}); |
||||
|
|
||||
|
// state 属性 |
||||
|
const state = reactive<DataFormState>({ |
||||
|
loading: false, |
||||
|
data: props.data, |
||||
|
columnCount: calcColumnCount(props.columnCount), |
||||
|
dataFormItems: toDataFormItems(props.formFields, props.data), |
||||
|
}); |
||||
|
|
||||
|
// 内部数据 |
||||
|
const data: DataFormData = { |
||||
|
firstDataChange: true, |
||||
|
oldData: {}, |
||||
|
formRef: null, |
||||
|
inputRefs: {}, |
||||
|
ctxData: {}, |
||||
|
}; |
||||
|
|
||||
|
// 响应式断点 |
||||
|
const breakpoints = useBreakpoints(props.breakpoints ?? breakpointsTailwind); |
||||
|
|
||||
|
/** bothFixed:label和input都固定宽度布局 */ |
||||
|
function isBothFixed() { |
||||
|
return props.layout === "bothFixed"; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将 Array<FormField> 装换成 Array<DataFormItem> |
||||
|
* @param formFields 表单字段配置 |
||||
|
* @param formData 表单数据 |
||||
|
*/ |
||||
|
function toDataFormItems(formFields?: Array<FormField>, formData?: FormData) { |
||||
|
if (!formFields) return []; |
||||
|
return formFields.map(formField => toDataFormItem(formField, formData)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将 FormField 装换成 DataFormItem |
||||
|
* @param formField 表单字段配置 |
||||
|
* @param formData 表单数据 |
||||
|
*/ |
||||
|
function toDataFormItem(formField: FormField, formData?: FormData) { |
||||
|
const { dataPath, widthCount, label, input, inputProps, format, transform, extInputs, hidden, watchValues, rawProps } = formField; |
||||
|
const item: DataFormItem = { |
||||
|
widthCount: widthCount ?? 1, |
||||
|
inputRef: dataPath?.replaceAll(/[.\[\]]/g, '_') + '_' + lodash.uniqueId(), |
||||
|
formItemProps: { ...rawProps }, |
||||
|
dataPath, |
||||
|
format, |
||||
|
transform, |
||||
|
hidden, |
||||
|
}; |
||||
|
if (dataPath) { |
||||
|
item.formItemProps.name = dataPathToNamePath(dataPath); |
||||
|
} else { |
||||
|
item.formItemProps.autoLink = false; |
||||
|
} |
||||
|
if (dataPath && Typeof.isFunction(label)) { |
||||
|
const dataValue = Expression.getKeyPathValue(dataPath, formData); |
||||
|
const vnode = label(dataValue, dataPath, formData); |
||||
|
item.formItemProps.label = toVNode(vnode); |
||||
|
} else if (label) { |
||||
|
item.formItemProps.label = label; |
||||
|
} |
||||
|
if (input) { |
||||
|
item.input = resolveInputComponent(input); |
||||
|
if (item.input && inputProps) { |
||||
|
item.inputProps = inputProps; |
||||
|
} |
||||
|
} |
||||
|
if (Typeof.isArray(watchValues)) { |
||||
|
item.watchValues = watchValues; |
||||
|
} else if (watchValues) { |
||||
|
item.watchValues = [watchValues]; |
||||
|
} |
||||
|
// formItemProps |
||||
|
const { style, class: className, labelWidth, labelPosition, showMessage, inlineMessage, rules, required } = formField; |
||||
|
if (Typeof.hasValue(style)) item.formItemProps.style = style; |
||||
|
if (Typeof.hasValue(className)) item.formItemProps.class = className; |
||||
|
if (Typeof.hasValue(labelWidth)) item.formItemProps.labelWidth = labelWidth; |
||||
|
if (Typeof.hasValue(labelPosition)) item.formItemProps.labelPosition = labelPosition; |
||||
|
if (Typeof.hasValue(showMessage)) item.formItemProps.showMessage = showMessage; |
||||
|
if (Typeof.hasValue(inlineMessage)) item.formItemProps.inlineMessage = inlineMessage; |
||||
|
if (Typeof.hasValue(rules)) item.formItemProps.rules = rules; |
||||
|
if (Typeof.hasValue(required)) item.formItemProps.required = required; |
||||
|
// TODO bothFixed 配置下,字段的 widthCount > 1 时,字段宽度需要自适应 |
||||
|
if (widthCount && widthCount > 1 && isBothFixed()) { |
||||
|
// if (!item.formItemProps.wrapperCol) item.formItemProps.wrapperCol = {}; |
||||
|
// if (!item.formItemProps.wrapperCol.style) item.formItemProps.wrapperCol.style = {}; |
||||
|
// const width = `calc((${props.labelWidth} + ${props.inputWidth}) * ${widthCount - 1} + ${props.inputWidth})`; |
||||
|
// if (!item.formItemProps.wrapperCol.style.width) item.formItemProps.wrapperCol.style.width = width; |
||||
|
// if (!item.formItemProps.wrapperCol.style.flex) item.formItemProps.wrapperCol.style.flex = `0 0 ${width}`; |
||||
|
} |
||||
|
// extInputs |
||||
|
if (Typeof.isArray(extInputs)) { |
||||
|
item.extInputs = extInputs.map(extInput => toDataFormItem(extInput, formData)); |
||||
|
} |
||||
|
return item; |
||||
|
} |
||||
|
|
||||
|
/** 获取 input vue组件 */ |
||||
|
function resolveInputComponent(input: any) { |
||||
|
if (!Typeof.isStr(input)) return input; |
||||
|
const components = props.components ?? {}; |
||||
|
const cmp = components[input]; |
||||
|
if (!cmp) console.warn("input组件未注册:", input); |
||||
|
return cmp; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据响应式断点配置,计算一行显示的字段数量 |
||||
|
* @param columnCount 响应式断点配置 |
||||
|
* @param current 当前响应式断点值 |
||||
|
*/ |
||||
|
function calcColumnCount(columnCount?: DataFormProps["columnCount"], current?: Array<string>): number { |
||||
|
return Math.min(calcBreakpointValue(columnCount, current, props.defColumnCount ?? 2), 24); |
||||
|
} |
||||
|
|
||||
|
interface DataFormExpose { |
||||
|
state: DataFormState; |
||||
|
data: DataFormData; |
||||
|
} |
||||
|
|
||||
|
const expose: DataFormExpose = { |
||||
|
state, |
||||
|
data, |
||||
|
}; |
||||
|
// 定义组件公开内容 |
||||
|
defineExpose(expose); |
||||
|
|
||||
|
export type { |
||||
|
DataFormProps, |
||||
|
DataFormState, |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="data-form"> |
||||
|
|
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
|
||||
|
|
||||
|
.data-form-item.data-form-item-flex-1 { |
||||
|
flex: 1 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-2 { |
||||
|
flex: 2 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-3 { |
||||
|
flex: 3 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-4 { |
||||
|
flex: 4 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-5 { |
||||
|
flex: 5 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-6 { |
||||
|
flex: 6 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-7 { |
||||
|
flex: 7 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-8 { |
||||
|
flex: 8 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-9 { |
||||
|
flex: 9 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-10 { |
||||
|
flex: 10 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-11 { |
||||
|
flex: 11 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-12 { |
||||
|
flex: 12 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-13 { |
||||
|
flex: 13 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-14 { |
||||
|
flex: 14 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-15 { |
||||
|
flex: 15 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-16 { |
||||
|
flex: 16 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-17 { |
||||
|
flex: 17 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-18 { |
||||
|
flex: 18 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-19 { |
||||
|
flex: 19 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-20 { |
||||
|
flex: 20 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-21 { |
||||
|
flex: 21 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-22 { |
||||
|
flex: 22 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-23 { |
||||
|
flex: 23 1 0; |
||||
|
} |
||||
|
|
||||
|
.data-form-item.data-form-item-flex-24 { |
||||
|
flex: 24 1 0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,69 @@ |
|||||
|
import { markRaw } from "vue"; |
||||
|
import { |
||||
|
ElAutocomplete, |
||||
|
ElCascader, |
||||
|
ElCheckbox, |
||||
|
ElColorPicker, |
||||
|
ElDatePicker, |
||||
|
ElInput, |
||||
|
ElInputNumber, |
||||
|
ElInputTag, |
||||
|
ElMention, |
||||
|
ElRadio, |
||||
|
ElRate, |
||||
|
ElSelect, |
||||
|
ElSelectV2, |
||||
|
ElSlider, |
||||
|
ElSwitch, |
||||
|
ElTimePicker, |
||||
|
ElTimeSelect, |
||||
|
ElTransfer, |
||||
|
ElTreeSelect, |
||||
|
ElUpload, |
||||
|
} from "element-plus"; |
||||
|
import type { DisplayMode } from "./DataFormTypes.ts"; |
||||
|
|
||||
|
/** 内建的表单输入组件 */ |
||||
|
const builtInInputComponents = { |
||||
|
Autocomplete: markRaw<any>(ElAutocomplete), |
||||
|
Cascader: markRaw<any>(ElCascader), |
||||
|
Checkbox: markRaw<any>(ElCheckbox), |
||||
|
ColorPicker: markRaw<any>(ElColorPicker), |
||||
|
DatePicker: markRaw<any>(ElDatePicker), |
||||
|
Input: markRaw<any>(ElInput), |
||||
|
InputNumber: markRaw<any>(ElInputNumber), |
||||
|
InputTag: markRaw<any>(ElInputTag), |
||||
|
Mention: markRaw<any>(ElMention), |
||||
|
Radio: markRaw<any>(ElRadio), |
||||
|
Rate: markRaw<any>(ElRate), |
||||
|
Select: markRaw<any>(ElSelect), |
||||
|
SelectV2: markRaw<any>(ElSelectV2), |
||||
|
Slider: markRaw<any>(ElSlider), |
||||
|
Switch: markRaw<any>(ElSwitch), |
||||
|
TimePicker: markRaw<any>(ElTimePicker), |
||||
|
TimeSelect: markRaw<any>(ElTimeSelect), |
||||
|
Transfer: markRaw<any>(ElTransfer), |
||||
|
TreeSelect: markRaw<any>(ElTreeSelect), |
||||
|
Upload: markRaw<any>(ElUpload), |
||||
|
}; |
||||
|
|
||||
|
type BuiltInInput = keyof typeof builtInInputComponents; |
||||
|
|
||||
|
const dataFormInputComponents: Record<BuiltInInput | string, any> = markRaw<any>(builtInInputComponents); |
||||
|
|
||||
|
/** DisplayMode 的默认值 */ |
||||
|
const defDisplayMode: DisplayMode = {}; |
||||
|
|
||||
|
export type { |
||||
|
BuiltInInput, |
||||
|
}; |
||||
|
|
||||
|
export default { |
||||
|
dataFormInputComponents, |
||||
|
defDisplayMode, |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
dataFormInputComponents, |
||||
|
defDisplayMode, |
||||
|
} |
||||
@ -0,0 +1,276 @@ |
|||||
|
import { type ComponentInternalInstance, type CSSProperties } from "vue"; |
||||
|
import { type Breakpoints, breakpointsTailwind } from "@vueuse/core"; |
||||
|
import { type FormItemProps, type FormProps } from "element-plus"; |
||||
|
|
||||
|
/** 表单数据类型 */ |
||||
|
type FormData = Record<string, any> | Array<any>; |
||||
|
|
||||
|
/** 字段标题 */ |
||||
|
type Label = VueNode | ((value: any, dataPath: string, formData?: FormData) => VueNode); |
||||
|
|
||||
|
/** 表单数据绑定到表单组件上时的格式化操作 */ |
||||
|
type ValueFormat = DictGroup | ((value: any, dataPath: string, formData?: FormData) => any); |
||||
|
|
||||
|
/** 表单组件值更新到表单数据时的数据转换 */ |
||||
|
type ValueTransform = DictGroup | ((value: any, dataPath: string, formData?: FormData) => any); |
||||
|
|
||||
|
/** 前缀、后缀的自定义组件配置 */ |
||||
|
interface PrefixSuffixComponent { |
||||
|
/** 禁用 */ |
||||
|
disabled?: boolean; |
||||
|
/** 组件名或组件对象 */ |
||||
|
component?: any; |
||||
|
/** 组件props */ |
||||
|
props?: Record<string, any>; |
||||
|
/** 自定义渲染逻辑 */ |
||||
|
render?: (value: any) => VueNode; |
||||
|
} |
||||
|
|
||||
|
/** 显示模式 */ |
||||
|
interface DisplayMode { |
||||
|
/** 禁用 */ |
||||
|
disabled?: boolean; |
||||
|
/** 定义样式 */ |
||||
|
style?: CSSProperties; |
||||
|
/** 自定义class样式 */ |
||||
|
class?: string; |
||||
|
/** 数据格式显示 */ |
||||
|
format?: (value: any) => string; |
||||
|
/** 自定义渲染逻辑 */ |
||||
|
render?: (value: any) => VueNode; |
||||
|
} |
||||
|
|
||||
|
/** 表单字段输入组件通用的props */ |
||||
|
interface FormInputBaseProps { |
||||
|
/** 定义样式 */ |
||||
|
style?: CSSProperties; |
||||
|
/** 自定义class样式 */ |
||||
|
class?: string; |
||||
|
/** 占位符 */ |
||||
|
placeholder?: string; |
||||
|
/** 允许清空 */ |
||||
|
allowClear?: boolean; |
||||
|
/** 只读 */ |
||||
|
readonly?: boolean; |
||||
|
/** 禁用 */ |
||||
|
disabled?: boolean; |
||||
|
/** 隐藏 */ |
||||
|
hidden?: boolean; |
||||
|
/** 前缀区域的自定义组件 */ |
||||
|
prefixConfig?: PrefixSuffixComponent; |
||||
|
/** 后缀区域的自定义组件 */ |
||||
|
suffixConfig?: PrefixSuffixComponent; |
||||
|
/** 前缀区域已经后缀区域的包装容器的样式 */ |
||||
|
preSufWrapStyle?: CSSProperties; |
||||
|
/** 显示模式 */ |
||||
|
displayMode?: DisplayMode | true; |
||||
|
|
||||
|
// onFocus
|
||||
|
// onBlur
|
||||
|
// onChange
|
||||
|
// onPressEnter
|
||||
|
|
||||
|
[key: string]: any; |
||||
|
} |
||||
|
|
||||
|
/** 监听表单字段值 */ |
||||
|
interface WatchFormFieldValue<V = any> { |
||||
|
/** 数据路径,如: "userName"、"user.age"、"user.hobby.[1].name"、"[2].id"*/ |
||||
|
dataPath: string |
||||
|
/** |
||||
|
* 表单字段值变化时的回调 |
||||
|
* @param oldValue 监听的表单字段旧值 |
||||
|
* @param newValue 监听的表单字段新值 |
||||
|
* @param data 整个表单数据 |
||||
|
* @param formItem 表单字段配置(修改当前对象的属性实现ui更新) |
||||
|
* @param input 输入组件实例 |
||||
|
* @param from Form组件实例 |
||||
|
* @param ctxData 表单上下文数据 |
||||
|
*/ |
||||
|
onChange: ( |
||||
|
oldValue: V, |
||||
|
newValue: V, |
||||
|
formData: any, |
||||
|
formItem: Pick<DataFormItem, "widthCount" | "inputProps" | "formItemProps" | "hidden">, |
||||
|
input: any, |
||||
|
from: any, |
||||
|
ctxData: DataFormData["ctxData"], |
||||
|
) => void |
||||
|
/** 在开始监听时立即触发回调 */ |
||||
|
immediate?: boolean |
||||
|
} |
||||
|
|
||||
|
/** 表单字段,一般包含一个 label 和一个 input */ |
||||
|
interface FormField { |
||||
|
// 核心配置
|
||||
|
/** 数据路径,如: "userName"、"user.age"、"user.hobby.[1].name"、"[2].id" */ |
||||
|
dataPath?: string; |
||||
|
/** 宽度占用列数 */ |
||||
|
widthCount?: number; |
||||
|
/** 字段标题 */ |
||||
|
label?: Label; |
||||
|
/** 字段输入组件,组件名或者组件对象 */ |
||||
|
input?: any; |
||||
|
/** 字段输入组件props */ |
||||
|
inputProps?: FormInputBaseProps; |
||||
|
/** 表单数据绑定到表单组件上时的格式化操作 */ |
||||
|
format?: ValueFormat; |
||||
|
/** 表单组件值更新到表单数据时的数据转换 */ |
||||
|
transform?: ValueTransform; |
||||
|
/** 当字段输入组件有多个时,扩展的输入组件 */ |
||||
|
extInputs?: Array<Omit<FormField, "extInputs">>; |
||||
|
/** 是否隐藏当前字段 */ |
||||
|
hidden?: boolean; |
||||
|
/** 监听表单字段值变化,修改当前表单字段状态 */ |
||||
|
watchValues?: WatchFormFieldValue | Array<WatchFormFieldValue>; |
||||
|
// 扩展配置
|
||||
|
/** 定义样式 */ |
||||
|
style?: CSSProperties; |
||||
|
/** 自定义class样式 */ |
||||
|
class?: string; |
||||
|
/** label宽度 */ |
||||
|
labelWidth?: string | number; |
||||
|
/** label标签的位置 */ |
||||
|
labelPosition?: "left" | "right" |
||||
|
/** 是否显示校验错误信息 */ |
||||
|
showMessage?: boolean; |
||||
|
/** 是否在行内显示校验信息 */ |
||||
|
inlineMessage?: string | boolean; |
||||
|
/** 表单验证规则 */ |
||||
|
rules?: FormItemProps["rules"]; |
||||
|
/** 是否必填,如不设置,则会根据校验规则自动生成 */ |
||||
|
required?: boolean; |
||||
|
/** 原始的 Form.Item 属性 */ |
||||
|
rawProps?: FormItemProps; |
||||
|
} |
||||
|
|
||||
|
/** 运行时的表单项 */ |
||||
|
interface DataFormItem { |
||||
|
/** 表单项占用列数 */ |
||||
|
widthCount: number; |
||||
|
/** 输入组件ref */ |
||||
|
inputRef: string; |
||||
|
/** 输入组件 */ |
||||
|
input?: VueComponent; |
||||
|
/** 输入组件props */ |
||||
|
inputProps?: FormInputBaseProps; |
||||
|
/** 扩展的输入组件 */ |
||||
|
extInputs?: Array<Omit<DataFormItem, "extInputs">>; |
||||
|
/** 需要使用的 FormItem 属性 */ |
||||
|
formItemProps: Record<keyof FormItemProps | string, any>; |
||||
|
/** 数据路径,如: "userName"、"user.age"、"user.hobby.[1].name"、"[2].id" */ |
||||
|
dataPath?: string; |
||||
|
/** 表单数据绑定到表单组件上时的格式化操作 */ |
||||
|
format?: ValueFormat; |
||||
|
/** 表单组件值更新到表单数据时的数据转换 */ |
||||
|
transform?: ValueTransform; |
||||
|
/** 是否隐藏当前表单项 */ |
||||
|
hidden?: boolean; |
||||
|
/** 监听表单字段值变化 */ |
||||
|
watchValues?: Array<WatchFormFieldValue>; |
||||
|
} |
||||
|
|
||||
|
// 定义 Props 类型
|
||||
|
interface DataFormProps { |
||||
|
// 核心配置
|
||||
|
/** 表单名称,会作为表单字段 id 前缀使用 */ |
||||
|
name?: string; |
||||
|
/** 表单数据 */ |
||||
|
data?: FormData; |
||||
|
/** 服务端数据API */ |
||||
|
dataApi?: HttpRequestConfig<FormData>; |
||||
|
/** 自动使用"dataApi"配置加载服务端数据 */ |
||||
|
autoLoadData?: boolean; |
||||
|
/** 表单字段 */ |
||||
|
formFields?: Array<FormField>; |
||||
|
/** 响应式断点配置 */ |
||||
|
breakpoints?: Breakpoints; |
||||
|
/** 默认一行显示的字段数量 */ |
||||
|
defColumnCount?: number; |
||||
|
/** 一行显示的字段数量,支持响应式断点配置,最大值:24 */ |
||||
|
columnCount?: number | Record<(keyof typeof breakpointsTailwind) | string, number>; |
||||
|
/** 表单布局:bothFixed:label和input都固定宽度;onlyLabelFixed:仅label固定宽度 */ |
||||
|
layout?: "bothFixed" | "onlyLabelFixed"; |
||||
|
/** label标签的位置 */ |
||||
|
labelPosition?: "left" | "right"; |
||||
|
/** 表单域标签的后缀 */ |
||||
|
labelSuffix?: string; |
||||
|
/** label宽度 */ |
||||
|
labelWidth?: string | number; |
||||
|
/** value宽度,仅layout=bothFixed时有效 */ |
||||
|
inputWidth?: string | number; |
||||
|
/** submit 区域 FormItem 的 Props 配置 */ |
||||
|
submitFormItemProps?: Record<keyof FormItemProps | string, any>; |
||||
|
// 扩展配置
|
||||
|
/** 表单字段大小 */ |
||||
|
size?: "" | "small" | "default" | "large"; |
||||
|
/** 是否显示校验错误信息 */ |
||||
|
showMessage?: boolean; |
||||
|
/** 是否在行内显示校验信息 */ |
||||
|
inlineMessage?: boolean; |
||||
|
/** 是否显示校验错误信息 */ |
||||
|
requireAsteriskPosition?: "left" | "right"; |
||||
|
/** 隐藏所有表单项的必选标记 */ |
||||
|
hideRequiredAsterisk?: boolean; |
||||
|
/** 表单验证规则 */ |
||||
|
rules?: FormProps["rules"]; |
||||
|
/** 是否在 rules 属性改变后立即触发一次验证 */ |
||||
|
validateOnRuleChange?: boolean; |
||||
|
/** 设置表单组件禁用 */ |
||||
|
disabled?: boolean; |
||||
|
/** 当校验失败时,滚动到第一个错误表单项 */ |
||||
|
scrollToError?: boolean; |
||||
|
/** 当校验有失败结果时,滚动到第一个失败的表单项目 可通过 scrollIntoView 配置 */ |
||||
|
scrollIntoViewOptions?: FormProps["scrollIntoViewOptions"]; |
||||
|
/** 注册内置使用的组件 */ |
||||
|
components?: Record<string, any>; |
||||
|
/** 原始的 Form 属性 */ |
||||
|
rawProps?: FormProps; |
||||
|
} |
||||
|
|
||||
|
// 定义 State 类型
|
||||
|
interface DataFormState { |
||||
|
/** 是否是加载中 */ |
||||
|
loading: boolean; |
||||
|
/** 数据值 */ |
||||
|
data?: FormData; |
||||
|
/** 表格列数 */ |
||||
|
columnCount: number; |
||||
|
/** 表单项数组 */ |
||||
|
dataFormItems: Array<DataFormItem>; |
||||
|
} |
||||
|
|
||||
|
// 定义 Data 类型
|
||||
|
interface DataFormData { |
||||
|
/** 表单数据是否是第一次变化 */ |
||||
|
firstDataChange: boolean; |
||||
|
/** 表单旧数据,Record<dataPath, dataValue> */ |
||||
|
oldData: Record<string, any>; |
||||
|
/** 表单组件的引用 */ |
||||
|
formRef: any; |
||||
|
/** 输入组件的引用 */ |
||||
|
inputRefs: Record<string, any>; |
||||
|
/** 表单上下文数据 */ |
||||
|
ctxData: { |
||||
|
/** 当前 DataForm 组件实例 */ |
||||
|
instance?: ComponentInternalInstance; |
||||
|
/** 其它属性 */ |
||||
|
[key: string]: any; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export type { |
||||
|
FormData, |
||||
|
Label, |
||||
|
ValueFormat, |
||||
|
ValueTransform, |
||||
|
PrefixSuffixComponent, |
||||
|
DisplayMode, |
||||
|
FormInputBaseProps, |
||||
|
WatchFormFieldValue, |
||||
|
FormField, |
||||
|
DataFormItem, |
||||
|
DataFormProps, |
||||
|
DataFormState, |
||||
|
DataFormData, |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
import lodash from "lodash"; |
||||
|
import { Typeof } from "@ease-forge/shared"; |
||||
|
|
||||
|
/** |
||||
|
* 将字符串转换成 antd 支持的 NamePath |
||||
|
* @param dataPath 数据路径,如: "userName"、"user.age"、"user.hobby.[1].name"、"[2].id" |
||||
|
*/ |
||||
|
function dataPathToNamePath(dataPath: string): string | number | Array<string | number> { |
||||
|
dataPath = lodash.trim(dataPath); |
||||
|
const paths = dataPath.split("."); |
||||
|
const namePath: Array<string | number> = []; |
||||
|
for (let path of paths) { |
||||
|
path = lodash.trim(path); |
||||
|
let name: string | number = path; |
||||
|
if (path.startsWith("[") && path.endsWith("]")) { |
||||
|
name = lodash.toInteger(path.substring(1, path.length - 1)); |
||||
|
if (!Typeof.isValidNumber(name)) { |
||||
|
name = path; |
||||
|
} |
||||
|
} |
||||
|
namePath.push(name); |
||||
|
} |
||||
|
if (namePath.length === 0) { |
||||
|
return dataPath; |
||||
|
} else if (namePath.length === 1) { |
||||
|
return namePath[0]; |
||||
|
} |
||||
|
return namePath; |
||||
|
} |
||||
|
|
||||
|
export default { |
||||
|
dataPathToNamePath, |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
dataPathToNamePath, |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
|
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
DataForm01.vue |
||||
|
</template> |
||||
|
|
||||
|
<style scoped lang="less"> |
||||
|
|
||||
|
</style> |
||||
@ -1,29 +1,35 @@ |
|||||
import { createRouter, createWebHashHistory } from 'vue-router' |
import { createRouter, createWebHashHistory } from 'vue-router' |
||||
|
|
||||
const router = createRouter({ |
const router = createRouter({ |
||||
history: createWebHashHistory(import.meta.env.BASE_URL), |
history: createWebHashHistory(import.meta.env.BASE_URL), |
||||
routes: [ |
routes: [ |
||||
{ |
{ |
||||
path: '/', |
path: '/', |
||||
name: 'home', |
name: 'home', |
||||
// 自动引导到 /editor
|
// 自动引导到 /editor
|
||||
redirect: '/editor' |
redirect: '/editor' |
||||
}, |
}, |
||||
{ |
{ |
||||
path: '/editor', |
path: '/editor', |
||||
name: 'editor', |
name: 'editor', |
||||
// component: HomeView,
|
// component: HomeView,
|
||||
component: () => import('../editor/ModelMain.vue') |
component: () => import('../editor/ModelMain.vue') |
||||
}, |
}, |
||||
{ |
{ |
||||
path: '/about', |
path: '/about', |
||||
name: 'about', |
name: 'about', |
||||
// route level code-splitting
|
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue') |
component: () => import('../views/AboutView.vue') |
||||
} |
}, |
||||
] |
|
||||
|
{ |
||||
|
path: '/DataForm01', |
||||
|
name: 'DataForm01', |
||||
|
component: () => import('@/pages/DataForm01.vue'), |
||||
|
}, |
||||
|
] |
||||
}) |
}) |
||||
|
|
||||
export default router |
export default router |
||||
|
|||||
@ -0,0 +1,227 @@ |
|||||
|
import lodash from "lodash"; |
||||
|
import { createTextVNode, createVNode, Fragment, isVNode, onMounted, onUnmounted, onUpdated, toRaw, type VNode } from "vue"; |
||||
|
import { Format, Typeof } from "@ease-forge/shared"; |
||||
|
import { type AnyFunction, type BaseProps } from "@ease-forge/runtime"; |
||||
|
|
||||
|
interface CreateVNode { |
||||
|
/** |
||||
|
* @param type 组件对象或html标签 |
||||
|
* @param props 组件属性 |
||||
|
* @param children 组件内容或者插槽 |
||||
|
*/ |
||||
|
(type: any, props?: BaseProps, children?: Array<any> | Record<string, AnyFunction<any, VNode>>): VNode; |
||||
|
} |
||||
|
|
||||
|
/** createVNode函数 */ |
||||
|
const h: CreateVNode = createVNode as any; |
||||
|
|
||||
|
/** |
||||
|
* 调试组件渲染次数 |
||||
|
*/ |
||||
|
function debugRender(tag: string = "", ...params: any[]) { |
||||
|
if (APP_INFO.mode === "production") return; |
||||
|
if (tag) tag = `[${tag}]: `; |
||||
|
const nowTime = () => Format.numberFormat(new Date().getTime() / 1000.0, "0.000"); |
||||
|
onMounted(() => console.log(`${nowTime()} | ${tag}加载 onMounted`, ...params)); |
||||
|
onUpdated(() => console.log(`${nowTime()} | ${tag}渲染 onUpdated`, ...params)); |
||||
|
onUnmounted(() => console.log(`${nowTime()} | ${tag}卸载 onUnmounted`, ...params)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 把对象装换成 VNode,返回结果可以直接使用 <component :is="VNode"/> 渲染 |
||||
|
*/ |
||||
|
function toVNode(vueNode: VueNode, key?: any) { |
||||
|
let props: any = null; |
||||
|
if (Typeof.hasValue(key)) { |
||||
|
props = {}; |
||||
|
props.key = lodash.toString(key); |
||||
|
} |
||||
|
if (isVNode(vueNode)) { |
||||
|
// VNode
|
||||
|
return vueNode; |
||||
|
} else if (Typeof.noValue(vueNode)) { |
||||
|
// 空值
|
||||
|
return createTextVNode(); |
||||
|
} else if (Typeof.isArray(vueNode)) { |
||||
|
// 数组
|
||||
|
const children = vueNode.map((node, idx) => toVNode(node, `idx_${idx}`)); |
||||
|
return createVNode(Fragment, props, children); |
||||
|
} else if (Typeof.isFunction(vueNode)) { |
||||
|
// 函数
|
||||
|
return toVNode(vueNode(), key); |
||||
|
} else if (Typeof.isBool(vueNode)) { |
||||
|
// 布尔值
|
||||
|
return createTextVNode(lodash.toString(vueNode)); |
||||
|
} else if (!Typeof.isObj(vueNode)) { |
||||
|
// 非对象
|
||||
|
return createTextVNode(lodash.toString(vueNode)); |
||||
|
} else { |
||||
|
// 其它值
|
||||
|
return createVNode(Fragment, props, [vueNode]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
interface MergePropsObjOptions { |
||||
|
/** 表示空值 */ |
||||
|
nullVal: any; |
||||
|
/** 表示未定义的值 */ |
||||
|
unSetVal: any; |
||||
|
} |
||||
|
|
||||
|
const defMergePropsObjOptions: MergePropsObjOptions = { |
||||
|
nullVal: "none", |
||||
|
unSetVal: "unset", |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 合并组件的 props 属性配置 |
||||
|
* @param propName prop 属性名 |
||||
|
* @param props props 对象 |
||||
|
* @param res 合并 props 配置的结果对象 |
||||
|
* @param options 合并的选项配置 |
||||
|
*/ |
||||
|
function mergePropsObj<Props = any>(propName: keyof Props, props: Props, res: any, options?: Partial<MergePropsObjOptions>) { |
||||
|
const ops: MergePropsObjOptions = lodash.defaultsDeep({}, options, defMergePropsObjOptions); |
||||
|
const propValue = props[propName]; |
||||
|
// props 未配置
|
||||
|
if (Typeof.noValue(propValue) || propValue === ops.unSetVal) return; |
||||
|
// props 配置空值
|
||||
|
if (propValue === ops.nullVal) { |
||||
|
res[propName] = undefined; |
||||
|
return; |
||||
|
} |
||||
|
// 如果是对象就合并配置,否则直接覆盖
|
||||
|
if (Typeof.isPlainObj(propValue) && !Typeof.isArray(propValue) && !Typeof.isDate(propValue)) { |
||||
|
res[propName] = lodash.defaultsDeep({}, propValue, res[propName]); |
||||
|
} else { |
||||
|
res[propName] = propValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 合并对象指定的函数属性的函数逻辑与当前提供的函数逻辑 |
||||
|
* @param obj 目标操作对象 |
||||
|
* @param funName 函数名 |
||||
|
* @param fun 需要合并的函数逻辑 |
||||
|
*/ |
||||
|
function mergeFunction<T, K extends keyof T>(obj: T, funName: K, fun: T[K]) { |
||||
|
if (!Typeof.isFun(fun)) return; |
||||
|
const rawFun = obj[funName] as Function; |
||||
|
if (!rawFun) { |
||||
|
obj[funName] = fun; |
||||
|
return; |
||||
|
} |
||||
|
// const func:Function = fun;
|
||||
|
if (Typeof.isFunction(rawFun)) { |
||||
|
obj[funName] = (function (this: any, ...args: any[]) { |
||||
|
if (Typeof.isAsyncFunction(fun)) { |
||||
|
fun.apply(this, args).then(() => rawFun.apply(this, args)); |
||||
|
} else { |
||||
|
fun.apply(this, args); |
||||
|
return rawFun.apply(this, args); |
||||
|
} |
||||
|
}) as any; |
||||
|
} else if (Typeof.isAsyncFunction(rawFun)) { |
||||
|
obj[funName] = (async function (this: any, ...args: any[]) { |
||||
|
if (Typeof.isAsyncFunction(fun)) { |
||||
|
await fun.apply(this, args); |
||||
|
} else { |
||||
|
fun.apply(this, args); |
||||
|
} |
||||
|
return await rawFun.apply(this, args); |
||||
|
}) as any; |
||||
|
} else { |
||||
|
console.warn("mergeFunction 失败不支持的函数类型:", rawFun); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 合并vue组件的expose对象,会将sources中存在且expose中不存在的属性合并到expose对象 |
||||
|
* @param expose |
||||
|
* @param sources |
||||
|
*/ |
||||
|
function mergeExpose(expose: Record<string, any>, sources: any) { |
||||
|
if (!expose) return; |
||||
|
sources = toRaw(sources); |
||||
|
if (!sources || Typeof.isDate(sources) || Typeof.isArray(sources) || !Typeof.isObj(sources)) return; |
||||
|
// sources 是 dom 对象
|
||||
|
if (sources instanceof HTMLElement || (sources.constructor?.name?.includes("HTML") && sources.constructor?.name?.includes("Element"))) { |
||||
|
expose.$el = sources; |
||||
|
return; |
||||
|
} |
||||
|
// 压制警告[Vue warn] Object.keys(sources)
|
||||
|
const rawConsoleWarn = console.warn; |
||||
|
console.warn = _emptyFun; |
||||
|
let keys = Object.keys(sources); |
||||
|
console.warn = rawConsoleWarn; |
||||
|
// 处理 expose
|
||||
|
for (let key of keys) { |
||||
|
const newValue = sources[key]; |
||||
|
if (Typeof.noValue(newValue)) continue; |
||||
|
expose[key] = newValue; |
||||
|
} |
||||
|
// 处理 vue 组件内置属性
|
||||
|
keys = ["$data", "$props", "$attrs", "$slots", "$refs", "$emit", "$on", "$off", "$once", "$forceUpdate", "$nextTick", "$watch", "$el", "$options", "$parent", "$root"]; |
||||
|
for (let key of keys) { |
||||
|
const newValue = sources[key]; |
||||
|
if (Typeof.noValue(newValue)) continue; |
||||
|
expose[key] = newValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 空函数
|
||||
|
function _emptyFun() { |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 计算响应式断点值 |
||||
|
* @param breakpointsConfig 响应式断点配置 |
||||
|
* @param current 当前响应式断点: useBreakpoints().current() |
||||
|
* @param defValue 默认值 |
||||
|
*/ |
||||
|
function calcBreakpointValue(breakpointsConfig: number | Record<string, number> | undefined, current: Array<string> | undefined, defValue: number): number { |
||||
|
if (Typeof.noValue(breakpointsConfig)) return defValue; |
||||
|
if (Typeof.isNum(breakpointsConfig)) return breakpointsConfig; |
||||
|
let minValue: number | undefined = undefined; |
||||
|
for (let key in breakpointsConfig) { |
||||
|
const value = breakpointsConfig[key]; |
||||
|
if (minValue === undefined) { |
||||
|
minValue = value; |
||||
|
} else if (value < minValue) { |
||||
|
minValue = value; |
||||
|
} |
||||
|
} |
||||
|
if (minValue === undefined) return defValue; |
||||
|
if (!current || current.length <= 0) return minValue; |
||||
|
const breakpoint = current[current.length - 1]; |
||||
|
if (!breakpoint) return defValue; |
||||
|
return breakpointsConfig?.[breakpoint] ?? defValue; |
||||
|
} |
||||
|
|
||||
|
export type { |
||||
|
MergePropsObjOptions, |
||||
|
} |
||||
|
|
||||
|
export default { |
||||
|
createVNode: h, |
||||
|
h, |
||||
|
debugRender, |
||||
|
toVNode, |
||||
|
defMergePropsObjOptions, |
||||
|
mergePropsObj, |
||||
|
mergeFunction, |
||||
|
mergeExpose, |
||||
|
calcBreakpointValue, |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
createVNode, |
||||
|
h, |
||||
|
debugRender, |
||||
|
toVNode, |
||||
|
defMergePropsObjOptions, |
||||
|
mergePropsObj, |
||||
|
mergeFunction, |
||||
|
mergeExpose, |
||||
|
calcBreakpointValue, |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import type { Component, DefineComponent, VNode } from 'vue' |
||||
|
|
||||
|
declare global { |
||||
|
/** vue node 原子类型 */ |
||||
|
type VueNodeAtom = VNode | string | number | boolean | Date | null | undefined | (() => VueNode) |
||||
|
|
||||
|
/** vue node 类型,可使用 ComponentsUtils.toVNode(VueNode) 转换成 VNode 后直接渲染 */ |
||||
|
type VueNode = VueNodeAtom | Array<VueNode | VueNodeAtom> |
||||
|
|
||||
|
/** vue组件 */ |
||||
|
type VueComponent = Component | DefineComponent |
||||
|
} |
||||
|
|
||||
|
export {} |
||||
Loading…
Reference in new issue