资讯详情

资讯详情

基于Univer实现单元格级只读控制的在线填报系统实践

最近在做一个企业内部的“数据填报系统”需求听起来平平无奇主管先创建一张表格把自己想让员工填写的格子标出来其他单元格谁都别想动员工打开页面后只能在指定的可编辑单元格里录入数据表头、说明、公式区域全部只读。这个需求放在网页端做第一反应是直接用Excel文件上传后端解析就完了。但产品加了一个限制员工必须在线看到原始表格填完还能复核、导出Excel。Excel上传方案只能做单向下发做不了基于单元格粒度的编辑权限控制。于是我把目标转向纯前端表格内核最后选定了开源的Univer整体跑下来效果符合预期。这篇文章算是一份从选型到落地、再到踩坑的完整记录如果你也在做类似的台账录入、在线填报、静态模板收集工具应该能少走不少弯路。1. 为什么用Univer而不是SpreadJS或Luckysheet1.1 业务场景拆解先把这个需求掰开看它本质上是“带权限约束的在线表格填写工具”而不是一个多人协作文档。主管在管理端负责定义模板包括表头、列宽、公式、默认值、下拉菜单然后框选一块区域作为“可填写区”其余地方都是“只读区”。员工侧打开的页面需求就三条第一能看清整张表格的结构第二只能在指定单元格里输入内容第三录入后能保存、能导出。表面上是表格组件的能力实际上考验的是“编辑控制”的精细程度毕竟多数在线表格组件默认允许用户随便点随便改。结合这个场景我当时给方案定了几个硬性指标必须是前端渲染的类Excel交互体验而不是一张“看起来像表格”的HTML表。支持列宽、合并单元格、样式、公式因为主管设计的模板经常会用到这些能力。能控制单元格的可编辑性最好可以拦截具体的编辑操作。与现有前端框架的集成成本不能太高团队是React技术栈。开源优先能自己改代码不依赖黑盒授权。1.2 SpreadJS、Luckysheet与Univer的横向对比市面上的前端表格方案其实不少真正纳入过评估的只有三个SpreadJS、Luckysheet、Univer。SpreadJS是商业组件功能确实全面数据透视、图表、打印、导入导出都做得很成熟。团队试用过稳定性也让人放心但授权费用摆在那里。对于一个内部填报工具来说这个成本不值得背而且商业组件的扩展边界即使再宽也总有“咨询工单式”的等待成本。Luckysheet是较早的开源方案Excel互导顺手UI也接近Office风格。但它的代码维护节奏一直不太稳定而且协同能力被拆成了另一个产品插件体系相对封闭。拿来做常规展示可以要在上面做“单元格编辑权限”这种深水区改造心里没底。最终敲定的是Univer。它把表格、文档、幻灯片放在一套统一内核里UI层和数据层分离扩展上确实灵活。更关键的是它的命令驱动架构界面上每一次操作最终都会变成一条Command在命令系统里跑。既然是命令就能在它执行之前拦下来做权限判断。这对于“部分单元格只读”这种需求来说属于天然对口的技术底座。1.3 Univer的架构优势命令驱动带来的可能性Univer的核心概念并不复杂整个表格数据存储在Workbook里每个Sheet由CellData、样式、行列信息等组成而所有对数据的修改都要经由命令系统派发。这个设计带来一个好处你要控制用户能不能改某个单元格不一定要去改UI层的事件绑定只需要在命令入口处做判断。用户按了Delete、手动输入、粘贴数据走到最后都会触发对应的编辑命令拦截到这一层权限控制就变得可控且统一。Univer还提供了一套Facade API面向业务开发者的封装很多常用操作不需要直接碰命令层代码。比如要设置某个区域的值标准写法是拿活动工作表然后getRange(row, col, rowCount, colCount)后调用setValue非常直观。官方推荐的集成方式也简单支持React、Vue、Angular等主流框架容器挂载的方式和普通DOM组件差别不大。2. 工程接入5分钟让Univer跑起来2.1 依赖安装与Preset方案当前版本Univer推荐使用Preset方式进行安装它会把创建Univer实例所需的模块打包好免去手动组装一套Service的繁琐过程。以Sheet场景为例安装命令如下npm install univerjs/presets然后就可以创建Univer实例import { FUniver } from univerjs/core/facade; import { UniverPresetSheet } from univerjs/presets/lib/sheets; const container document.getElementById(univer-container)!; const univer FUniver.newUniver({ locale: zhCN, presets: [ UniverPresetSheet({ container }) ] });容器是一个普通的div注意需要给这个容器一个明确宽度和高度否则画布能渲染出来但交互区尺寸会是0。我在项目里统一用的#univer-container { width: 100%; height: 640px; border: 1px solid #ddd; border-radius: 4px; }Univer的版本迭代非常快不同小版本之间API名称和参数结构会有调整。我下面给的代码都基于当时使用的0.3.x版本如果你安装的版本更新碰到方法名对不上就去node_modules/univerjs/**/lib/facade里翻类型定义这个习惯在Univer社区里几乎是必须的。2.2 创建Workbook并写入初始数据实例创建完成后可以直接在工作表上做初始化。我们要做的模板会先在管理端预填好表头、公式、样式然后由员工端加载。const activeSheet univer.getActiveSheet(); // 直接给一块区域写入二维数组 activeSheet .getRange(0, 0, 2, 4) .setValue([ [指标名称, 上期值, 本期填写值, 环比变化], [营收(万元), 120, null, C2-B2] ]);写入的数据支持数字、字符串、公式字符串公式会由Univer内置计算引擎计算。注意我在“本期填写值”那里填了null表示这是一个空单元格留给员工填写后端返回模板数据时空单元格保持为空即可。如果工作薄里需要多个Sheet可以在创建时配置好也可以用后续的命令动态添加。项目里我们的模板是按Sheet组织的一个模板一个Sheet加载完数据后通过sheet.setName()设置成指标名称。2.3 通过Facade操作Sheet公式、样式、行列配置除了写数据模板还需要设置列宽、合并单元格、背景色。这些能力全都可以通过Facade API完成// 设置第1列宽度为120像素 activeSheet.setColumnWidth(0, 120); // 合并A1:A2 activeSheet.mergeCells(0, 0, 2, 1); // 给表头区域加灰色背景 activeSheet .getRange(0, 0, 1, 4) .setStyle({ backColor: #F2F2F2, fontWeight: bold, horizontalAlign: center });这些操作在界面上会有立即可见的反馈也可以全部在初始化阶段执行。我们甚至给管理端做过一个“模板设计预览”就是复用同一套Facade代码只是把输入源从后端配置换成管理员在页面上框选的结果。3. 核心需求指定单元格可编辑其余只读3.1 将“可编辑区域”抽象成配置“允许用户填写一定区域、其他单元格不能修改”这个需求实现前先要设计数据模型。不能把可编辑状态散落在各个单元格上否则模板配置、权限判断、后端校验都会很难维护。我采用的方式是定义一份区域配置精确到行、列、行数、列数{ templateId: template-001, editableRanges: [ { row: 1, col: 2, rowSpan: 5, colSpan: 1 }, { row: 1, col: 3, rowSpan: 5, colSpan: 1 } ], protectedRanges: [] }意思很明确整张表的默认状态是“只读”只有两个区域允许编辑。一来方便管理端通过框选自动生成二来员工端加载模板时直接套用这套配置三来后端在接收提交数据时也能根据同一个配置做二次校验避免有人绕过前端直接造接口请求。3.2 方案A工作表保护与单元格锁定Univer本身有工作表保护的概念思路和Excel一致先把允许编辑的单元格标记为“不锁定”再开启工作表的保护模式这样被保护的单元格就不可编辑了。const editableRanges [ { row: 1, col: 2, rowSpan: 5, colSpan: 1 }, { row: 1, col: 3, rowSpan: 5, colSpan: 1 } ]; // 先给所有单元格上锁 activeSheet .getRange(0, 0, activeSheet.getConfig().rowCount, activeSheet.getConfig().columnCount) .setStyle({ protection: { lock: true } }); // 再把允许编辑的区域解锁 for (const range of editableRanges) { activeSheet .getRange(range.row, range.col, range.rowSpan, range.colSpan) .setStyle({ protection: { lock: false } }); } // 开启工作表保护 activeSheet.setProtection({ protect: true, password: });这段代码对Excel场景非常自然导出后如果用户把文件下载到本地保护信息也能保留。但Univer在不同版本里对保护能力的实现深度有差异尤其是拖拽填充、双击编辑这类操作不一定完全被保护逻辑拦住。我在测试中就碰到过“单元格锁住了但下拉填充还能把数据拖进锁定区”的情况所以不能只依赖这一层。3.3 方案B命令拦截实现软只读更稳妥的方式是在命令系统层面做拦截。Univer的大部分编辑行为最终都会派发编辑类命令比如BeforeActiveCellEditCommand这类前置命令。我们可以在命令进入执行管线前拿到当前操作的目标单元格坐标然后判断这个坐标是否落在可编辑区域内不在就直接阻止。这部分的代码是核心但不同版本命令ID略有差异我给出的是经过验证的思路import { ISetRangeValuesCommandParams } from univerjs/sheets; import { SetRangeValuesCommand } from univerjs/sheets; const editableRanges [ { row: 1, col: 2, rowSpan: 5, colSpan: 1 } ]; function isEditable(row: number, col: number) { for (const range of editableRanges) { if ( row range.row row range.row range.rowSpan col range.col col range.col range.colSpan ) { return true; } } return false; } univer.onBeforeCommandExecute((command) { if (command.id SetRangeValuesCommand.id) { const params command.params as ISetRangeValuesCommandParams; // 解析出目标区域 const { range } params; // 遍历区域的每个单元格只要有不可编辑的格子就整体阻止 for (let row range.startRow; row range.endRow; row) { for (let col range.startColumn; col range.endColumn; col) { if (!isEditable(row, col)) { // 阻止命令执行弹提示或忽略 return false; } } } } });命令被阻止后界面上的表现是“输入无效”但用户没有反馈会觉得很奇怪。所以我在拦截的同时会调用业务层的Toast组件提示“当前单元格为只读区域请在可填写范围内录入”。这个方案不影响公式计算、不影响界面渲染只是把编辑入口关掉。缺点是它只对在线交互有效如果用户导出Excel后本地修改那就管不到了。3.4 双轨方案与取舍建议这两个方案不是二选一我最终在线上版本里是组合使用的。交互层用命令拦截保证员工在网页上没法误触只读单元格也不会有奇怪的拖拽填充行为。导出层用工作表保护保证下载到本地的Excel自带锁定规则二次分发后也不会被随意修改。如果只是做内部工具单用方案B其实也够如果要交付给外部供应商或跨部门使用Excel导出的保护信息就是刚需。建议还是两个都做成本不高收益是权限链路完整。4. 进阶应用用户自定义模板、填写、回收与校验4.1 管理端如何设计模板既然热词里提到了“支持用户定义表格”那就必然涉及管理端如何把模板“设计”出来。Univer整个界面是Web应用可以直接在管理端页面嵌入一个Univer实例让管理员像操作Excel一样编辑模板。我在项目里给管理端做了一个“模板编辑模式”管理员打开编辑器像操作Excel一样填写表头、设置样式、插入公式。编辑完成后切换到一个“标记可编辑区域”的状态这个状态下可以直接在页面上框选一块区域框选后点击“标记为可填写”系统会把区域坐标追加到editableRanges里。点击保存后前端把当前工作表的配置、样式、数据、公式连同editableRanges一起提交到后端存成一套模板JSON。保存模板的核心数据可以这样拿const workbook univer.getActiveWorkbook(); const sheet workbook!.getActiveSheet(); const config sheet.getConfig(); // 把工作簿、公式、样式保存为模板 const templatePayload { name: sheet.getName(), cellData: config.cellData, rowData: config.rowData, colData: config.colData, // 来自管理员的框选结果 editableRanges };这份JSON就是模板的完整定义。员工端加载时只要拿到它用同样的一套Univer API重建即可。4.2 用户填写后的数据提取员工填写完成后最核心的问题是如何把数据保存到后端我通常不用setValue反向读取全部单元格。更合理的做法是只遍历editableRanges里的区域因为这些才是用户真正被允许修改的地方其他单元格无论是什么数据都不应该被提交。const result: Recordstring, unknown[] []; for (const range of editableRanges) { const values activeSheet .getRange(range.row, range.col, range.rowSpan, range.colSpan) .getValue(); result.push({ range: ${range.row}:${range.col}, values }); } // 提交给后端 api.post(/submit, { templateId: template-001, data: result });这里有个小坑如果可编辑区域里混有公式单元格getValue()拿到的可能是计算后的值而不是公式本身。如果业务需要在服务端重新计算建议前端提交时把公式也顺带一起提交或者只允许在纯输入单元格上做可编辑标记别把公式单元格开放出来。一般模板设计规范里就该禁止这个操作。4.3 数据校验与公式联动填报表单光能输入还不够需要有一层校验。Univer自带的数据校验能力可以用于下拉选择、数值范围控制但如果我们希望校验逻辑完全由后端统一维护更可控的方式还是在命令拦截层做自定义校验。举个例子某个指标要求填写的数值不能小于0univer.onBeforeCommandExecute((command) { if (command.id SetRangeValuesCommand.id) { // 拿到value后做校验 const newValues params.value; for (let i 0; i newValues.length; i) { for (let j 0; j newValues[i].length; j) { if (typeof newValues[i][j] number newValues[i][j] 0) { showError(数值不能小于0); return false; } } } } });这样做的好处是即使员工输入了非法值也会在单元格层面直接被拦下不会污染数据。配合后端的提交校验数据质量可控。公式联动方面我建议把公式放在只读区而不是可填写区。比如“环比变化”那一列管理员在模板里预先写好公式C2-B2员工只需填“本期填写值”环比自动算出来。由于公式列不在可编辑范围内员工也没法覆盖公式这个设计天然保护了计算逻辑。5. 避坑实录Univer落地过程中的常见问题5.1 只读控制被工具栏绕过只拦截编辑命令并不代表高枕无忧。Univer自带的工具栏里还有“清除内容”、“删除单元格”、“排序”等操作这些操作会绕过用户输入层面的拦截直接改掉单元格数据。我在一开始就吃过这个亏员工按了全选然后点“清除内容”整张表的内容全没了。后来处理方式是把工具栏和右键菜单裁剪掉UniverPresetSheet({ container, toolbar: false, rightMenu: false });对于纯填写场景工具栏对用户来说本来就是多余的。保留单元格选择和筛选就有足够好的体验工具栏关掉后误操作大幅减少。如果确实需要部分按钮Univer支持自定义工具栏配置按需保留即可。5.2 保护状态下的公式编辑冲突使用工作表保护后公式单元格和锁定的样式单元格虽然不能手动编辑了但Univer内部某些命令或者拖拽自动填充还是可能触达这些区域。我在测试中复现过选中一个可编辑单元格拖拽填充柄往下拉Univer会把可编辑区域的公式或值延续到下面几行即使这些行不在解锁范围内。排查下来发现这是自动填充逻辑没有实时校验锁定状态导致的。我的处理方式是在模板初始化阶段就把表格设置为只读默认态避免“先全部可编辑再标记只读”造成的保护盲区。命令拦截层把“拖拽填充”这一类命令也纳入判断只要目标区域包含非可编辑单元格就整体拒绝。如果你在线上也遇到类似问题建议在onBeforeCommandExecute里把能想到的命令都打印出来然后逐个检查哪些命令操作了非可编辑区域。5.3 大数据量下渲染与性能调优表格数据到几千行时Univer渲染和交互会有一定的卡顿这是Web表格的通病。我们的填报模板一般控制在一百行以内暂未遇到大的性能瓶颈。但如果你的模板确实要承载大量数据有几个调整方向关闭网格线渲染Univer设置里能省一部分绘制开销。减少复杂合并单元格的使用合并会给计算带来额外开销。初始化数据时避免逐格写入尽量用区域批量写入也就是getRange(...).setValue(二维数组)的方式。如果模板只读可以设置后不启用编辑服务轻量模式下性能提升明显。5.4 版本升级导致的API不兼容Univer到现在依然处于快速迭代期差不多半年内API就可能发生不兼容变化。我们曾因为升级小版本导致setProtection的参数结构变了编译不通过排查花了不少时间。我的建议是锁定主版本不要随便升级小版本除非有明确的功能修复。升级前先在本地跑一遍核心的模板初始化、只读控制、数据提取自动化测试。关注官方升级日志尤其留意Facade层的变更说明。这类项目里稳定比新特性重要得多。5.5 撤销栈把只读逻辑绕过去最后一个坑比较隐蔽。Univer默认有撤销/重做功能用户先执行了一个允许的操作然后再通过撤销命令把表格恢复到某个中间状态这个恢复操作可能会把只读区域的数据也一起改动。处理方式是看场景填报工具原则上不允许撤销恢复覆盖其他区域我把撤销栈范围做了限制在命令拦截层禁止撤销命令影响非可编辑区域或者干脆在员工端关闭撤销功能。内部工具里填写准确比操作便利更重要。最后想分享的一个心得这个项目最让我满意的不是Univer能渲染出多像Excel而是它的命令机制让权限控制变得足够干净。你要负责的是“定义规则”而不是“对抗UI”把一份模板的可编辑区域配好拦截命令校验数据这套链路跑通后员工真的很难通过常规操作破坏表格。后续如果想再接协同编辑、接入AI生成公式模板Univer的架构也能撑得住。至少再让我选一次我还会用这套方案。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →