零配置 Excel 预览组件:一行代码,搞定渲染 + 计算 + 美化
上传或接收后台二进制流即可在页面即时渲染 Excel,自动解析公式、合并单元格、百分比/小数精度、空行列容错,并自带优雅样式,真正实现"拿来即用"
一、能力一览
功能 | 是否内置 | 说明 |
---|---|---|
本地/后台文件解析 | ✅ | 支持 File、ArrayBuffer、Blob、Base64、对象嵌套等多种入参 |
公式实时计算 | ✅ | 基于 hot-formula-parser,跨 Sheet 引用也能识别 |
合并单元格 | ✅ | 自动识别 !merges ,渲染时零错位 |
空行列兼容 | ✅ | 过滤右侧空列,保留空行,避免大片空白 |
精度 & 百分比 | ✅ | 读取 Excel 原始格式 cell.z ,小数位严格对齐 |
样式美化 | ✅ | 表头高亮、隔行变色、hover 效果、错误单元格标红 |
公式可视化 | ✅ | showFormulas 属性可一键显示公式而非结果 |
二、使用方式
1. 本地文件上传
js
<template>
<!-- 上传按钮 -->
<el-upload
action=""
:http-request="_export"
:auto-upload="true"
accept=".xls,.xlsx"
>
<el-button size="small" type="primary">导入</el-button>
</el-upload>
<!-- 预览区域 -->
<ExcelPreview :file="file" showFormulas />
</template>
<script>
export default {
data() {
return { file: null };
},
methods: {
_export({ file }) {
this.file = file; // 直接塞 File 实例
}
}
}
</script>
2. 后台推送
js
//`handleFileResponse` 已帮你写好,**无需改动**。
axios.get('/api/excel').then(res => {
// 统一入口,支持 Blob / ArrayBuffer / Base64 / JSON 嵌套
this.handleFileResponse(res.data);
});
这些直接复制带走 别看 看了心烦~
js
// 统一处理文件响应
handleFileResponse(data) {
try {
console.log('开始处理文件响应,数据类型:', typeof data);
// 如果已经是ArrayBuffer,直接使用
if (data instanceof ArrayBuffer) {
console.log('检测到ArrayBuffer数据,直接使用');
this.file = data;
this.$msg({
content: '数据解析成功',
});
return;
}
// 如果是Blob,转换为ArrayBuffer
if (data instanceof Blob) {
console.log('检测到Blob数据,转换为ArrayBuffer');
this.convertBlobToArrayBuffer(data);
return;
}
// 如果是字符串,尝试解析为base64
if (typeof data === 'string') {
console.log('检测到字符串数据,尝试解析为base64');
this.convertStringToArrayBuffer(data);
return;
}
// 如果是对象,尝试提取文件数据
if (typeof data === 'object' && data !== null) {
console.log('检测到对象数据,尝试提取文件数据');
this.extractFileDataFromObject(data);
return;
}
throw new Error('不支持的数据格式');
} catch (error) {
console.error('文件响应处理失败:', error);
this.$msg({
content: '文件格式不支持',
type: 'err',
});
}
},
// 转换Blob为ArrayBuffer
convertBlobToArrayBuffer(blob) {
console.log('开始转换Blob,大小:', blob.size, '类型:', blob.type);
const reader = new FileReader();
reader.onload = event => {
console.log('FileReader读取完成');
this.file = event.target.result; // ArrayBuffer
this.$msg({
content: '数据解析成功',
});
};
reader.onerror = error => {
console.error('FileReader读取失败:', error);
this.$msg({
content: '文件读取失败',
type: 'err',
});
};
reader.readAsArrayBuffer(blob);
},
// 转换字符串为ArrayBuffer
convertStringToArrayBuffer(stringData) {
try {
let base64Data = stringData;
// 如果是data URL格式,提取base64部分
if (stringData.startsWith('data:')) {
const parts = stringData.split(',');
if (parts.length >= 2) {
base64Data = parts[1];
}
}
// 解码base64
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
console.log('base64转换完成,数据大小:', bytes.length);
this.file = bytes.buffer;
this.$msg({
content: '数据解析成功',
});
} catch (error) {
console.error('字符串转换失败:', error);
this.$msg({
content: '文件格式不支持',
type: 'err',
});
}
},
// 从对象中提取文件数据
extractFileDataFromObject(obj) {
// 尝试常见的文件数据字段
const possibleFields = ['fileData', 'data', 'content', 'file', 'excel', 'xlsx'];
for (const field of possibleFields) {
if (obj[field] && typeof obj[field] === 'string') {
console.log(`从对象字段 ${field} 提取数据`);
this.convertStringToArrayBuffer(obj[field]);
return;
}
}
// 如果没有找到字符串字段,尝试其他类型
if (obj.data instanceof Blob) {
this.convertBlobToArrayBuffer(obj.data);
return;
}
if (obj.data instanceof ArrayBuffer) {
this.file = obj.data;
this.$msg({
content: '数据解析成功',
});
return;
}
throw new Error('对象中未找到有效的文件数据');
},
自取依赖 npm i xlsx hot-formula-parser
npm i xlsx
主角在这 直接拿走
js
<template>
<!-- excle预览插件 传入文件即可 -->
<div class="excel-preview-container">
<div v-if="data.length > 0">
<!-- 表格信息 -->
<table class="excel-table">
<tbody>
<tr v-for="(row, rowIndex) in data" :key="rowIndex">
<template v-for="(value, colIndex) in row">
<td
v-if="!isCellMerged(rowIndex, colIndex)"
:key="colIndex"
:colspan="getColspan(rowIndex, colIndex)"
:rowspan="getRowspan(rowIndex, colIndex)"
:class="{
'formula-cell': cellFormulas[`${rowIndex},${colIndex}`],
'error-cell': isErrorValue(value),
'empty-cell': isEmptyValue(value),
}"
>
<div
v-if="
cellFormulas[`${rowIndex},${colIndex}`] &&
showFormulas &&
isEmptyValue(value)
"
class="formula-indicator"
>
<span class="formula-icon">ƒ</span>
<span class="formula-text"
>={{ cellFormulas[`${rowIndex},${colIndex}`] }}</span
>
</div>
<span v-else>{{
formatCellValue(value, rowIndex, colIndex)
}}</span>
</td>
</template>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-message">请导入数据</div>
</div>
</template>
<script>
import * as XLSX from 'xlsx';
// const FormulaParser = require('hot-formula-parser').Parser;
// var FormulaParser = new formulaParser.Parser();
var FormulaParser = require('hot-formula-parser').Parser;
var parser = new FormulaParser();
export default {
name: 'ExcelPreview',
props: {
file: { type: [File, ArrayBuffer, null], default: null },
showFormulas: { type: Boolean, default: false },
},
data() {
return {
data: [],
mergedCells: {},
cellFormulas: {},
cellFormats: {}, // 存储单元格格式信息
cellDisplay: {}, // 存储单元格显示值(如带%)
sheetDataDict: {},
currentSheetName: '',
};
},
watch: {
file: {
immediate: true,
handler(val) {
if (!val) {
this.resetData();
return;
}
if (val instanceof File) {
const reader = new FileReader();
reader.onload = event => {
this.processExcelData(event.target.result);
};
reader.readAsArrayBuffer(val);
} else if (val instanceof ArrayBuffer) {
this.processExcelData(val);
}
},
},
},
methods: {
resetData() {
this.data = [];
this.mergedCells = {};
this.cellFormulas = {};
this.cellFormats = {};
this.cellDisplay = {};
this.sheetDataDict = {};
this.currentSheetName = '';
},
processExcelData(arrayBuffer) {
console.log(parser, 'parser', arrayBuffer);
try {
const fileData = new Uint8Array(arrayBuffer);
const workbook = XLSX.read(fileData, {
type: 'array',
cellFormula: true,
cellHTML: false,
cellDates: true,
sheetStubs: true,
});
// 构建所有sheet的数据字典
const sheetDataDict = {};
workbook.SheetNames.forEach(sheetName => {
const ws = workbook.Sheets[sheetName];
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1:A1');
// 直接使用单元格地址映射,避免空行问题
const sheetData = {};
// 遍历所有单元格,只保存有数据的单元格
for (let r = range.s.r; r <= range.e.r; r++) {
for (let c = range.s.c; c <= range.e.c; c++) {
const addr = XLSX.utils.encode_cell({ r, c });
const cell = ws[addr];
if (cell && cell.v !== undefined) {
sheetData[addr] = cell.v;
}
}
}
sheetDataDict[sheetName] = {
cells: sheetData,
range: range,
worksheet: ws,
};
console.log(`工作表 ${sheetName} 数据:`, sheetData);
});
this.sheetDataDict = sheetDataDict;
// 默认展示第一个sheet
const sheetName = workbook.SheetNames[0];
this.currentSheetName = sheetName;
const worksheet = workbook.Sheets[sheetName];
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
console.log('主工作表范围:', range);
console.log('主工作表名称:', sheetName);
console.log('主工作表范围详情:', {
startRow: range.s.r,
endRow: range.e.r,
startCol: range.s.c,
endCol: range.e.c,
});
const newData = [];
const formulas = {};
const cellFormats = {}; // 存储单元格格式信息
const cellDisplay = {}; // 存储单元格显示值
parser.on('callCellValue', (cellCoord, done) => {
const sheet = cellCoord.sheet || sheetName;
const row = cellCoord.row.index;
const col = cellCoord.column.index;
// 使用单元格地址来获取值,确保位置正确
const addr = XLSX.utils.encode_cell({ r: row, c: col });
const value =
sheetDataDict[sheet] && sheetDataDict[sheet].cells[addr];
console.log(
`callCellValue: sheet=${sheet}, row=${row}, col=${col}, addr=${addr}, value=${value}`
);
done(value !== undefined ? value : null);
});
parser.on('callRangeValue', (startCellCoord, endCellCoord, done) => {
const sheet = startCellCoord.sheet || sheetName;
const startRow = startCellCoord.row.index;
const endRow = endCellCoord.row.index;
const startCol = startCellCoord.column.index;
const endCol = endCellCoord.column.index;
const values = [];
for (let r = startRow; r <= endRow; r++) {
const row = [];
for (let c = startCol; c <= endCol; c++) {
const addr = XLSX.utils.encode_cell({ r, c });
const value =
sheetDataDict[sheet] && sheetDataDict[sheet].cells[addr];
row.push(value !== undefined ? value : null);
}
values.push(row);
}
console.log(
`callRangeValue: sheet=${sheet}, range=${startRow}:${endRow},${startCol}:${endCol}, values=`,
values
);
done(values);
});
// 重新构建主工作表数据,保持原始结构包括空行
console.log('开始构建主工作表数据...');
for (let row = range.s.r; row <= range.e.r; row++) {
const rowData = [];
console.log(`处理第 ${row} 行`);
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = worksheet[cellAddress];
// === 这里是调试代码 ===
if (cell) {
console.log(
`cell ${cellAddress} 格式z:`,
cell.z,
'显示w:',
cell.w,
'值v:',
cell.v,
'公式f:',
cell.f
);
}
// =====================
console.log(`处理单元格 ${cellAddress}: row=${row}, col=${col}`);
let value = '';
if (cell) {
if (cell.f) {
formulas[`${row},${col}`] = cell.f;
console.log(`发现公式: ${cellAddress} = ${cell.f}`);
}
// 存储单元格格式信息
if (cell.z) {
cellFormats[`${row},${col}`] = cell.z;
}
// 存储单元格显示值
if (cell.w) {
cellDisplay[`${row},${col}`] = cell.w;
}
// 优先用Excel保存的值,否则自动计算
if (cell.v !== undefined && cell.v !== null) {
value = cell.v;
} else if (cell.f) {
// 自动计算公式
console.log(`开始计算公式: ${cell.f}`);
const res = parser.parse('=' + cell.f);
value = res.error ? `#ERROR` : res.result;
console.log(`公式计算结果: ${value}`);
} else if (cell.t === 'z') {
value = '';
}
if (cell.t === 'd' && value instanceof Date) {
value = this.formatDate(value);
}
if (cell.t === 'b') {
value = cell.v ? 'TRUE' : 'FALSE';
}
if (cell.t === 'e') {
value = `${cell.w || 'ERROR!'}`;
}
}
// 保持空单元格为空字符串
rowData.push(value);
}
// 保持空行
newData.push(rowData);
console.log(`第 ${row} 行数据:`, rowData);
}
this.data = newData;
this.cellFormulas = formulas;
this.cellFormats = cellFormats; // 保存格式信息
this.cellDisplay = cellDisplay; // 保存显示值
this.getMergedCells(worksheet);
// 只过滤右侧空列,保留空行
this.data = this.filterEmptyColumns(this.data);
} catch (error) {
console.error('Excel解析失败:', error, error && error.stack);
this.$message && this.$message.error('文件解析失败,请检查文件格式');
}
},
// 只过滤右侧空列,保留空行
filterEmptyColumns(data) {
if (!data || data.length === 0) return data;
// 找到每行最后一个非空单元格的列索引
const maxCols = Math.max(
...data.map(row => {
for (let i = row.length - 1; i >= 0; i--) {
if (row[i] !== '' && row[i] !== null && row[i] !== undefined) {
return i + 1;
}
}
return 0;
})
);
// 只截取到最后一个非空列,保留所有行
return data.map(row => row.slice(0, maxCols));
},
formatDate(date) {
if (!(date instanceof Date)) return date;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
formatCellValue(value, row, col) {
// 优先用 cellDisplay 的显示值(Excel里看到的内容)
const display = this.cellDisplay[`${row},${col}`];
if (typeof display === 'string' && display.trim() !== '') {
return display;
}
// 优先用 cellFormulas 的百分号显示
const formulaDisplay = this.cellFormulas[`${row},${col}`];
if (
typeof formulaDisplay === 'string' &&
formulaDisplay.includes('%')
) {
return formulaDisplay;
}
if (
this.cellFormulas[`${row},${col}`] &&
(value === '' || value === null || value === undefined)
) {
return this.showFormulas ? '' : '';
}
// 获取单元格格式
const format = this.cellFormats[`${row},${col}`];
if (typeof value === 'number') {
// 处理百分比格式
if (format && format.includes('%')) {
// 解析格式中的小数位数
let digits = 2; // 默认2位小数
const match = format.match(/0\.(0+)%/);
if (match) {
digits = match[1].length;
} else if (format.match(/0%/)) {
digits = 0;
} else if (format.match(/0\.0%/)) {
digits = 1;
}
// 计算百分比,使用更高精度避免浮点数问题
const percentageValue = value * 100;
// 根据格式要求格式化
if (digits === 0) {
// 整数百分比
return Math.round(percentageValue) + '%';
} else {
// 小数百分比,使用更高精度计算
let percentage = percentageValue.toFixed(Math.max(digits, 6));
// 去除末尾的无效零,但保留有效的小数位
if (digits > 0) {
// 先去除末尾的 .0
percentage = percentage.replace(/\.0+$/, '');
// 再去除末尾的无效零,但保留至少 digits 位小数
const parts = percentage.split('.');
if (parts.length > 1) {
const decimal = parts[1];
const significantDigits = Math.min(
digits,
decimal.replace(/0+$/, '').length
);
if (significantDigits > 0) {
percentage =
parts[0] + '.' + decimal.substring(0, significantDigits);
} else {
percentage = parts[0];
}
}
}
return percentage + '%';
}
}
// 处理其他数字格式 - 读取Excel中的格式设置
if (format) {
// 解析Excel格式中的小数位数
let digits = null;
// 匹配不同的数字格式模式
if (format.match(/0\.(0+)/)) {
// 格式如 "0.00" 或 "0.000"
const match = format.match(/0\.(0+)/);
digits = match[1].length;
} else if (format.match(/#\.(0+)/)) {
// 格式如 "#.00" 或 "#.000"
const match = format.match(/#\.(0+)/);
digits = match[1].length;
} else if (format.match(/0\.0/)) {
// 格式如 "0.0" (1位小数)
digits = 1;
} else if (format.match(/0/)) {
// 格式如 "0" (整数)
digits = 0;
}
// 调试信息
console.log(
`单元格 ${row},${col}: 格式="${format}", 小数位数=${digits}, 原始值=${value}`
);
// 根据Excel格式要求格式化
if (digits !== null) {
if (digits === 0) {
return Math.round(value).toString();
} else {
return value.toFixed(digits);
}
}
}
// 如果没有特定格式,保持原始精度
if (Number.isInteger(value)) return value;
// 保持原始精度,不强制截断
const valueStr = value.toString();
// 如果是科学计数法,转换为普通小数
if (valueStr.includes('e') || valueStr.includes('E')) {
return value.toFixed(10).replace(/\.?0+$/, '');
}
// 去除末尾的无效零,但保留有效的小数位
return valueStr.replace(/\.?0+$/, '');
}
return value;
},
isEmptyValue(value) {
return value === '' || value === null || value === undefined;
},
isErrorValue(value) {
return typeof value === 'string' && value.startsWith('#');
},
getMergedCells(worksheet) {
this.mergedCells = {};
if (worksheet['!merges']) {
worksheet['!merges'].forEach(merge => {
const s = merge.s;
const e = merge.e;
for (let r = s.r; r <= e.r; r++) {
for (let c = s.c; c <= e.c; c++) {
this.mergedCells[r + ',' + c] = {
start: r === s.r && c === s.c,
colspan: e.c - s.c + 1,
rowspan: e.r - s.r + 1,
};
}
}
});
}
},
isCellMerged(row, col) {
const cell = this.mergedCells[row + ',' + col];
return cell && !cell.start;
},
getColspan(row, col) {
const cell = this.mergedCells[row + ',' + col];
return cell && cell.colspan ? cell.colspan : 1;
},
getRowspan(row, col) {
const cell = this.mergedCells[row + ',' + col];
return cell && cell.rowspan ? cell.rowspan : 1;
},
},
};
</script>
<style scoped lang="scss">
.excel-preview-container {
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
background: white;
height: 100%;
position: relative;
margin: 0 auto;
// 响应式设计
@media (max-width: 768px) {
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.table-info {
padding: 10px 15px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
display: flex;
gap: 20px;
font-size: 12px;
color: #666;
}
.info-item {
display: flex;
align-items: center;
gap: 5px;
}
.info-item::before {
content: '•';
color: #007bff;
font-weight: bold;
}
.excel-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: fixed; // 固定表格布局,提高性能
th,
td {
padding: 8px 12px;
text-align: center;
vertical-align: middle;
border: 1px solid #e0e0e0;
min-width: 80px;
max-width: 200px; // 限制最大宽度
position: relative;
transition: background-color 0.2s;
background: white;
word-wrap: break-word; // 允许文字换行
overflow: hidden;
text-overflow: ellipsis; // 超出显示省略号
}
tr:nth-child(even) td {
background-color: #f9fbfd;
}
tr:hover td {
background-color: #f0f7ff;
}
// 第一行作为表头样式
tr:first-child td {
background-color: #f5f7fa;
font-weight: 600;
color: #2c3e50;
border-bottom: 2px solid #ddd;
}
// 响应式设计
@media (max-width: 768px) {
font-size: 12px;
th,
td {
padding: 6px 8px;
min-width: 60px;
max-width: 120px;
}
}
}
.formula-cell::after {
content: 'ƒ';
position: absolute;
top: 4px;
right: 4px;
font-size: 10px;
color: #2c80c5;
font-weight: bold;
}
.error-cell {
background-color: #fff0f0 !important;
color: #e74c3c;
font-weight: 500;
}
.empty-message {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: #a0aec0;
background: #fafafa;
border-radius: 8px;
}
.empty-icon {
font-size: 72px;
margin-bottom: 25px;
color: #cbd5e0;
}
.empty-text {
font-size: 22px;
margin-bottom: 15px;
font-weight: 500;
}
.empty-subtext {
font-size: 16px;
color: #909399;
max-width: 500px;
text-align: center;
line-height: 1.6;
}
.empty-cell {
background-color: #fafafa !important;
color: #ccc;
font-style: italic;
}
.empty-cell::after {
content: '---';
color: #ddd;
font-style: normal;
}
</style>