You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
581 lines
20 KiB
581 lines
20 KiB
<script setup lang="ts">
|
|
import lodash from "lodash";
|
|
import { computed, getCurrentInstance, nextTick, onMounted, reactive, watch } from "vue";
|
|
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
|
|
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, type WatchFormFieldValue } from "./DataFormTypes.ts";
|
|
import { dataFormInputComponents } from "./DataFormConstant.ts";
|
|
import { dataPathToNamePath } from "./DataFormUtils.tsx";
|
|
|
|
const vLoading = ElLoadingDirective;
|
|
|
|
defineOptions({
|
|
name: 'DataForm',
|
|
});
|
|
|
|
// 组件事件定义
|
|
const emit = defineEmits<{
|
|
/** loading变化事件 */
|
|
loadingChange: [loading: boolean];
|
|
/** 表单字段变化 */
|
|
// fieldsChange: [changedFields: Array<FieldData>, allFields: Array<FieldData>];
|
|
/** 表单值变化 */
|
|
dataChange: [newData: 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);
|
|
// 表单行内容
|
|
const formRows = computed(() => getFormRows(state.dataFormItems, state.columnCount));
|
|
|
|
// 监听响应式断点
|
|
if (Typeof.isObj(props.columnCount)) {
|
|
watch(breakpoints.current(), current => state.columnCount = calcColumnCount(props.columnCount, current), { immediate: true });
|
|
}
|
|
// 加载数据
|
|
if (props.autoLoadData && props.dataApi) reload().finally();
|
|
// 监听data
|
|
watch(() => state.data, newData => {
|
|
const firstDataChange = data.firstDataChange;
|
|
dataChange(state.dataFormItems, newData);
|
|
if (!firstDataChange) emit("dataChange", newData);
|
|
}, { immediate: true, deep: true });
|
|
// 监听loading
|
|
watch(() => state.loading, loading => emit("loadingChange", loading));
|
|
// 设置ctxData(表单上下文数据)
|
|
onMounted(() => data.ctxData.instance = instance!);
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* 根据 dataFormItems 和 columnCount 配置计算出表单行渲染内容
|
|
* @param dataFormItems 表单项数组
|
|
* @param columnCount 一行显示的字段数量
|
|
*/
|
|
function getFormRows(dataFormItems: Array<DataFormItem>, columnCount: number) {
|
|
if (columnCount < 1) columnCount = 1;
|
|
const formRows: Array<Array<DataFormItem>> = [];
|
|
let formRow: Array<DataFormItem> | undefined;
|
|
let columnIndex = 0;
|
|
for (let formItem of dataFormItems) {
|
|
if (formItem.hidden) {
|
|
continue;
|
|
}
|
|
// 第一行
|
|
if (!formRow) {
|
|
formRow = [];
|
|
formRows.push(formRow);
|
|
}
|
|
// 判断是否需要换行
|
|
if (columnIndex >= columnCount) {
|
|
columnIndex = 0;
|
|
formRow = [];
|
|
formRows.push(formRow);
|
|
}
|
|
// 当前行剩下的列数
|
|
const leftOverColumnCount = columnCount - columnIndex;
|
|
// 当前数据需要占用的列数
|
|
let currentColumnCount = Math.min(formItem.widthCount, columnCount);
|
|
// 剩下的列数不足以放下当前表单项,需要换行
|
|
if (currentColumnCount > leftOverColumnCount) {
|
|
columnIndex = 0;
|
|
formRow = [];
|
|
formRows.push(formRow);
|
|
}
|
|
// 增加列索引
|
|
columnIndex += currentColumnCount;
|
|
// formItem 加入当前行
|
|
formRow.push(formItem);
|
|
}
|
|
// 增加 submit 区域
|
|
if (slots.submit && formRows.length > 0) {
|
|
formRow = formRows[formRows.length - 1];
|
|
const nextRow = formRow.length >= columnCount;
|
|
const submitFormItem: DataFormItem = {
|
|
widthCount: nextRow ? columnCount : columnCount - formRow.length,
|
|
inputRef: "__submit",
|
|
input: slots.submit,
|
|
inputProps: {},
|
|
formItemProps: {
|
|
autoLink: false,
|
|
...props.submitFormItemProps,
|
|
},
|
|
};
|
|
if (nextRow) {
|
|
formRows.push([submitFormItem]);
|
|
} else {
|
|
// 优化 submit 区域与上一个字段直接的间距
|
|
if (!submitFormItem.formItemProps.style) submitFormItem.formItemProps.style = {};
|
|
if (!submitFormItem.formItemProps.style.paddingLeft && !submitFormItem.formItemProps.style["padding-left"]) {
|
|
submitFormItem.formItemProps.style.paddingLeft = "12px";
|
|
}
|
|
formRow.push(submitFormItem);
|
|
}
|
|
// 优化表单只有单行时的 submit 区域宽度(更加自然合理)
|
|
if (formRows.length === 1) {
|
|
if (submitFormItem.formItemProps.class) {
|
|
submitFormItem.formItemProps.class = `${submitFormItem.formItemProps.class} data-form-item-auto`;
|
|
} else {
|
|
submitFormItem.formItemProps.class = "data-form-item-auto";
|
|
}
|
|
}
|
|
}
|
|
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 响应式断点配置
|
|
* @param current 当前响应式断点值
|
|
*/
|
|
function calcColumnCount(columnCount?: DataFormProps["columnCount"], current?: Array<string>): number {
|
|
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;
|
|
}
|
|
|
|
const expose: DataFormExpose = {
|
|
state,
|
|
data,
|
|
};
|
|
// 定义组件公开内容
|
|
defineExpose(expose);
|
|
|
|
export type {
|
|
DataFormProps,
|
|
DataFormState,
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<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-input-width: 100%;
|
|
--el-date-editor-width: 100%;
|
|
}
|
|
|
|
.data-form :deep(.el-cascader) {
|
|
width: 100%;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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>
|
|
|