vue预览excle 妈妈5年了研究很多次终于写出来了

零配置 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>
相关推荐
小高0073 分钟前
告别“if-else”条件判断:5 个让 JavaScript 逻辑更优雅的写法
前端·javascript
二闹12 分钟前
前端安全:你还在忽视这3个致命 XSS 漏洞?
前端
前端的日常15 分钟前
面试必备:前端路由 route 和 router 的核心要点
前端
icr21 分钟前
React Fiber和React:diff 算法
前端
_未完待续22 分钟前
框架实战指南-错误处理
前端·vue.js
xianxin_24 分钟前
HTML 锚点
前端
Sean_summer29 分钟前
暑期第二周
前端·数据库·python
_未完待续34 分钟前
框架实战指南-组件参考
前端·vue.js
李文旺35 分钟前
图片加载优化-Nextjs与webpack源码
前端·react.js
不想当小卡拉米35 分钟前
高德地图上marker过多(超过3000个)渲染卡顿过慢问题解决
前端