Luckysheet

753 阅读7分钟

Luckysheet

Luckysheet 已不再维护,推荐使用 Univer 替代。后续再更新 Univer

目前的使用效果

image.png

具体的功能

  • 对输入的内容(字符串类型)做实时的校验,简单的配置即可
  • 支持前端校验、服务端校验(对服务端校验过的数据会做缓存,避免重复校验)
  • 可触发校验的操作:单行修改、多行修改、复制/粘贴/剪切、撤销、删除

引入步骤

<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/css/pluginsCss.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/plugins.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/css/luckysheet.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/assets/iconfont/iconfont.css' />
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/js/plugin.js"></script>
<script src="https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/luckysheet.umd.js"></script>

根据上面的链接,将文件下载保存到项目中,通过相对路径引入。

如果不是立即加载,推荐使用 defer 延迟加载 js。luckysheet.umd.js 文件有点大。

<script src="/dist/plugins/js/plugin.js" defer></script>
<script src="/dist/luckysheet.umd.js" defer></script>

注意:

  • luckysheet.css 存在一些全局样式。如果影响到你的文件,删掉它。例如:.btn

组件封装

代码封装在 OnlineExport 组件中。

弹框使用的是 element-plus 中的弹框,按钮也是。如果想移植修改,注意这些之外,再注意 vue2vue3 一些语法的不同即可。组件使用的是 options api

<!-- options api 方便通用更改 -->
<template>
    <el-dialog v-model="visible" :title="title" width="98%" top="5vh" append-to-body class="online-export-dialog" :footer-center="false" @opened="opened">
        <div class="online-dialog-core p-20 flex-1 flex overflow-y-auto" v-loading="loading">
            <!-- 问题 table -->
            <div class="table-wrap">
                <div class="title">数据问题</div>
                <el-table class="flex-1 overflow-y-auto" :data="tableList">
                    <el-table-column label="行号" prop="index" align="center" :minWidth="60"> </el-table-column>
                    <el-table-column label="问题" prop="problem" align="center"> </el-table-column>
                </el-table>
            </div>

            <!-- 表格 -->
            <div class="sheet-wrap">
                <div class="sheet-title-wrap">
                    <div>
                        <span class="batch-title-word">批量编辑</span>
                        <span class="batch-title-tip">
                            <slot name="title"> (请先导出食材并将需要新增或编辑的食材粘贴到下方表格中) </slot>
                        </span>
                    </div>

                    <slot name="export">
                        <el-button type="primary" @click="onExport">导出食材</el-button>
                    </slot>
                </div>
                <div ref="luckysheetTableRef" id="luckysheetTable" class="batch-upload-table"></div>
            </div>
        </div>

        <template #footer>
            <div class="footer-wrap">
                <el-button @click="onClosed"> 取消 </el-button>
                <el-button type="primary" :disabled="loading" :loading="loading" @click="onConfirm"> 确认配置 </el-button>
            </div>
        </template>
    </el-dialog>
</template>

<script>
export default {
    props: {
        modelValue: {
            type: Boolean,
            default: false,
        },
        // 标题
        title: {
            type: String,
            default: '批量导入',
        },
        // 配置项
        option: {
            type: Object,
            default: () => ({
                // 第一行
                column: [],
                // 校验接口
                interfaceCheckApi: '',
                // 插入接口
                importApi: '',
                // 额外参数
                extraParams: {
                    type: Object,
                    default: () => ({}),
                },
            }),
        },
    },
    data() {
        return {
            loading: false,

            // 存储所有错误信息
            errorMap: {},
        }
    },
    computed: {
        visible: {
            get() {
                return this.modelValue
            },
            set(val) {
                this.$emit('update:modelValue', val)
            },
        },

        tableList() {
            return Object.keys(this.errorMap)
                .map(v => +v)
                .sort((a, b) => a - b)
                .map(v => {
                    return {
                        index: v,
                        problem: this.errorMap[v].join('、'),
                    }
                })
                .filter(v => !!v.problem)
        },

        // 获取每列的校验函数 或 ''
        validateMap() {
            let validateList = this.option.column.filter(v => !!v.validate)
            if (!validateList.length) return ''

            return validateList.reduce((total, v) => {
                return {
                    ...total,
                    [v.id]: v.validate,
                }
            }, {})
        },
        // 获取每列对应的 id 名称 列数
        columnList() {
            return this.option.column
                .map((v, i) => ({
                    id: v.id,
                    vv: v.v,
                    index: i,
                }))
                .filter(v => !!v.id)
        },
    },
    beforeUnmount() {
        this.hackLuckysheetDestory()
    },
    methods: {
        opened() {
            this.checkSheetContainer()
        },
        // 格式化后台所需数据
        formatPostData(optionList) {
            return {
                ...this.option.extraParams,
                list: optionList,
            }
        },
        editInput(val) {
            try {
                let editDom = document.querySelector('#luckysheet-input-box')
                // hack  双击
                if (val == -1) {
                    editDom.classList.remove('z-index-999999')
                    editDom.classList.add('z-index-1')
                } else {
                    editDom.classList.add('z-index-999999')
                    editDom.classList.remove('z-index-1')
                }
            } catch (error) {
                console.error(error)
            }
        },
        // 是否为空
        isEmpty(val) {
            if (val === null || typeof val === 'undefined') {
                return true
            }

            if (typeof val === 'object') {
                if (Object.keys(val).length === 0) return true
                if (val.m === '') return true
            }
        },
        // 匹配错误信息 【第二行】错误信息
        extractContentWithinBrackets(str) {
            const regex = /\【([^\]]+)\】/g
            let match
            const results = []

            while ((match = regex.exec(str))) {
                results.push(match[1])
            }

            return results[0]
        },
        // 格式化后台返回错误信息
        getSheetMessage(msg) {
            if (!msg) return

            if (!this.extractContentWithinBrackets(msg)) {
                return this.$message.error(msg)
            }

            let msgList = msg.split(', ')

            msgList.forEach(v => {
                let matchStr = this.extractContentWithinBrackets(v)

                if (!matchStr) return

                let problemStr = v.replace(/\【([^\]]+)\】/g, '')

                let problemList = problemStr.split('、')

                let index = matchStr.replace(/第|行/g, '')

                // 后台返回的错误信息 覆盖前端的错误信息
                this.errorMap[index] = []

                problemList.forEach(msg => {
                    if (!this.errorMap[index].includes(msg)) {
                        this.errorMap[index].push(msg)
                    }
                })
            })
        },
        // 再次确保 DOM 已渲染
        checkSheetContainer() {
            this.$nextTick(() => {
                if (!this.$refs.luckysheetTableRef) {
                    setTimeout(() => {
                        this.initLuckysheet()
                    }, 20)
                } else {
                    this.initLuckysheet()
                }
            })
        },
        handleKeyDown(event) {
            // 检查是否是 Ctrl + V
            if (event.ctrlKey && event.keyCode === 86) {
                this.onCtrlV()
            }
        },
        onCtrlV() {
            // hack 粘贴
            // 监听不到具体的 api,只能全部触发
            let timer = setTimeout(() => {
                clearTimeout(timer)
                this.checkAllData()
            }, 20)
        },
        // 获取文档全部数据
        getAllSheetData() {
            this.sheetFile = luckysheet.getSheetData()
        },
        initLuckysheet() {
            luckysheet.create({
                container: 'luckysheetTable',
                lang: 'zh',
                column: 8,
                accuracy: 2,
                showtoolbar: false, //是否显示工具栏
                showinfobar: false, //是否显示顶部信息栏
                showsheetbar: false, //是否显示底部sheet页按钮
                cellRightClickConfig: {
                    hideRow: false, // 隐藏选中行和显示选中行
                    hideColumn: false, // 隐藏选中列和显示选中列
                    chart: false, // 图表生成
                    image: false, // 插入图片
                    link: false, // 插入链接
                    copy: false, // 复制
                    copyAs: false, // 复制为
                    paste: false, // 粘贴
                    insertRow: false, // 插入行
                    insertColumn: false, // 插入列
                    deleteRow: false, // 删除选中行
                    deleteColumn: false, // 删除选中列
                    deleteCell: false, // 删除单元格
                    hideRow: false, // 隐藏选中行和显示选中行
                    hideColumn: false, // 隐藏选中列和显示选中列
                    rowHeight: false, // 行高
                    columnWidth: false, // 列宽
                    clear: false, // 清除内容
                    matrix: false, // 矩阵操作选区
                    sort: false, // 排序选区
                    filter: false, // 筛选选区
                    chart: false, // 图表生成
                    image: false, // 插入图片
                    link: false, // 插入链接
                    data: false, // 数据验证
                    cellFormat: false, // 设置单元格格式

                    // 开启
                    deleteRow: true,
                },
                frozen: {}, //冻结行列配置
                data: [
                    {
                        color: '', //工作表颜色
                        index: 0, //工作表索引
                        status: 1, //激活状态
                        hide: 0, //是否隐藏
                        defaultRowHeight: 23, //自定义行高
                        defaultColWidth: 73, //自定义列宽
                        config: {
                            merge: {}, //合并单元格
                            rowhidden: {}, //隐藏行
                            colhidden: {}, //隐藏列
                            borderInfo: {}, //边框
                            authority: {}, //工作表保护
                            rowlen: {}, //表格行高
                            columnlen: this.option.column.reduce((total, v, i) => (total = { ...total, [i]: v.width || 150 }), {}), // 表格列宽 默认 150
                        },
                        // 初始化使用的单元格数据
                        celldata: this.option.column.map((item, index) => ({
                            r: 0, // 行
                            c: index, // 列
                            v: {
                                v: item.vv, // 内容的原始值
                                ht: '0', // 水平居中
                                bg: '#fff000', // 背景色
                            },
                            ct: {
                                // 默认字符串
                                fa: item.ctfa || '@',
                                t: item.ctt || 's',
                            },
                        })),
                    },
                ],
                hook: {
                    // 框选或者设置选区后触发
                    rangeSelect: (_, range) => {
                        this.rangeSelectChange(range)
                    },
                    // 更新这个单元格后触发
                    cellUpdated: r => {
                        this.sheetFileCellUpdated({ index: r })
                    },
                },
            })

            this.hackLuckysheetMounted()
        },
        hackLuckysheetMounted() {
            // hack  删除行
            // 删除时,会全部上移
            let deleteDom = document.querySelector('#luckysheet-rightclick-menu')
            if (deleteDom) {
                deleteDom.addEventListener('click', () => {
                    this.checkAllData()
                })
            }
            // 双击
            this.editInput()

            document.addEventListener('keydown', this.handleKeyDown)
            document.addEventListener('paste', this.onCtrlV)
        },
        hackLuckysheetDestory() {
            this.editInput(-1)

            document.removeEventListener('keydown', this.handleKeyDown)
            document.removeEventListener('paste', this.onCtrlV)
        },

        // 监听选区变化,单选、多选、下拉复制粘贴、撤销(监听不到复制、删除)
        // 文档变化获取到的行是真实的行数,用于文档显示 & 前端显示 & 后台校验的是 +1 行
        rangeSelectChange(range) {
            if (!(range && range.length)) return

            let rowList = []
            range.forEach(v => {
                let { row_focus, row } = v
                let [rowStart, rowEnd] = row
                if (rowStart === rowEnd) {
                    rowList.push(row_focus)
                } else {
                    rowList.push(rowStart, rowEnd)
                }
            })

            let minRow = Math.min(...rowList)
            let maxRow = Math.max(...rowList)

            let checkIndexList = []
            // 去除首行
            if (minRow < 1) minRow = 1
            for (let index = minRow; index <= maxRow; index++) {
                checkIndexList.push(index)
            }
            checkIndexList.length && this.sheetFileRangeSelect(checkIndexList)
        },
        // 单元格触发的单行
        sheetFileCellUpdated({ index }) {
            this.rangeSelectChange([
                {
                    row_focus: index,
                    row: [index, index],
                },
            ])
        },
        // 文档变化之后,根据变化的行数获取数据
        sheetFileRangeSelect(checkIndexList) {
            this.getAllSheetData()

            let resultList = []

            this.sheetFile.forEach((itemList, index) => {
                // 是否是需要的行数
                if (checkIndexList.includes(index)) {
                    // 去除空的行
                    let isExist = itemList.filter(item => !this.isEmpty(item)).length
                    if (isExist) {
                        resultList.push(this.wrapFileData({ index, item: itemList }))
                    } else {
                        // 去除空行可能存在的错误信息
                        this.errorMap[index + 1] = []
                    }
                }
            })

            resultList.length && this.checkFileData({ checkList: resultList })
        },
        // 部分操作,需获取文件全部数据
        getAllsheetFileData() {
            this.getAllSheetData()

            let resultList = []

            this.sheetFile.forEach((itemList, index) => {
                // 去除首行与为空的行
                let isExist = itemList.filter(item => !this.isEmpty(item)).length
                if (index !== 0 && isExist) {
                    resultList.push(this.wrapFileData({ index, item: itemList }))
                }
            })
            return resultList
        },
        // 根据行数、配置包裹数据
        wrapFileData({ index, item }) {
            // 与列表一一对应
            return {
                index: index + 1,
                ...this.columnList.reduce((total, v) => {
                    return {
                        ...total,
                        [v.id]: item[v.index] ? (typeof item[v.index].m === 'undefined' ? '' : item[v.index].m) : '',
                    }
                }, {}),
            }
        },
        // 校验分为:缓存校验(后端已校验并且无变化的数据,不会再校验)、前端校验、后端校验
        checkFileData({ checkList, forntCheck = true, backCheck = true }) {
            this.saveCheckedMap = this.saveCheckedMap || {}

            // 能成功给后端校验的行
            let successList = []

            checkList.forEach(item => {
                // 缓存校验
                let cacheItemStr = this.saveCheckedMap[item.index]
                if (cacheItemStr === JSON.stringify(item)) return

                // 前端校验
                let index = item.index
                if (forntCheck) {
                    this.forntCheckFile({ checkList: [item] })
                }
                // 是否已存在错误信息
                if (this.errorMap[index] && this.errorMap[index].length) {
                    return
                }

                successList.push(item)
            })
            // 后端校验
            if (backCheck && successList.length) {
                this.interfaceCheck(successList)
            }
        },
        // 前端校验
        forntCheckFile({ checkList }) {
            checkList.forEach(row => {
                let { index } = row
                this.errorMap[index] = []
                if (this.validateMap) {
                    Object.keys(row).forEach(key => {
                        let validateFn = this.validateMap[key]
                        if (validateFn) {
                            let msg = validateFn(row[key])

                            if (msg) {
                                if (!this.errorMap[index].includes(msg)) {
                                    this.errorMap[index].push(msg)
                                }
                            }
                        }
                    })
                }
            })
        },
        // 校验全部数据
        checkAllData() {
            // 主动触发校验全部时,清空已缓存信息
            this.errorMap = {}
            this.saveCheckedMap = {}

            let resList = this.getAllsheetFileData()

            this.interfaceCheck(resList)
        },
        // 接口校验 校验通过返回 true
        async interfaceCheck(resList) {
            if (!this.option.interfaceCheckApi) return true

            let res = await this.option.interfaceCheckApi(this.formatPostData(resList), false)
            if (!res.success) {
                this.saveChecked(resList)
                this.getSheetMessage(res.msg)
            } else {
                this.lastCheckListStr = ''
            }

            return res.success
        },
        saveChecked(checkList) {
            this.saveCheckedMap = this.saveCheckedMap || {}

            checkList.forEach(v => {
                // 确保了只包含简单类型等
                this.saveCheckedMap[v.index] = JSON.stringify(v)
            })
        },
        // 默认导出
        onExport() {
            this.$emit('export')
        },
        onClosed() {
            this.visible = false
        },
        async onConfirm() {
            this.loading = true

            // 主动触发时,清空已缓存信息
            this.errorMap = {}
            this.saveCheckedMap = {}

            let resList = this.getAllsheetFileData()

            // 调用接口校验
            let checkStatus = await this.interfaceCheck(resList)
            if (!checkStatus) {
                this.loading = false
                return
            }

            let res = await this.option.importApi(this.formatPostData(resList))
            this.loading = false
            if (res.success) {
                this.$message.success('操作成功!')
                this.$emit('success')
                this.onClosed()
            }
        },
    },
}
</script>

<style lang="less" scoped>
.flex {
    display: flex;
}

.flex-1 {
    flex: 1;
}

.overflow-y-auto {
    overflow-y: auto;
}

.mb-10 {
    margin-bottom: 20px;
}

.mt-20 {
    margin-top: 20px;
}

.p-20 {
    padding: 20px;
}

.online-dialog-core {
    .table-wrap {
        flex: 1;
        padding-right: 20px;
        display: flex;
        flex-direction: column;
        align-self: stretch;

        .title {
            font-size: 14px;
            font-weight: 600;
        }
    }

    .sheet-wrap {
        flex: 3;
        align-self: stretch;
        display: flex;
        flex-direction: column;

        .batch-title-word {
            font-size: 14px;
            font-weight: 600;
        }

        .sheet-title-wrap {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 5px 0;

            .batch-title-tip {
                color: #ff4d4f;
            }
        }

        .batch-upload-table {
            flex: 1;
        }
    }

    .footer-wrap {
        padding-top: 16px;
        font-size: 14px;
    }
}
</style>

<style lang="less">
// 设置 dialog 尽可能大
.online-export-dialog.el-dialog {
    height: 95vh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    margin-top: 2vh;
    margin-bottom: 2vh;

    .el-dialog__body {
        padding: 0px 20px 0px;
        flex: 1;
        display: flex;
        flex-direction: column;
        overflow-y: auto;
    }
}

// 右键
.luckysheet-rightgclick-menu {
    z-index: 999999;
}

.z-index-1 {
    z-index: -1;
}

.z-index-999999 {
    z-index: 999999;
}
</style>

组件的配置

参数说明类型默认值
model-value / v-model是否显示booleanfalse
title标题string批量导入
option配置项object

option 参数

参数说明类型默认值
column第一行array
interfaceCheckApi数据校验接口function
importApi数据插入接口function
extraParams接口额外需要的参数object

column :

  • vv:内容的原始值
  • id : 此列传给后端的字段,不传仅做展示
  • width: 列宽度,默认 150
  • ctfa、ctt: 默认字符串
  • validate: 此列前端校验函数(同步函数)

Slots

插槽名说明
title右侧导出提示
export右侧导出按钮

Events

插槽名说明
export右侧导出
success数据成功插入

使用案列

<OnlineExport v-if="onlineExportDialog.visible" v-model="onlineExportDialog.visible" :option="onlineExportDialog.option">
    
const onlineExportDialog = reactive({
    visible: false,
    option: {
        column: [
            {
                vv: '食材ID',
                id: 'pid',
            },
            {
                vv: '大类名称', // 仅做展示
            },
            {
                vv: '食材名称',
                id: 'name',
                // 校验函数
                validate(val) {
                    if (!val) return '食材名称不能为空'
                },
            },
        ],
        // 额外的参数
        extraParams: {
            a: 1,
        },
        interfaceCheckApi: () => {}, // 校验接口
        importApi: () => {}, // 插入接口
    },
})

注意

  • 删除、复制粘贴会触发全局的校验,因为没 api 支持,特殊处理过。

遇到的问题

右击菜单,弹框没显示

弹框层级不够大。

// 右键
.luckysheet-rightgclick-menu {
    z-index: 999999;
}

输入框不显示、而且输入时直接关闭、输入框还显示

.z-index-1 {
    z-index: -1;
}

.z-index-999999 {
    z-index: 999999;
}

再通过 editInput 函数处理

删除、复制粘贴

hackLuckysheetMounted、hackLuckysheetDestory,一些兼容都写在这里面