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' |
|||
|
|||
const router = createRouter({ |
|||
history: createWebHashHistory(import.meta.env.BASE_URL), |
|||
routes: [ |
|||
{ |
|||
path: '/', |
|||
name: 'home', |
|||
// 自动引导到 /editor
|
|||
redirect: '/editor' |
|||
}, |
|||
{ |
|||
path: '/editor', |
|||
name: 'editor', |
|||
// component: HomeView,
|
|||
component: () => import('../editor/ModelMain.vue') |
|||
}, |
|||
{ |
|||
path: '/about', |
|||
name: 'about', |
|||
// route level code-splitting
|
|||
// this generates a separate chunk (About.[hash].js) for this route
|
|||
// which is lazy-loaded when the route is visited.
|
|||
component: () => import('../views/AboutView.vue') |
|||
} |
|||
] |
|||
history: createWebHashHistory(import.meta.env.BASE_URL), |
|||
routes: [ |
|||
{ |
|||
path: '/', |
|||
name: 'home', |
|||
// 自动引导到 /editor
|
|||
redirect: '/editor' |
|||
}, |
|||
{ |
|||
path: '/editor', |
|||
name: 'editor', |
|||
// component: HomeView,
|
|||
component: () => import('../editor/ModelMain.vue') |
|||
}, |
|||
{ |
|||
path: '/about', |
|||
name: 'about', |
|||
// route level code-splitting
|
|||
// this generates a separate chunk (About.[hash].js) for this route
|
|||
// which is lazy-loaded when the route is visited.
|
|||
component: () => import('../views/AboutView.vue') |
|||
}, |
|||
|
|||
{ |
|||
path: '/DataForm01', |
|||
name: 'DataForm01', |
|||
component: () => import('@/pages/DataForm01.vue'), |
|||
}, |
|||
] |
|||
}) |
|||
|
|||
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