Luckysheet 内网部署教程:后端生成 Excel,前端只读预览
📅 2026/7/29 21:28:46
👁️ 阅读次数
📝 编程学习
Luckysheet 内网部署教程:后端生成 Excel,前端只读预览
一、场景说明
在企业后台管理系统中,常见需求是:后端根据业务数据生成 Excel 报表,前端直接从接口下载该文件并进行在线预览,用户只能查看、不能编辑。
典型场景包括:
- 财务报表生成与查阅
- 数据报表导出与审批
- 系统日志导出查看
- 用户数据报表预览
本文聚焦于内网部署场景,所有资源均为本地化,不依赖外网 CDN。后端使用Spring Boot + EasyExcel生成
.xlsx文件,前端使用Luckysheet + Luckyexcel进行只读预览。
二、整体架构与数据流
┌─────────┐ 请求导出 ┌─────────────┐ 生成Excel ┌─────────────┐ │ 前端 │ ──────────────────▶ │ 后端接口 │ ──────────────────▶ │ Excel文件 │ │ (Vue3) │ │ (SpringBoot)│ │ (.xlsx) │ └─────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │◀─────────────────────────────────│ │ │ 返回文件流(Blob) │ │ │ │ │ 接收Blob并解析 │ │ │ 使用Luckyexcel转换为Luckysheet格式│ │ │ 只读渲染预览 │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────────┐ ┌─────────────┐ │ 用户 │ │ Luckysheet │ │ Luckyexcel │ │ 查看 │ │ 只读预览 │ │ 解析xlsx │ └─────────┘ └─────────────┘ └─────────────┘核心流程:
- 前端发起导出请求→ 调用后端接口
- 后端生成 Excel 文件→ 使用 EasyExcel 或 Apache POI 将数据写入
.xlsx,以文件流形式返回 - 前端接收 Blob 数据→ 将二进制数据转换为
File对象 - Luckyexcel 解析→ 将
.xlsx转换为 Luckysheet 可识别的 JSON 数据 - 只读渲染→ 以
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.sheet,Content-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.js4.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 只读预览核心配置
| 配置项 | 值 | 作用 |
|---|---|---|
allowEdit | false | 禁止所有编辑操作 |
showtoolbar | false | 隐藏顶部功能栏 |
showinfobar | false | 隐藏信息栏 |
sheetFormulaBar | false | 隐藏公式输入栏 |
enableAddRow/enableAddCol | false | 禁止增删行列 |
showsheetbarConfig.add | false | 禁止新增工作表 |
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 文件。
解决:
- 确认资源已放入
public/luckysheet/目录 - 确认
index.html中路径正确(以/luckysheet/开头) - 检查目录结构是否完整(含
assets/iconfont/字体文件)
七、总结
本方案实现了后端生成 Excel → 前端只读预览的完整内网部署流程:
| 层级 | 技术选型 | 职责 |
|---|---|---|
| 后端 | Spring Boot + EasyExcel | 根据业务数据生成.xlsx文件流 |
| 前端预览 | Luckysheet | 只读展示 Excel 内容 |
| 文件解析 | Luckyexcel | 将 Blob 转换为 Luckysheet 数据格式 |
| 传输方式 | REST API + Blob | 文件流下载与前端解析 |
核心要点:
- 后端返回文件流,前端通过
responseType: 'blob'接收 - Luckyexcel 解析 Blob,转为 Luckysheet 可识别的 JSON
- 只读预览模式,
allowEdit: false是核心配置 - 所有资源本地化,适应内网部署场景
这样就完整实现了"后端渲染生成 Excel,前端下载后只做预览"的需求。
编程学习
技术分享
实战经验