Luckysheet
Luckysheet 已不再维护,推荐使用 Univer 替代。后续再更新 Univer 。
目前的使用效果
具体的功能
- 对输入的内容
(字符串类型)
做实时的校验,简单的配置即可 - 支持前端校验、服务端校验(对服务端校验过的数据会做缓存,避免重复校验)
- 可触发校验的操作:单行修改、多行修改、复制/粘贴/剪切、撤销、删除
引入步骤
<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
中的弹框,按钮也是。如果想移植修改,注意这些之外,再注意 vue2
, vue3
一些语法的不同即可。组件使用的是 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 | 是否显示 | boolean | false |
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,一些兼容都写在这里面