Luckysheet 内网部署教程:后端生成 Excel,前端只读预览

📅 2026/7/29 21:28:46 👁️ 阅读次数 📝 编程学习
Luckysheet 内网部署教程:后端生成 Excel,前端只读预览

Luckysheet 内网部署教程:后端生成 Excel,前端只读预览

一、场景说明

在企业后台管理系统中,常见需求是:后端根据业务数据生成 Excel 报表,前端直接从接口下载该文件并进行在线预览,用户只能查看、不能编辑

典型场景包括:

  • 财务报表生成与查阅
  • 数据报表导出与审批
  • 系统日志导出查看
  • 用户数据报表预览

本文聚焦于内网部署场景,所有资源均为本地化,不依赖外网 CDN。后端使用Spring Boot + EasyExcel生成.xlsx文件,前端使用Luckysheet + Luckyexcel进行只读预览。

二、整体架构与数据流

┌─────────┐ 请求导出 ┌─────────────┐ 生成Excel ┌─────────────┐ │ 前端 │ ──────────────────▶ │ 后端接口 │ ──────────────────▶ │ Excel文件 │ │ (Vue3) │ │ (SpringBoot)│ │ (.xlsx) │ └─────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │◀─────────────────────────────────│ │ │ 返回文件流(Blob) │ │ │ │ │ 接收Blob并解析 │ │ │ 使用Luckyexcel转换为Luckysheet格式│ │ │ 只读渲染预览 │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │ 用户 │ │ Luckysheet │ │ Luckyexcel │ │ 查看 │ │ 只读预览 │ │ 解析xlsx │ └─────────┘ └─────────────┘ └─────────────┘

核心流程:

  1. 前端发起导出请求→ 调用后端接口
  2. 后端生成 Excel 文件→ 使用 EasyExcel 或 Apache POI 将数据写入.xlsx,以文件流形式返回
  3. 前端接收 Blob 数据→ 将二进制数据转换为File对象
  4. Luckyexcel 解析→ 将.xlsx转换为 Luckysheet 可识别的 JSON 数据
  5. 只读渲染→ 以allowEdit: false模式展示表格,用户仅可查看

三、后端:Spring Boot 生成 Excel

3.1 添加依赖

<!-- Maven 依赖 --><dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.4</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>

EasyExcel 是阿里开源的 Excel 处理库,性能优于 Apache POI,适合大量数据导出场景 。

3.2 定义导出数据实体

// UserExportVO.javapackagecom.example.excel.vo;importcom.alibaba.excel.annotation.ExcelProperty;importlombok.Data;@DatapublicclassUserExportVO{@ExcelProperty(value="用户ID",index=0)privateLonguserId;@ExcelProperty(value="用户名",index=1)privateStringuserName;@ExcelProperty(value="手机号",index=2)privateStringphone;@ExcelProperty(value="注册时间",index=3)privateStringregisterTime;}

3.3 后端导出接口

// ExcelExportController.javapackagecom.example.excel.controller;importcom.alibaba.excel.EasyExcel;importcom.example.excel.vo.UserExportVO;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;importjavax.servlet.http.HttpServletResponse;importjava.io.IOException;importjava.net.URLEncoder;importjava.util.ArrayList;importjava.util.List;@RestController@RequestMapping("/excel")publicclassExcelExportController{@GetMapping("/export")publicvoidexportUserList(HttpServletResponseresponse)throwsIOException{// 1. 模拟数据(实际从数据库获取)List<UserExportVO>dataList=getMockData();// 2. 设置响应头response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");response.setCharacterEncoding("utf-8");// 处理中文文件名乱码StringfileName=URLEncoder.encode("用户数据报表","UTF-8").replaceAll("\\+","%20");response.setHeader("Content-disposition","attachment;filename*=utf-8''"+fileName+".xlsx");// 3. 写入 Excel 到响应输出流EasyExcel.write(response.getOutputStream(),UserExportVO.class).sheet("用户信息").doWrite(dataList);}privateList<UserExportVO>getMockData(){List<UserExportVO>list=newArrayList<>();for(inti=1;i<=10;i++){UserExportVOuser=newUserExportVO();user.setUserId((long)i);user.setUserName("用户"+i);user.setPhone("188888888"+String.format("%02d",i));user.setRegisterTime("2026-07-"+String.format("%02d",i));list.add(user);}returnlist;}}

后端返回文件流的关键配置:Content-Type必须设置为application/vnd.openxmlformats-officedocument.spreadsheetml.sheetContent-Disposition用于告知浏览器下载文件名 。

四、前端:Excel 只读预览

4.1 资源本地化(内网部署)

将 Luckysheet 和 Luckyexcel 的静态资源下载到项目的public/luckysheet/目录下:

public/luckysheet/ ├── css/ │ └── luckysheet.css ├── plugins/ │ ├── css/ │ │ └── pluginsCss.css │ └── plugins.js ├── assets/ │ └── iconfont/ ├── luckyexcel.umd.js └── luckysheet.umd.js

4.2 在index.html中引入资源

<!DOCTYPEhtml><html><head><!-- 样式:顺序固定 --><linkrel="stylesheet"href="/luckysheet/plugins/css/pluginsCss.css"/><linkrel="stylesheet"href="/luckysheet/plugins/plugins.css"/><linkrel="stylesheet"href="/luckysheet/css/luckysheet.css"/><linkrel="stylesheet"href="/luckysheet/assets/iconfont/iconfont.css"/><!-- 脚本:顺序固定 --><scriptsrc="/luckysheet/plugins/plugins.js"></script><scriptsrc="/luckysheet/luckysheet.umd.js"></script></head></html>

4.3 创建ExcelPreview.vue组件

<template> <div class="preview-wrapper"> <!-- 加载状态 --> <div v-if="loading" class="loading-overlay"> <span class="loading-spinner">⏳</span> <p>{{ loadingText }}</p> </div> <!-- 预览工具栏 --> <div v-if="hasData" class="toolbar"> <span class="file-name">📄 {{ fileName }}</span> <span class="sheet-count">{{ sheetCount }} 个工作表</span> <button class="btn-download" @click="handleDownload">📥 下载原始文件</button> <button class="btn-clear" @click="handleClear">清空</button> </div> <!-- 空状态 --> <div v-if="!hasData && !loading" class="empty-state"> <div class="empty-icon">📊</div> <h3>暂无数据</h3> <p>点击下方按钮从后端获取报表</p> <button class="btn-load" @click="fetchExcel">加载报表</button> </div> <!-- 表格容器 --> <div ref="containerRef" class="sheet-container"></div> </div> </template> <script setup> import { ref, onBeforeUnmount, nextTick } from 'vue'; import axios from 'axios'; const props = defineProps({ apiUrl: { type: String, default: '/excel/export' // 后端接口地址 }, height: { type: String, default: '600px' } }); const emit = defineEmits(['loaded', 'error', 'downloaded']); const containerRef = ref(null); const loading = ref(false); const loadingText = ref('正在生成报表...'); const hasData = ref(false); const fileName = ref(''); const sheetCount = ref(0); let instance = null; let currentBlob = null; // 存储原始文件 Blob,供下载使用 // 销毁表格 function destroySheet() { if (instance) { try { instance.destroy(); } catch (e) {} instance = null; } if (window.luckysheet && window.luckysheet.destroy) { try { window.luckysheet.destroy(); } catch (e) {} } } // 只读渲染 function renderSheet(exportJson, name) { if (!exportJson.sheets || exportJson.sheets.length === 0) { emit('error', new Error('没有有效的工作表')); return; } destroySheet(); nextTick(() => { if (!containerRef.value) return; // 关键:只读预览配置 instance = window.luckysheet.create({ container: containerRef.value, data: exportJson.sheets, title: name || '未命名', lang: 'zh', // ===== 只读核心配置 ===== allowEdit: false, // 禁止编辑 showtoolbar: false, // 隐藏工具栏 showinfobar: false, // 隐藏信息栏 sheetFormulaBar: false, // 隐藏公式栏 enableAddRow: false, // 禁止增加行 enableAddCol: false, // 禁止增加列 showstatisticBar: false, // 隐藏统计栏 showsheetbarConfig: { add: false, // 禁止新增 Sheet menu: false, sheet: true // 保留切换标签 }, contextMenu: [ { text: '复制', onclick: () => {} } ], // 禁止编辑快捷键 hook: { cellMousedown() { return false; } } }); hasData.value = true; fileName.value = name; sheetCount.value = exportJson.sheets.length; loading.value = false; emit('loaded', { data: exportJson, name }); }); } // 从后端获取 Excel async function fetchExcel() { loading.value = true; loadingText.value = '正在生成报表...'; hasData.value = false; try { const response = await axios.get(props.apiUrl, { responseType: 'blob', timeout: 60000 }); // 检查响应类型 const contentType = response.headers['content-type'] || ''; if (!contentType.includes('sheet') && !contentType.includes('octet-stream')) { const text = await response.data.text(); const error = JSON.parse(text); throw new Error(error.message || '导出失败'); } // 从 Content-Disposition 提取文件名 const disposition = response.headers['content-disposition'] || ''; let name = '报表.xlsx'; const match = disposition.match(/filename\*=(?:UTF-8|utf-8)''(.+)/); if (match) { name = decodeURIComponent(match[1]); } else { const simpleMatch = disposition.match(/filename=(.+)/); if (simpleMatch) { name = decodeURIComponent(simpleMatch[1].replace(/"/g, '')); } } currentBlob = response.data; loadingText.value = '正在解析文件...'; // 将 Blob 转换为 File 对象 const file = new File([response.data], name, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); // 使用 Luckyexcel 解析 if (!window.LuckyExcel) { // 如果未全局引入,动态加载 const script = document.createElement('script'); script.src = '/luckysheet/luckyexcel.umd.js'; script.onload = () => parseFile(file, name); document.head.appendChild(script); } else { parseFile(file, name); } } catch (error) { console.error('加载失败:', error); loading.value = false; emit('error', new Error('获取报表失败: ' + error.message)); } } function parseFile(file, name) { window.LuckyExcel.transformExcelToLucky( file, (exportJson) => renderSheet(exportJson, name), (err) => { console.error('解析失败:', err); loading.value = false; emit('error', new Error('文件解析失败: ' + err.message)); } ); } // 下载原始 Excel 文件 function handleDownload() { if (!currentBlob) return; const link = document.createElement('a'); link.href = URL.createObjectURL(currentBlob); link.download = fileName.value || '报表.xlsx'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); emit('downloaded', fileName.value); } function handleClear() { destroySheet(); hasData.value = false; fileName.value = ''; sheetCount.value = 0; currentBlob = null; if (containerRef.value) containerRef.value.innerHTML = ''; } // 暴露方法 defineExpose({ fetchExcel, handleClear, destroySheet, hasData }); onBeforeUnmount(() => { destroySheet(); }); </script> <style scoped> .preview-wrapper { position: relative; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } /* 加载遮罩 */ .loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; } .loading-spinner { font-size: 48px; animation: spin 1.5s linear infinite; } @keyframes spin { 100% { transform: rotate(360deg); } } .loading-overlay p { margin-top: 16px; color: #666; font-size: 16px; } /* 空状态 */ .empty-state { padding: 80px 20px; text-align: center; background: #fafafa; } .empty-icon { font-size: 64px; margin-bottom: 16px; } .empty-state h3 { font-size: 20px; color: #333; margin-bottom: 8px; } .empty-state p { color: #999; font-size: 14px; margin-bottom: 24px; } .btn-load { padding: 10px 32px; background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; transition: 0.3s; } .btn-load:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); } /* 工具栏 */ .toolbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px; background: #fafafa; border-bottom: 1px solid #e8e8e8; font-size: 14px; color: #333; flex-wrap: wrap; } .file-name { font-weight: 500; color: #333; } .sheet-count { color: #999; font-size: 13px; } .toolbar .btn-download, .toolbar .btn-clear { margin-left: auto; padding: 4px 16px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; font-size: 13px; color: #555; transition: 0.2s; } .toolbar .btn-download { margin-left: auto; } .toolbar .btn-download:hover { border-color: #667eea; color: #667eea; } .toolbar .btn-clear:hover { border-color: #ff4d4f; color: #ff4d4f; } .sheet-container { width: 100%; height: v-bind(height); min-height: 400px; background: #fff; } </style>

4.4 在页面中使用

<template> <div class="page"> <h1>📊 数据报表预览</h1> <p class="subtitle">报表由后端生成,前端仅可查看</p> <ExcelPreview ref="previewRef" api-url="/excel/export" height="650px" @loaded="onLoaded" @error="onError" @downloaded="onDownloaded" /> <div v-if="errorMsg" class="error-message"> ⚠️ {{ errorMsg }} <button @click="errorMsg = ''" class="close-btn">×</button> </div> </div> </template> <script setup> import { ref } from 'vue'; import ExcelPreview from '@/components/ExcelPreview.vue'; const previewRef = ref(null); const errorMsg = ref(''); function onLoaded(data) { console.log('✅ 报表加载成功:', data); errorMsg.value = ''; } function onError(err) { errorMsg.value = err.message; console.error('❌ 加载失败:', err); } function onDownloaded(name) { console.log('📥 已下载:', name); } // 也可通过代码触发加载 function loadReport() { previewRef.value?.fetchExcel(); } </script> <style scoped> .page { max-width: 1400px; margin: 0 auto; padding: 20px; } .page h1 { font-size: 24px; margin-bottom: 4px; } .subtitle { color: #999; font-size: 14px; margin-bottom: 20px; } .error-message { margin-top: 16px; padding: 12px 16px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 6px; color: #ff4d4f; display: flex; justify-content: space-between; align-items: center; } .close-btn { background: none; border: none; font-size: 20px; cursor: pointer; color: #999; } .close-btn:hover { color: #ff4d4f; } </style>

五、接口规范与配置详解

5.1 后端导出接口规范

项目说明
请求方式GET
URL/excel/export
响应类型application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
响应头Content-Disposition: attachment;filename*=utf-8''报表名称.xlsx
响应体Excel 文件二进制流

5.2 只读预览核心配置

配置项作用
allowEditfalse禁止所有编辑操作
showtoolbarfalse隐藏顶部功能栏
showinfobarfalse隐藏信息栏
sheetFormulaBarfalse隐藏公式输入栏
enableAddRow/enableAddColfalse禁止增删行列
showsheetbarConfig.addfalse禁止新增工作表
hook.cellMousedown() => false禁止单元格点击编辑

六、常见问题与解决方案

6.1 文件名中文乱码

问题:下载的文件名显示为乱码。

解决:使用URLEncoder.encode()编码,并替换+%20

StringfileName=URLEncoder.encode("用户报表","UTF-8").replaceAll("\\+","%20");response.setHeader("Content-disposition","attachment;filename*=utf-8''"+fileName+".xlsx");

6.2 前端解析 Blob 失败

问题LuckyExcel.transformExcelToLucky报错,提示不是有效 xlsx。

解决:确保后端返回的 Content-Type 正确,前端请求时添加responseType: 'blob'

constresponse=awaitaxios.get(apiUrl,{responseType:'blob'// 关键});

6.3 大文件导出超时

问题:数据量大时,请求超过默认超时时间。

解决:增加前端超时配置,后端使用 EasyExcel 的流式写入。

// 前端constresponse=awaitaxios.get(apiUrl,{responseType:'blob',timeout:120000// 120秒});

6.4 内网资源加载失败

问题:浏览器控制台报 404,找不到 CSS 或 JS 文件。

解决

  1. 确认资源已放入public/luckysheet/目录
  2. 确认index.html中路径正确(以/luckysheet/开头)
  3. 检查目录结构是否完整(含assets/iconfont/字体文件)

七、总结

本方案实现了后端生成 Excel → 前端只读预览的完整内网部署流程:

层级技术选型职责
后端Spring Boot + EasyExcel根据业务数据生成.xlsx文件流
前端预览Luckysheet只读展示 Excel 内容
文件解析Luckyexcel将 Blob 转换为 Luckysheet 数据格式
传输方式REST API + Blob文件流下载与前端解析

核心要点:

  1. 后端返回文件流,前端通过responseType: 'blob'接收
  2. Luckyexcel 解析 Blob,转为 Luckysheet 可识别的 JSON
  3. 只读预览模式allowEdit: false是核心配置
  4. 所有资源本地化,适应内网部署场景

这样就完整实现了"后端渲染生成 Excel,前端下载后只做预览"的需求。