54def6aa创建于 2025年8月14日历史提交
<template>
    <div class="flex-col gap-10">
        <div class="flex-row align-c">
            <template v-if="is_remove_selected">
                <el-button class="custom-button" @click="remove_handle">取消操作</el-button>
                <el-button class="custom-button" @click="remove_selected">删除选中</el-button>
            </template>
            <template v-else>
                <el-button class="custom-button" type="primary" @click="add"><icon name="add-wide" size="14"></icon>添加</el-button>
                <el-button v-if="form.form_value.length > 0" class="custom-button" @click="remove_handle"><icon name="delete" size="14"></icon>删除</el-button>
            </template>
        </div>
        <div class="subform flex-row" :style="`height: ${ customHeight };`">
            <div class="table-container rendering-area">
                <div class="table-header flex">
                    <div class="flex-row align-c jc-c">
                        <div class="head-label flex-row align-c jc-c shrink" :style="left_sticky(0)">
                            <el-checkbox v-if="is_remove_selected" v-model="selectAll" :indeterminate="indeterminate" @change="handleCheckAllChange" />
                        </div>
                        <!-- 头部标题显示 -->
                        <div v-for="(item, index) in children" :key="item.id" class="item-label flex-row align-c shrink" :style="`width: ${ item.com_data.com_width }px;${ left_sticky(index + 1) }`">
                            <span v-if="item.com_data.is_required == '1'" class="required">*</span>
                            {{ item.com_data.title }}
                            <tooltip v-if="item.com_data.common_config.help_is_show == '1'" :content="item.com_data.common_config.help_explain" :size="common_store.help_icon_size"></tooltip>
                        </div>
                    </div>
                </div>
                <div class="table-body">
                    <!-- <el-checkbox-group :model-value="selected_list" class="flex-1 flex-col selected-checkbox" @change="checkbox_change"> -->
                    <div class="flex-1 flex-col">
                        <div v-for="(item, index) in form.form_value" :key="index" class="table-row flex-row">
                            <div class="cell-num flex-row align-c jc-c shrink re" :style="left_sticky(0)">
                                <template v-if="is_remove_selected && selected_list.length > 0">
                                    <el-checkbox v-model="selected_list[index]" :value="index" />
                                </template>
                                <template v-else>
                                    <div class="row-num flex-row align-c jc-c">
                                        <template v-if="isEmpty(line_error(index))">{{ index + 1 }}</template>
                                        <template v-else><div class="error-icon">!</div></template>
                                    </div>
                                    <el-tooltip effect="dark" :show-after="200" :hide-after="200" :content="line_error(index)" :disabled="isEmpty(line_error(index))" popper-class="custom-error-tooltip" :show-arrow="false" raw-content placement="top-start">
                                        <div class="operate flex-row align-c jc-c gap-5">
                                            <icon name="enlarge" size="14" color="primary" @click="enlarge_click(index)"></icon>
                                            <el-popconfirm :key="index + get_math()" width="220" title="该条记录存在数据,数据删除后将无法恢复,确定删除?" :hide-after="0" @confirm="remove(index)">
                                                <template #reference>
                                                    <icon name="delete" size="14" color="primary"></icon>
                                                </template>
                                                <template #actions="{ confirm, cancel }">
                                                    <el-button size="small" @click="cancel">取消</el-button>
                                                    <el-button type="danger" size="small" @click="confirm">确定</el-button>
                                                </template>
                                            </el-popconfirm>
                                            <el-dropdown :key="index + get_math()" placement="bottom">
                                                <icon name="more-o" size="14" color="primary"></icon>
                                                <template #dropdown>
                                                    <el-dropdown-menu>
                                                        <el-dropdown-item @click.stop="copy(index, 'bottom')">复制到下一行</el-dropdown-item>
                                                        <el-dropdown-item @click.stop="copy(index, 'last')">复制到最后一行</el-dropdown-item>
                                                        <el-dropdown-item @click.stop="insert(index, 'top')">向上插入一行</el-dropdown-item>
                                                        <el-dropdown-item @click.stop="insert(index, 'bottom')">向下插入一行</el-dropdown-item>
                                                    </el-dropdown-menu>
                                                </template>
                                            </el-dropdown>
                                        </div>
                                    </el-tooltip>
                                </template>
                            </div>
                            <div v-for="(children_item, children_index) in children" :key="children_item.id" :class="['cell re flex-row align-c jc-c shrink', { 'item-row-error': error_list(index, children_item.id)[0] == '1' }]" :style="`width: ${ children_item.com_data?.com_width || 0 }px;${ left_sticky(children_index + 1) }`">
                                <template v-if="show_row(index, children_item.id)">
                                    <el-tooltip effect="dark" :show-after="200" :hide-after="200" :content="error_list(index, children_item.id)[1]" popper-class="custom-error-tooltip" :disabled="error_list(index, children_item.id)[0] == '0'" :show-arrow="false" raw-content placement="top-start">
                                        <subform-rendering v-model="children_item.com_data" v-model:type="children_item.key" :value="item[children_item.id]" :index="index" @change="tablist_change($event, index, children_item.id)" @data_check="data_check($event, index, children_item.id, children_item.com_data)"></subform-rendering>
                                    </el-tooltip>
                                </template>
                            </div>
                        </div>
                    </div>
                    <!-- </el-checkbox-group> -->
                </div>
            </div>
        </div>
    </div>
    <subform-drawer v-model:visible="drawer_visible" v-model:title="drawer_title" v-model:total="form.form_value.length" v-model:size="drawer_index" v-model:form_data="drawer_data" @previous-page="previous_page" @next-page="next_page" @add="drawer_add" @copy="drawer_copy" @change="drawer_change"></subform-drawer>
</template>

<script lang="ts" setup>
import { cloneDeep, isEmpty } from "lodash";
import { commonStore } from "@/store";
import { checkbox_range_handle, get_format_checks, get_format_checks_v2, get_math, number_range_handle } from "@/utils";
const common_store = commonStore();
const props = defineProps({
    value: {
        type: Object,
        default: () => ({}),
    },
    isPreview: {
        type: Boolean,
        default: false,
    },
    isDefault: {
        type: Boolean,
        default: false,
    },
    customHeight: {
        type: String,
        default: '100%'
    }
});
const form = ref(props.value);
watch(() => props.value, (val) => {
    form.value = val;
}, {immediate: true, deep: true});
//#region 判断列是否显示,或者某一行的某一个是否显示
interface DiyItem {
    id: number | string;
    key: string;
    is_enable: string;
    com_data: any;
}
// 计算属性:根据显隐规则过滤出需要显示的组件
const filteredDiyData = computed(() => filtered_Data('all'));
// index: 列索引 id: 组件id
const show_row = (index: number, id: string) => {
    if (props.isPreview || props.isDefault) {
        const show_children = filtered_Data('value', index);
        const children_index = show_children.findIndex((item: any) => item.id === id);
        return children_index !== -1;
    } else {
        return true;
    }
};
// 判断childer符合显隐规则的数量
const filtered_Data = (type: string, index?: number) => { 
    const componentMap = new Map(form.value.children.map((item: any) => [item.id, item])) as any;

    // 取出所有设置显隐规则的组件
    const list = form.value.children.filter((item: any) => ['single-text', 'select', 'radio-btns'].includes(item.key) && ['select', 'radio-btns'].includes(item.com_data.type) && item.com_data.show_hidden_list.length > 0);
    const list_map = list.map((item: DiyItem) => ({ id: item.id, list: item.com_data.show_hidden_list }));
    return form.value.children.filter((item: DiyItem) => {
        // 优先判断是否启用
        if (item.is_enable !== '1') return false;

        if (list_map.length === 0) return true;
        // 将所有的内容的组件进行筛选
        const isShownByRule = list_map.some((list_item: any) => {
            const targetComponent = componentMap.get(list_item.id);
            // 判断显隐规则对应的组件是否存在
            if (!targetComponent) return false;
            return list_item.list.some((hidden_item: any) => {
                // 判断当前组件是否在显隐规则中,如果不在,直接显示,否则的话判断值是否存在
                if (hidden_item.is_show.includes(item.id)) {
                    if (type == 'all') {
                        // 判断所有的是否满足条件
                        const data = form.value.form_value.filter((form_item: any) => form_item[list_item.id].includes(hidden_item.value))
                        return data.length > 0;
                    } else {
                        // 判断是单个还是多个内容
                        if (index == null) {
                            return false;
                        } else {
                            // 否则判断当前组件的值是否存在
                            return form.value.form_value[index][list_item.id].includes(hidden_item.value);
                        }
                    }
                } else {
                    return true;
                }
            });
        });
        return isShownByRule;
    });
};
const children = computed(() => props.isPreview || props.isDefault ? filteredDiyData.value : form.value.children.filter((item: any) => item.is_enable === '1'));
//#endregion
// 表单数据发生变化时的处理
watch(() => form.value.form_value, (val) => {
    if (props.isPreview) {
        if (val.length > 0) {
            val.forEach((item: any, index: number) => {
                if (isEmpty(form.value.form_error_list[index])) {
                    const data: any = {};
                    children.value.forEach((item1: any) => {
                        if (isEmpty(item[item1.id])) {
                            data[item1.id] = { common_config: { is_error: '0', error_text: ''} };
                        }
                    });
                    form.value.form_error_list[index] = data;
                }
            });
        } else {
            form.value.form_error_list = [];
        }
    }
}, {immediate: true, deep: true});

const error_list = computed(() => { 
    return (index: number, id: string) => {
        if (!isEmpty(form.value.form_error_list[index]) && !isEmpty(form.value.form_error_list[index][id])) {
            const data = form.value.form_error_list[index][id];
            return [data.common_config.is_error, data.common_config.error_text];
        } else {
            return ['0', '']
        }
    }
});
const form_data = computed(() => {
    return children.value.reduce((acc: any, item: any) => {
        acc[item.id] = item.com_data.form_value;
        return acc;
    }, {});
});
//#region 表格操作
const remove = (index: number) => {
    form.value.form_value.splice(index, 1);
    // 如果没有数据了,就不显示删除按钮·
    if (form.value.form_value.length <= 0) {
        is_remove_selected.value = false;
    }
};
const add = () => {
    form.value.form_value.push(cloneDeep(form_data.value));
};
// 复制数据
const copy = (index: number, type: string) => {
    const data = cloneDeep(form.value.form_value[index]);
    if (type == 'last') {
        form.value.form_value.push(data);
    } else {
        form.value.form_value.splice(index, 0, data);
    }
};
const insert = (index: number, type: string) => {
    if (type == 'top') {
        // 如果是小于1的时候就将数据放在头部
        if (index - 1 <= 0) {
            form.value.form_value.splice(0, 0, cloneDeep(form_data.value));
        } else {
            form.value.form_value.splice(index - 1, 0, cloneDeep(form_data.value));
        }
    } else {
        form.value.form_value.splice(index + 1, 0, cloneDeep(form_data.value));
    }
};
// 子表单更新数据传递给父组件
const tablist_change = (val:any, index: number, id: string) => {
    form.value.form_value[index][id] = val;
};
//#endregion
//#region 删除操作
const is_remove_selected = ref(false);

const remove_handle = () => { 
    is_remove_selected.value = !is_remove_selected.value;
    // selected_list.value = [];
};
const remove_selected = () => {
    const list: any[] = [];
    // 过滤掉选中的项,直接删除,index为当前项的索引会乱,所以用一个新数组承接
    form.value.form_value.forEach((item: any, index: number) => {
        if (!selected_list.value[index]) {
            list.push(item);
        }
    });
    form.value.form_value = cloneDeep(list);
    if (form.value.form_value.length <= 0) {
        is_remove_selected.value = false;
    }
    // 删除完成之后,将所有的数据改为false
    selected_list.value = form.value.form_value.map((item: any, index: number) => false);
};
// 是否全选
const selectAll = ref(false);
const indeterminate = ref(true);
// 选中的内容
const selected_list = ref<number[]>([]);
// 全选反选时的操作
const handleCheckAllChange = () => {
    const val = form.value.form_value;
    // 如果是全选的话,就按照数据结构全部为true,否则的话是全部为false
    selected_list.value = selectAll.value ? form.value.form_value.map((item: any, index: number) => true) : val.map((item: any) => false)
};
// 监听数据发生变化
watchEffect(() => {
    const val = form.value.form_value;
    // 取出所以选中的项
    const new_selected_list = selected_list.value.filter(item => item);
    // 判断是全选还是未全选
    selectAll.value = val.length > 0 && new_selected_list.length === val.length;
    indeterminate.value = new_selected_list.length > 0 && new_selected_list.length < val.length;
});
// 子组件中有全选和反选的判断,如果加上了checkboxGroup,子组件的全选和反选就无法生效,所以改为数组通过true和false来进行判断
watch(() => form.value.form_value, (new_val) => { 
    if (new_val.length > 0) {
        selected_list.value = new_val.map((item: any) => false)
    } else {
        selected_list.value = [];
    }
}, { immediate: true, deep: true });
//#endregion

const data_check = (object: any, index: number, id: string, com_data: any) => {
    if (props.isPreview) {
        const data = form.value.form_error_list[index][id];
        const form_value = form.value.form_value[index][id];
        if (isEmpty(data)) {
            return 
        }
        // 判断是否是必填字段,并且没有值
        if (com_data.is_required == '1' && isEmpty(form_value)) {
            // 是否报错显示
            data.common_config.is_error = '1';
            data.common_config.error_text = `此项为${['select', 'checkbox', 'upload', 'time', 'address', 'score', 'radio'].includes(object.type) ? '必选' : '必填'}项`;
        } else {
            // 否就清除报错显示
            data.common_config.is_error = '0';
            data.common_config.error_text = '';
            if (object.is_format) {
                if (object.type == 'number') {
                    // 数字组件的校验逻辑
                    number_range_handle(com_data, form_value);
                } else if (object.type == 'checkbox') {
                    // 复选框和复选下拉框的校验逻辑
                    checkbox_range_handle(com_data, form_value);
                } else {
                    // 单行文本的校验逻辑
                    // 对字段进行格式检查
                    get_format_checks_v2(com_data.common_config, form_value);
                }
            }
        } 
    }
};
// 报错显示
const line_error = computed(() => {
    return (index: number) => {
        let text = '';
        for (let i = 0; i < children.value.length; i++) {
            const item = children.value[i];
            if (form.value.form_error_list.length > 0 && form.value.form_error_list[index]) {
                const err_list = form.value.form_error_list[index][item.id] || {};
                // 如果当前行有错误
                if (err_list && err_list.common_config && err_list.common_config.is_error == '1') {
                    if (err_list.common_config.error_text == '此项为必填项') {
                        text = `请填写「${item.com_data.title}」`;
                    } else {
                        text = `请正确填写「${item.com_data.title}」`;
                    }
                    break;
                }
            }
        }
        return text;
    };
});
//#region 表单详情相关
const drawer_visible = ref(false);
const drawer_index = ref(0);
const drawer_title = computed(() => form.value.title);
// 打开抽屉
const enlarge_click = (index: number) => {
    set_drawer_data(index);
    drawer_index.value = index + 1;
    drawer_visible.value = true;
}
const drawer_data = ref([]);
const set_drawer_data = (index: number) => { 
    const data = cloneDeep(form.value.children);
    data.forEach((item: any) => {
        if (props.isPreview) {
            if (form.value.form_error_list[index] && !isEmpty(form.value.form_error_list[index][item.id])) {
                const error = form.value.form_error_list[index][item.id];
                item.com_data.common_config.is_error = error.common_config.is_error;
                item.com_data.common_config.error_text = error.common_config.error_text;
            }
        } else {
            item.com_data.is_required = '0';
            item.com_data.common_config.is_error = '0';
            item.com_data.common_config.error_text = '';
        }
        if (form.value.form_value[index] && !isEmpty(form.value.form_value[index][item.id])) {
            const value = form.value.form_value[index][item.id];
            item.com_data.form_value = value;
        }
    });
    drawer_data.value = data;
};
const previous_page = (index: number) => {
    enlarge_click(index - 1);
};
const next_page = (index: number) => { 
    enlarge_click(index);
};
// 抽屉点击添加
const drawer_add = () => { 
    form.value.form_value.push(cloneDeep(form_data.value));
    enlarge_click(form.value.form_value.length - 1);
};
// 抽屉点击复制
const drawer_copy = (data: any) => { 
    const new_data = data.reduce((acc: any, item: any) => {
        acc[item.id] = item.com_data.form_value;
        return acc;
    }, {});
    form.value.form_value.push(cloneDeep(new_data));
    enlarge_click(form.value.form_value.length - 1);
};

const drawer_change = (data: any) => {
    const new_data = data.reduce((acc: any, item: any) => {
        acc[item.id] = item.com_data.form_value;
        return acc;
    }, {});
    const index = drawer_index.value - 1;
    form.value.form_value[index] = new_data;
    if (props.isPreview) {
        data.forEach((item: any) => {
            const err_list = form.value.form_error_list[index];
            if (err_list && err_list[item.id]) {
                err_list[item.id].common_config.is_error = item.com_data.common_config.is_error;
                err_list[item.id].common_config.error_text = item.com_data.common_config.error_text;
            }
        });
    }
};
//#endregion 
/**
 * 计算左侧粘性定位样式
 * 
 * @param index - 当前元素的索引位置
 * @returns 返回CSS粘性定位样式字符串,若不符合条件则返回空字符串
 */
 const left_sticky = (index: number) => {
    // 从表单数据中获取是否启用固定和固定数量配置
    const { is_fixed = '0', fixed_num = 1 } = form.value?.computer || {};
    
    // 检查是否满足粘性定位条件:启用固定且索引在固定数量范围内
    if (is_fixed !== '1' || index >= fixed_num || fixed_num <= 0) {
        return '';
    }

    const childrenList = children.value;
    // 初始左侧偏移量:第一个元素为0,其他元素默认78px
    let left = index === 0 ? 0 : 78;
    
    // 计算当前元素之前的兄弟元素宽度总和作为偏移量
    if (index > 0) {
        for (let i = 1; i < index; i++) {
            left += childrenList[i - 1]?.com_data?.com_width || 0;
        }
    }
    
    // 生成粘性定位CSS样式
    return `position: sticky;left: ${left}px;z-index: 2;`;
}
</script>

<style lang="scss" scoped>
.table-container {
    padding-bottom: 0.2rem;
    overflow: auto; /* 允许滚动 */
    .table-header {
        position: sticky;
        top: 0;
        z-index: 3;
        display: flex;
        .head-label {
            background: #f0f1f4;
            border: 0.1rem solid #e6e8ed;
            border-top-left-radius: 0.3rem;
            width: 7.8rem;
            height: 3.5rem;
        }
        .item-label {
            flex-shrink: 0;
            height: 3.5rem;
            padding: 0.5rem;
            background: #f0f1f4;
            font-size: 1.4rem;
            color: #141E31;
            border: 0.1rem solid #e6e8ed;
            border-left: 0;
        }
    }
    .table-body {
        display: flex;
        .table-row .cell-num {
            text-align: center;
            background: #fff;
            border: 0.1rem solid #e6e8ed;
            border-top: 0;
            width: 7.8rem;
            min-height: 4rem;
            line-height: 4rem;
        }
        .table-row .cell {
            flex-shrink: 0;
            background: #fff;
            padding: 0.5rem;
            min-height: 4rem;
            border: 0.1rem solid #e6e8ed;
            border-left: 0;
            border-top: 0;
        }
        .item-row-error {
            background: #fdeeee !important;
        }
        .table-row:hover {
            .cell {
                background: #f0f1f4 !important;
            }
            .item-row-error {
                background: #fdeeee !important;
            }
            .operate {
                display: flex;
            }
        }
        .operate:hover {
            display: flex;
        }
        .operate {
            position: absolute;
            background: #fff;
            left: 0.1rem;
            width: calc(100% - 0.2rem);
            height: calc(100% - 0.2rem);
            top: 0.1rem;
            z-index: 2;
            display: none;
        }
    }
    .row-num {
        font-size: 1.4rem;
    }
}
.table-row {
    :deep(.el-checkbox) {
        margin-right: 0;
        .el-checkbox__label {
            padding-left: 0;
        }
    }
}
.shrink {
    flex-shrink: 0;
}
.error-icon {
    width: 2rem;
    height: 2rem;
    font-size: 1.6rem;
    line-height: 1.6rem;
    background: #eb5050;
    color: #fff;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
}
</style>