Browse Source

feat(components): 实现 DataForm组件

- 添加 DataForm 组件的基本功能和样式
- 实现表单数据的响应式处理
- 添加多种内置输入组件的支持
- 实现表单布局和样式的自定义配置
- 添加表单数据的实时预览功能
master
lizw-2015 7 months ago
parent
commit
a9a518b747
  1. 216
      src/components/data-form/DataForm.vue
  2. 26
      src/components/data-form/DataFormConstant.ts
  3. 2
      src/components/data-form/DataFormTypes.ts
  4. 418
      src/pages/DataForm01.vue

216
src/components/data-form/DataForm.vue

@ -1,13 +1,16 @@
<script setup lang="ts">
import lodash from "lodash";
import { computed, getCurrentInstance, onMounted, reactive, watch } from "vue";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, watch } from "vue";
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
import { Expression, Typeof } from "@ease-forge/shared";
import { ElForm, ElFormItem, ElLoadingDirective } from "element-plus";
import { Expression, Request, Typeof, Utils } 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 { type DataFormData, type DataFormItem, type DataFormProps, type DataFormState, type FormData, type FormField, type WatchFormFieldValue } from "./DataFormTypes.ts";
import { dataFormInputComponents } from "./DataFormConstant.ts";
import { dataPathToNamePath } from "./DataFormUtils.tsx";
const vLoading = ElLoadingDirective;
defineOptions({
name: 'DataForm',
});
@ -68,9 +71,9 @@ if (Typeof.isObj(props.columnCount)) {
watch(breakpoints.current(), current => state.columnCount = calcColumnCount(props.columnCount, current), { immediate: true });
}
//
// if (props.autoLoadData && props.dataApi) reload().finally();
if (props.autoLoadData && props.dataApi) reload().finally();
// data
// watch(() => state.data, newData => dataChange(state.dataFormItems, newData), { immediate: true, deep: true });
watch(() => state.data, newData => dataChange(state.dataFormItems, newData), { immediate: true, deep: true });
// loading
watch(() => state.loading, loading => emit("loadingChange", loading));
// ctxData()
@ -240,6 +243,46 @@ function getFormRows(dataFormItems: Array<DataFormItem>, columnCount: number) {
return formRows;
}
/** 表单数据变化,执行watchValues */
function dataChange(dataFormItems: Array<DataFormItem>, newFormData?: FormData) {
// Record<dataPath, Array<{watch, watchOwnerDataFormItem}>>
const watchRecord: Record<string, Array<{ watch: WatchFormFieldValue; item: DataFormItem; }>> = {};
for (let dataFormItem of dataFormItems) {
const { watchValues } = dataFormItem;
if (!watchValues) continue;
for (let watchValue of watchValues) {
const { dataPath, onChange } = watchValue;
if (!dataPath || !onChange) continue;
if (!watchRecord[dataPath]) watchRecord[dataPath] = [];
watchRecord[dataPath].push({ watch: watchValue, item: dataFormItem });
}
}
// watchValues onChange
const watchFns: Array<Function> = [];
for (let dataPath in watchRecord) {
const watchItems = watchRecord[dataPath];
const oldValue = data.oldData[dataPath];
const newValue = Expression.getKeyPathValue(dataPath, newFormData);
const hasChange = !Utils.equalsValues(oldValue, newValue);
for (let watchItem of watchItems) {
const { watch: { onChange, immediate }, item: dataFormItem } = watchItem;
if (!onChange) continue;
const input = data.inputRefs[dataFormItem.inputRef];
const form = data.formRef;
if (immediate && data.firstDataChange) {
// immediate, onChange hasChange
watchFns.push(() => onChange(oldValue, newValue, newFormData, dataFormItem, input, form, data.ctxData));
} else if (hasChange) {
watchFns.push(() => onChange(oldValue, newValue, newFormData, dataFormItem, input, form, data.ctxData));
}
}
data.oldData[dataPath] = newValue;
}
data.firstDataChange = false;
// watchValues | nextTick, antd bug 使 nextTick
nextTick(() => watchFns.forEach(onChange => onChange()));
}
/**
* 根据响应式断点配置计算一行显示的字段数量
* @param columnCount 响应式断点配置
@ -249,6 +292,53 @@ function calcColumnCount(columnCount?: DataFormProps["columnCount"], current?: A
return Math.min(calcBreakpointValue(columnCount, current, props.defColumnCount ?? 2), 24);
}
/**
* 重新加载数据
* @param dataApi 请求配置
*/
async function reload(dataApi?: HttpRequestConfig<FormData>) {
const requestConfig: HttpRequestConfig<FormData> = lodash.defaultsDeep({}, dataApi, props.dataApi);
if (Typeof.noValue(requestConfig.url)) {
console.warn("未配置服务端数据API url");
return;
}
state.loading = true;
try {
state.data = await Request.request.request(requestConfig);
} finally {
state.loading = false;
}
}
/** 计算输入组件的值 */
function calcInputValue(dataFormItem: DataFormItem) {
const { dataPath, format } = dataFormItem;
if (!dataPath) return;
const dataValue = Expression.getKeyPathValue(dataPath, state.data);
if (!format) return dataValue;
if (Typeof.isArray(format)) {
const dictItem = window.globalConfig.matchDictItem(format, dataValue);
return dictItem?.text ?? dataValue;
} else if (Typeof.isFunction(format)) {
return format(dataValue, dataPath, state.data);
}
return dataValue;
}
/** 设置输入值 */
function setInputValue(newVal: any, dataFormItem: DataFormItem) {
const { dataPath, transform } = dataFormItem;
if (!dataPath) return;
let value = newVal;
if (Typeof.isArray(transform)) {
const dictItem = window.globalConfig.matchDictItem(transform, newVal);
value = dictItem?.text ?? newVal;
} else if (Typeof.isFunction(transform)) {
value = transform(newVal, dataPath, state.data);
}
Expression.setKeyPathValue(dataPath, state.data, value);
}
interface DataFormExpose {
state: DataFormState;
data: DataFormData;
@ -268,15 +358,121 @@ export type {
</script>
<template>
<div class="data-form">
<Loading>
</Loading>
</div>
<ElForm
:ref="($ref: any) => data.formRef = $ref"
:class="[
'data-form',
{
'data-form-label-fixed': !isBothFixed(),
'data-form-label-input-fixed': isBothFixed(),
},
]"
v-loading="state.loading"
:labelPosition="props.labelPosition"
:labelSuffix="props.labelSuffix"
:labelWidth="props.labelWidth"
:size="props.size"
:showMessage="props.showMessage"
:inlineMessage="props.inlineMessage"
:requireAsteriskPosition="props.requireAsteriskPosition"
:hideRequiredAsterisk="props.hideRequiredAsterisk"
:rules="props.rules"
:validateOnRuleChange="props.validateOnRuleChange"
:disabled="props.disabled"
:scrollToError="props.scrollToError"
:scrollIntoViewOptions="props.scrollIntoViewOptions"
v-bind="props.rawProps"
:model="state.data"
>
<div v-for="(formRow, rowIdx) in formRows" class="data-form-row">
<template v-for="(dataFormItem, celIdx) in formRow">
<div
v-if="dataFormItem.hidden"
:key="`div_${rowIdx}_${celIdx}`"
:class="[
'data-form-item',
'data-form-item-hidden',
`data-form-item-flex-${Math.min(dataFormItem.widthCount, state.columnCount)}`,
{
'multiple-width-count': Math.min(dataFormItem.widthCount, state.columnCount) > 1,
},
]"
/>
<ElFormItem
v-else
:key="`form_item_${rowIdx}_${celIdx}`"
v-bind="dataFormItem.formItemProps"
:class="[
'data-form-item',
`data-form-item-flex-${Math.min(dataFormItem.widthCount, state.columnCount)}`,
{
'multiple-width-count': Math.min(dataFormItem.widthCount, state.columnCount) > 1,
'data-form-item-has-ext-input': dataFormItem.extInputs && dataFormItem.extInputs.length > 0,
},
]"
>
<component
v-if="dataFormItem.input"
:key="dataFormItem.inputRef"
class="data-form-item-main-input"
:is="dataFormItem.input"
:ref="($ref: any) => data.inputRefs[dataFormItem.inputRef] = $ref"
v-bind="dataFormItem.inputProps"
:modelValue="calcInputValue(dataFormItem)"
@update:modelValue="(newVal: any) => setInputValue(newVal, dataFormItem)"
/>
<template v-for="(extInput, innerIdx) in dataFormItem.extInputs">
<ElFormItem
:key="`${rowIdx}_${celIdx}_${innerIdx}`"
v-if="extInput.input"
v-bind="extInput.formItemProps"
class="data-form-item-ext-input"
>
<component
v-if="extInput.input"
:key="extInput.inputRef"
:is="extInput.input"
:ref="($ref: any) => data.inputRefs[extInput.inputRef] = $ref"
v-bind="extInput.inputProps"
:modelValue="calcInputValue(extInput)"
@update:modelValue="(newVal: any) => setInputValue(newVal, extInput)"
/>
</ElFormItem>
</template>
</ElFormItem>
</template>
<div
v-if="state.columnCount - lodash.sum(formRow.map(item => item.widthCount)) > 0"
:key="`div_${rowIdx}`"
:class="[
'data-form-item',
'data-form-item-empty',
`data-form-item-flex-${state.columnCount - lodash.sum(formRow.map(item => item.widthCount))}`,
]"
/>
</div>
</ElForm>
</template>
<style scoped>
.data-form {
width: 100%;
height: 100%;
}
.data-form :deep(.el-date-editor) {
--el-date-editor-width: unset;
}
.data-form-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
.data-form-row > .data-form-item[class*=data-form-item-flex-]:not(.multiple-width-count) {
flex-shrink: 0;
}
.data-form-item.data-form-item-flex-1 {
flex: 1 1 0;

26
src/components/data-form/DataFormConstant.ts

@ -3,6 +3,7 @@ import {
ElAutocomplete,
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElColorPicker,
ElDatePicker,
ElInput,
@ -10,6 +11,7 @@ import {
ElInputTag,
ElMention,
ElRadio,
ElRadioGroup,
ElRate,
ElSelect,
ElSelectV2,
@ -25,25 +27,27 @@ 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),
Checkbox: markRaw<any>(ElCheckbox),
CheckboxGroup: markRaw<any>(ElCheckboxGroup),
Radio: markRaw<any>(ElRadio),
Rate: markRaw<any>(ElRate),
RadioGroup: markRaw<any>(ElRadioGroup),
Switch: markRaw<any>(ElSwitch),
Select: markRaw<any>(ElSelect),
SelectV2: markRaw<any>(ElSelectV2),
Slider: markRaw<any>(ElSlider),
Switch: markRaw<any>(ElSwitch),
DatePicker: markRaw<any>(ElDatePicker),
TimePicker: markRaw<any>(ElTimePicker),
TimeSelect: markRaw<any>(ElTimeSelect),
Transfer: markRaw<any>(ElTransfer),
Cascader: markRaw<any>(ElCascader),
TreeSelect: markRaw<any>(ElTreeSelect),
Autocomplete: markRaw<any>(ElAutocomplete),
InputTag: markRaw<any>(ElInputTag),
ColorPicker: markRaw<any>(ElColorPicker),
Slider: markRaw<any>(ElSlider),
Rate: markRaw<any>(ElRate),
Mention: markRaw<any>(ElMention),
Transfer: markRaw<any>(ElTransfer),
Upload: markRaw<any>(ElUpload),
};

2
src/components/data-form/DataFormTypes.ts

@ -49,7 +49,7 @@ interface FormInputBaseProps {
/** 占位符 */
placeholder?: string;
/** 允许清空 */
allowClear?: boolean;
clearable?: boolean;
/** 只读 */
readonly?: boolean;
/** 禁用 */

418
src/pages/DataForm01.vue

@ -1,11 +1,423 @@
<script setup lang="ts">
import { reactive } from "vue";
import { Format } from "@ease-forge/shared";
import DataForm, { type DataFormProps } from "@/components/data-form/DataForm.vue";
const dataForm1 = reactive<DataFormProps>({
data: {
str: "abcABC",
num_1: 123,
checkbox_1: true,
checkbox_group_1: ["v2"],
radio_1: false,
radio_group_1: "v2",
switch_1: false,
select_1: "006",
select_2: "006",
date_1: "2025-02-03",
date_2: Format.toDayjs("2025-02-03").toDate(),
time_1: "08:30:00",
time_select_1: "08:30:00",
cascader_1: ["wh"],
tree_select_1: "wh",
autocomplete: "汤磊",
tags_1: ["DEV"],
color_1: "#CA4343",
slider_1: 80,
rate_1: 3.5,
mentions_1: "abc @bj ABC",
transfer_1: ["v1", "v2"],
upload_1: [
{
name: 'image.png',
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
{
uid: '-2',
name: 'image.png',
status: 'done',
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
{
uid: '-3',
percent: 50,
name: 'image.png',
status: 'uploading',
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
{
uid: '-5',
name: 'image.png',
status: 'error',
},
],
password: "2025fff",
textarea: [
"蛇衔春色千山秀",
"龙舞祥光万户新",
"福满人间",
"属性适用于 textarea 节点,并且只有高度会自动变化。另外 autoSize 可以设定为一个对象,指定最小行数和最大行数"
].join("\n"),
},
formFields: [
{
dataPath: 'str', label: '字符串', input: 'Input',
inputProps: {
placeholder: '请输入',
clearable: true,
},
},
{
dataPath: 'num_1', label: '数字', input: 'InputNumber',
inputProps: {
placeholder: '请输入',
controlsPosition: 'right',
},
},
{
dataPath: 'checkbox_1', label: '复选', input: 'Checkbox',
inputProps: {
trueValue: true,
falseValue: false,
},
},
{
dataPath: 'checkbox_group_1', label: '复选组', input: 'CheckboxGroup',
inputProps: {
max: 2,
// TODO options
},
},
{
dataPath: 'radio_1', label: '单选', input: 'Radio',
inputProps: {
value: true,
},
},
{
dataPath: 'radio_group_1', label: '单选组', input: 'RadioGroup',
inputProps: {
// TODO options
},
},
{
dataPath: 'switch_1', label: '开关', input: 'Switch',
inputProps: {
inactiveValue: false,
activeValue: true,
inlinePrompt: true,
inactiveText: "关闭",
activeText: "开启",
},
},
{
dataPath: 'select_1', label: '选择器', input: 'Select',
inputProps: {
placeholder: "请选择",
clearable: true,
// TODO options
},
},
{
dataPath: 'select_2', label: '选择器', input: 'SelectV2',
inputProps: {
placeholder: "请选择",
clearable: true,
options: [
{ value: "001", label: "选项1" },
{ value: "002", label: "选项2" },
{ value: "003", label: "选项3" },
{ value: "004", label: "选项4" },
{ value: "005", label: "选项5" },
{ value: "006", label: "选项6" },
],
},
},
{
dataPath: 'date_1', label: '日期选择', input: 'DatePicker',
inputProps: {
placeholder: "选择时间",
type: "date",
format: "YYYY-MM-DD",
valueFormat: "YYYY-MM-DD",
},
},
{
dataPath: 'date_2', label: '日期时间选择', input: 'DatePicker',
inputProps: {
placeholder: "选择时间",
type: "datetime",
format: "YYYY-MM-DD HH:mm:ss",
// valueFormat: "YYYY-MM-DD",
},
},
{
dataPath: 'time_1', label: '时间选择', input: 'TimePicker',
inputProps: {
placeholder: "选择时间",
isRange: false,
format: "HH:mm:ss",
valueFormat: "HH:mm:ss",
},
},
{
dataPath: 'time_select_1', label: '时间选择', input: 'TimeSelect',
inputProps: {
placeholder: "选择时间",
clearable: true,
step: "00:15",
format: "HH:mm:ss",
},
},
{
dataPath: 'cascader_1', label: '级联选择', input: 'Cascader',
inputProps: {
placeholder: '请输入',
options: [
{
value: 'zj', label: '浙江',
children: [
{
value: 'hz', label: '杭州',
children: [
{ value: 'xh', label: '西湖' },
],
},
],
},
{
value: 'js', label: '江苏',
children: [
{
value: 'nj', label: '南京',
children: [
{ value: 'zhm', label: '中华门' },
],
},
],
},
{ value: 'sh', label: '上海', },
{ value: 'bj', label: '北京', },
{ value: 'sz', label: '深圳', },
{ value: 'cq', label: '重庆', },
{ value: 'gz', label: '广州', },
{ value: 'cd', label: '成都', },
{ value: 'wh', label: '武汉', },
],
},
},
{
dataPath: 'tree_select_1', label: '树形选择', input: 'TreeSelect',
inputProps: {
placeholder: '请输入',
clearable: true,
defaultExpandAll: true,
// renderAfterExpand: true,
data: [
{
value: 'zj', label: '浙江',
children: [
{
value: 'hz', label: '杭州',
children: [
{ value: 'xh', label: '西湖' },
],
},
],
},
{ value: 'wh', label: '武汉', },
{ value: 'sh', label: '上海', },
{ value: 'bj', label: '北京', },
],
},
},
{
dataPath: 'autocomplete', label: '自动补全', input: 'Autocomplete',
inputProps: {
placeholder: "自动补全",
clearable: true,
fetchSuggestions: (queryString: string, callback: Function) => {
const data = [
{ label: "1熊超", value: "熊超" },
{ label: "2姚洋", value: "姚洋" },
{ label: "3崔艳", value: "崔艳" },
{ label: "4侯芳", value: "侯芳" },
{ label: "5林敏", value: "林敏" },
{ label: "6金丽", value: "金丽" },
{ label: "7侯秀英", value: "侯秀英" },
{ label: "8刘秀英", value: "刘秀英" },
{ label: "9林刚", value: "林刚" },
{ label: "10汤磊", value: "汤磊" },
{ label: "11刘军", value: "刘军" },
{ label: "12潘娜", value: "潘娜" },
{ label: "13袁军", value: "袁军" },
{ label: "14段勇", value: "段勇" },
{ label: "15李霞", value: "李霞" },
{ label: "16赵杰", value: "赵杰" },
];
const res = data.filter(item => item.label.includes(queryString));
callback(res);
},
},
},
{
dataPath: 'tags_1', label: '标签输入', input: 'InputTag',
inputProps: {
placeholder: "请输入",
clearable: true,
},
},
{
dataPath: 'color_1', label: '颜色选择', input: 'ColorPicker',
inputProps: {},
},
{
dataPath: 'slider_1', label: '滑块', input: 'Slider',
widthCount: 2,
inputProps: {
showInput: true,
},
},
{
dataPath: 'rate_1', label: '评分', input: 'Rate',
inputProps: {
allowHalf: true,
},
},
{
dataPath: 'mentions_1', label: '提及', input: 'Mention',
inputProps: {
placeholder: '请输入',
options: [
{ value: 'sh', label: '上海', },
{ value: 'bj', label: '北京', },
{ value: 'sz', label: '深圳', },
{ value: 'cq', label: '重庆', },
],
},
},
{
dataPath: 'password', label: '密码', input: 'Input',
inputProps: {
placeholder: '请输入',
clearable: true,
type: "password",
showPassword: true,
},
},
{
dataPath: 'textarea', label: '多行文本', input: 'Input',
widthCount: 3,
inputProps: {
placeholder: '请输入',
clearable: true,
type: "textarea",
autosize: { minRows: 3, maxRows: 8 },
},
},
{
dataPath: 'transfer_1', label: '穿梭框', input: 'Transfer',
widthCount: 3,
inputProps: {
titles: ["数据项", "已选择"],
data: [
{ title: "选项1", key: "v1" },
{ title: "选项2", key: "v2" },
{ title: "选项3", key: "v3" },
{ title: "选项4", key: "v4" },
{ title: "选项5", key: "v5" },
{ title: "选项6", key: "v6" },
{ title: "选项7", key: "v7" },
{ title: "选项8", key: "v8" },
],
},
},
{
dataPath: 'upload_1', label: '文件上传', input: 'Upload',
widthCount: 3,
inputProps: {
listType: "picture-card",
fileList: [
{
name: 'food.jpeg',
url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
{
name: 'food.jpeg',
url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
{
name: 'food.jpeg',
url: 'https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100',
},
],
},
},
],
columnCount: 3,
layout: "onlyLabelFixed",
});
</script>
<template>
DataForm01.vue
<div class="flex-row-container" style="column-gap: 12px;height: calc(100% - 30px);">
<DataForm
class="flex-item-fixed"
style="width: 900px;height: 100%; overflow-y: auto;border: 1px solid #ccc;padding: 6px"
:data="dataForm1.data"
:formFields="dataForm1.formFields"
:columnCount="dataForm1.columnCount"
:layout="dataForm1.layout"
labelWidth="100px"
inputWidth=""
:colon="true"
/>
<pre class="flex-item-fill" style="overflow-y: auto;" lang="json">{{ JSON.stringify(dataForm1.data, null, 4) }}</pre>
</div>
<div style="height: 24px;"/>
</template>
<style scoped lang="less">
<style>
/** flex多行容器 */
.flex-column-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
/** flex多列容器 */
.flex-row-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
/** flex自动填充 */
.flex-item-fill {
flex-grow: 1;
overflow: hidden;
}
/** flex固定大小 */
.flex-item-fixed {
flex-shrink: 0;
}
/** flex主轴上对齐方式 */
.flex-justify-content-center {
justify-content: center;
}
/** flex交叉轴上对齐方式 */
.flex-align-items-center {
align-items: center;
}
</style>
/** 内容居中 */
.content-center {
display: flex;
align-items: center;
justify-content: center;
}
</style>

Loading…
Cancel
Save