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

<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>