资讯详情 基于Univer的多人填报系统:单元格锁定与权限控制实战
📅 2026/11/2 18:06:59
最近在给团队做一个数据收集表需求听起来简单管理员先画好表格结构、固定表头和公式再分发给所有人填写每个人只能编辑分配到的几个单元格其他单元格一律不可修改。一开始我想用现成的在线表格工具解决但数据回收、格式控制、权限粒度全都差点意思。后来我把目光放到开源项目 Univer 上——一个基于 Canvas 渲染的现代开源电子表格框架支持在线编辑、插件化扩展而且恰好具备工作表保护和单元格锁定能力。这篇文章就是我把它做成一个“多人填报系统”的完整记录从选型、初始化、单元格权限控制到踩坑经验和数据回收都尽量讲透。如果你也在做类似的在线收集表、报销单、调研问卷这篇应该能帮你省不少弯路。1. 从“Excel 发来发去”到“在线填表”为什么选 Univer1.1 填表场景的真实痛点先说说我遇到的实际情况。团队每个月要收集一次项目进度以前的做法是行政在 Excel 里做好模板发到群里大家下载下来填再发回来。这个流程的问题很大格式横飞。有人改了表头有人删了公式有人把别人负责的列也顺手改了。汇总麻烦。收回来几十个文件要用脚本合并、清洗、对账每次至少折腾半天。权限控制完全没有。只要把文件发出去内容就像泼出去的水想收回也来不及。后来换成腾讯文档、金山文档这类在线协作表格协作是好了一点但又冒出新的问题模板的开放性太强了填表的人能看到所有数据还能随意修改整个工作表的结构。我想要的其实是“每个人拿到一张定制的表只能填自己的格子”这跟在线文档那种“广场式协作”完全是两种需求。1.2 开源方案的对比既然现成工具不合适我就开始找开源方案前后对比了几个方案能做什么短板SheetJS (xlsx)解析和生成 Excel 文件只做文件读写没有在线编辑 UILuckysheet在线表格编辑社区活跃度下降功能扩展和协同不太成熟x-data-spreadsheet轻量表格组件功能太少复杂公式、保护、校验都没有OnlyOffice完整办公套件体积大、部署重嵌入定制成本高Univer现代开源电子表格文档偏少版本迭代快需要自己填坑Univer 吸引我的点很具体第一Canvas 渲染加虚拟滚动大数据量下交互依然流畅第二插件化架构公式、数据校验、协同、导入导出都能按需加载第三它的权限模型沿用了 Excel 的“工作表保护 单元格锁定”语义团队里大多数人都有 Excel 使用经验认知成本几乎为零第四MIT 协议内网部署没有授权包袱。1.3 我把需求拆成了三层选型之前我先把自己的需求拆成三层避免中途被技术细节带偏管理员层定义表格模板、指定哪些单元格可填写、锁定其余区域、回收提交数据。填写者层打开链接就能输入只碰到自己该填的格子最好还有提示和校验。系统层能导出原始数据、能落库最好还能监听变更。这个拆分帮我决定了技术路线底层用 Univer 做表格渲染和编辑能力中间用它的命令系统控制“谁可以改哪里”上层我自己写提交和回收逻辑。这个结构清晰后面所有开发都围绕这三层展开。2. 初始化工程跑通一个能用的在线表格2.1 环境准备与依赖安装Univer 的工程接入不复杂但有几个细节需要注意。首先Node 版本建议 18 以上包管理器我用的是 pnpm主要是为了锁依赖、避免多版本冲突。安装核心依赖时要注意Univer 拆包拆得很细常见的是下面这几个pnpm add univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/ui如果需要公式、数据校验、导入导出再追加对应的插件包pnpm add univerjs/sheets-formula univerjs/sheets-data-validation univerjs/sheets-export一个容易踩的坑是所有univerjs/*包的版本号必须一致尽量用同一个发布版本。Univer 的命令系统、插件注册机制在不同版本之间改得比较频繁版本错位常常表现为“初始化不报错但功能静默失效”排查起来非常痛苦。2.2 创建一个最小可运行的表格Univer 的初始化代码整体是这样的import { Univer, UniverInstanceType, LogLevel } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIPlugin } from univerjs/ui; import { LocaleType } from univerjs/core; const univer new Univer({ locale: LocaleType.ZH_CN, logLevel: LogLevel.ERROR, }); // 注册表格插件和 UI 插件 univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverUIPlugin, { container: univer-container, // 页面上的容器 div id });接下来创建一个工作簿const workbook univer.createUniver(UniverInstanceType.UNIVER_SHEET, { id: demo-workbook, locale: LocaleType.ZH_CN, });然后通过 id 拿到这个工作簿对象就能操作工作表了const activeSheet workbook.getActiveSheet(); activeSheet.getRange(0, 0, 1, 1).setValue(Hello Univer);getRange(0, 0, 1, 1)表示从第 1 行第 1 列开始取 1 行 1 列的区域。Univer 的行列索引是从 0 开始的这一点跟 Excel 里习惯的 A1 表达不太一样写代码时容易绕晕。2.3 理解 Univer 的数据驱动模型在这个阶段我强烈建议先弄懂 Univer 的底层模型不然后面做权限控制和数据回收会迷茫。Univer 的结构大致是工作簿Workbook包含多个工作表Worksheet每个工作表包含区域Range区域的数据和样式都存成 JSON 结构。它的核心思想是“数据驱动”——表格渲染成什么样完全由数据快照Snapshot决定而不是像传统 DOM 表格那样有一堆节点。这一点非常关键。你可以把 Univer 理解成一个“状态容器”单元格的值、样式、合并、保护状态全都在数据对象里通过命令系统来修改。我们要实现的“锁定单元格”操作的本质就是修改数据层里某个范围的保护状态而不是把输入框 disable 掉。3. 核心机制拆解用工作表保护 单元格锁定实现“只能填指定格子”3.1 先理解 Univer 的保护语义我在刚开始实现时犯过一个思维定式错误以为“锁定单元格”就是把某个 range 的属性设成 locked然后它就不能编辑了。实际上 Excel 和 Univer 的逻辑是单元格的 locked 属性只是一个“待命状态”真正让锁定生效的动作是开启“保护工作表”。你可以类比一栋楼的门锁每扇门都装着锁芯locked 属性但只有物业把楼门统一锁上开启保护后所有装锁芯的门才真正锁住。反过来如果楼门没锁那每扇门上的锁芯形同虚设。所以在 Univer 里我们的策略是先给所有“允许用户填写”的单元格设置locked: false相当于告诉系统“这几扇门不锁”。保持其他单元格默认的locked: true。最后开启工作表保护让锁定规则真正生效。3.2 编写解锁区域的样式我直接贴一段可以跑通核心逻辑的代码。假设表格第一行是表头从第二行到第二十行、第三列到第五列对应 Excel 里的 C2:E20是允许填写的区域import { SetWorksheetProtectionMutation } from univerjs/sheets; // 拿到当前活跃工作表 const sheet workbook.getActiveSheet(); // 第一步把 C2:E20 设置为“不锁定” // getRange 的参数依次为起始行、起始列、行数、列数均为 0 起始索引 const editableRange sheet.getRange(2, 2, 18, 3); editableRange.setStyle({ protection: { locked: false, }, });这里要注意setStyle传入的是样式对象。如果你之前已经给这个区域设置了字体、背景色建议先getStyle()再合并修改而不是直接传一个只包含protection的新对象。否则可能把原有样式覆盖掉。3.3 启用工作表保护设置好可编辑区域之后接下来就是“锁门”const commandService univer.getCommandService(); commandService.executeCommand(SetWorksheetProtectionMutation.id, { unitId: workbook.getId(), subUnitId: sheet.getSheetId(), protection: { sheetId: sheet.getSheetId(), hasPassword: false, selectLockedCells: false, selectUnlockedCells: true, formatCells: false, formatColumns: false, formatRows: false, insertColumns: false, insertRows: false, deleteColumns: false, deleteRows: false, sort: false, filter: false, usePivotTable: false, editObjects: false, }, });SelectLockedCells设为false的意思是填表人连“点击锁定单元格”都不行光标根本进不去更别说编辑了。这一点体验上非常重要如果你只锁编辑、但允许点击锁定单元格用户会看到一个半灰不灰的格子容易误以为是自己电脑卡了。SelectUnlockedCells设为true的含义则相反未锁定的可填写区域可以正常点选、输入。填表人打开页面后Tab 和鼠标操作都能顺畅落在可编辑区域。执行完这段代码后你可以立刻在页面上测试点击 A 列表头或其他锁定区域不会弹出编辑光标点 C2:E20 区域可以正常输入内容效果基本就是热搜里描述的那种“用户只能填指定的格子”。3.4 用“允许编辑范围”做更细的权限控制如果你要做的不是“所有人都能填同一批格子”而是“每个人只能填属于自己的几行”那就需要更细的权限模型。Univer 在保护机制里有“允许编辑范围”Allow Edit Range的概念。它的核心思想是开启工作表保护后你可以在保护规则里声明若干个例外区域每个区域还可以关联对应的编辑者。比如“张三只能改第 2 行的 C:E 列李四只能改第 3 行的 C:E 列”。我在实际项目中用的是一个变通方案在服务端根据每个用户生成独立的工作簿副本然后把各自的区域解锁再下发给对应的人。这样其实比单人单权限更省事因为 Univer 的快照本身就是 JSON服务端动态生成起来非常快。如果你确实需要动态判断“谁来填”再深入研究 Allow Edit Range 相关的类型定义和 mutation。3.5 别忘了公式保护还有一个容易被忽视的细节如果你在模板里写了公式比如“总价 单价 × 数量”填写者虽然不能改公式所在单元格但公式本身依赖的输入单元格可能被填了非法值。保护工作表后公式单元格是锁定的用户点不了也改不了。但你还是要提前把公式单元格也设置为locked: true默认即锁定并且确认hidden属性符合你的需求。protection.hidden设为true可以在公式被锁定的同时隐藏公式内容避免填表人偷看计算逻辑。这个在“报价单”“成本核算表”这类业务场景里很常用。4. 交互细节优化提示、校验、联动让填表人少犯错4.1 视觉上区分“可填区”与“只读区”权限做对了但用户不知道哪里能填、哪里不能填还是会有客服或者行政被反复追问。我的做法是在模板初始化后给可填写区域加上明显的底色和边框const editableRange sheet.getRange(2, 2, 18, 3); editableRange.setStyle({ background: #FFF9E6, // 浅黄色背景 border: { top: { color: #D9B300, style: medium }, bottom: { color: #D9B300, style: medium }, left: { color: #D9B300, style: medium }, right: { color: #D9B300, style: medium }, }, });这样用户一眼就能看出“黄色格子是我要填的”大大降低咨询量。4.2 用数据校验插件限制输入格式填表过程中常见的错误是格式混入日期写成字符串、数量写成负数、手机号少一位。Univer 有数据校验插件可以给指定区域设置校验规则。安装univerjs/sheets-data-validation插件后你可以在初始化时注册插件并使用校验 API。比如限制“数量”列只能填写 0 到 1000 的正整数超过就弹出红色提示。这个功能对收集表来说是刚需实际使用起来体验和 Excel 的“数据验证”非常接近。4.3 公式联动让汇总自动算好填表系统最有价值的一点是可以在模板中预设公式让计算结果自动生成在只读单元格里。例如报销单填金额、填数量总金额自动算出。报名表填报名人数剩余名额自动减少。月度复盘填几个分项得分总分和排名自动展示。这些公式默认就是锁定的填表人不会误删。只要确保模板里的公式在保护工作表之前已经写好用户填写时就能实时看到结果。这个体验远胜“发 Excel 文件过来再离线算”。4.4 冻结窗格与首行固定模板表头一般都很长用户滚动几屏就忘了当前看的是哪一行。Univer 支持冻结窗格我在项目里会把前两行固定住。这个操作需要找到 freeze 相关的 API通过registerSheetCommand或命令机制来设置。不同版本的调用位置不一样但套路是统一的先把要冻结的行列设置好再刷新视图。设置之后填表人滚动数据时表头始终可见效率提升非常明显。4.5 锁定区域的点击反馈用户点击锁定时最好有个明确的提示而不是“无声拒绝”。Univer UI 收到编辑尝试后通常会阻止操作并给出统一提示。如果你要自定义提示文案可以在初始化 UI 插件时传入配置或者监听相关的保护命令事件。我实际体验下来提示的颗粒度不用太细一个统一的“该区域为只读/受保护不可编辑”就好用户看到黄色格子就知道去哪里填看到灰色格子点一下弹出提示就再也不会乱点了。5. 实战踩坑清单那些文档里不会明说的坑5.1 版本不一致导致的“静默失灵”这是我遇到的第一个大坑。当时我把univerjs/core装成了最新版但univerjs/sheets还是旧版。初始化时不报错表格也能显示但执行getRange().setStyle()后样式没有反应单元格该锁定还是锁定。排查了很久最后在 package.json 里发现版本号对不上。改完全部统一到同一个版本后问题消失。经验Univer 的插件版本必须严格一致升级时最好一次性更新全部univerjs/*包不要漏掉任何一个。5.2 setStyle 小心覆盖整片样式Univer 的setStyle支持部分样式传入但某些版本在处理时并不是“合并”而是“整体替换”。我之前给填写区域先设置了黄色背景后来又用setStyle({ protection: { locked: false } })去解锁结果背景色、边框全没了表格成了白板。稳妥的做法是先取旧样式再合并const oldStyle editableRange.getStyle(); editableRange.setStyle({ ...oldStyle, protection: { locked: false }, });5.3 保护参数里的开关项要逐个确认SetWorksheetProtectionMutation的protection对象里有十几个布尔字段包括selectLockedCells、selectUnlockedCells、insertRows、deleteColumns等等。我第一次只填了sheetId和hasPassword结果用户虽然不能改值却可以在工作表里插入行、删除列把整个表单结构搞坏。所以在开启保护时把所有操作项都检查一遍不要偷懒formatCells、formatColumns、formatRows控制格式编辑insertRows、insertColumns、deleteRows、deleteColumns控制行删改sort、filter、usePivotTable控制数据操作editObjects控制嵌入对象。我的建议是全部设为false除非你明确需要开放。5.4 填表人撤销操作可能绕过视觉锁保护机制主要拦截的是“用户在 UI 里发起编辑”这条通道但撤销undo和重做redo走的是另一个路径。我遇到的情况是填表人先在可编辑区输入了一些值然后又用 CtrlZ 连续撤销结果某个原本锁定的单元格也跟着变了。这是因为撤销栈保留了之前的批量修改命令如果修改命令本身没有做权限校验就有可能在恢复旧状态时覆盖锁定区域。我的解决方案是在提交后端时做最终校验直接读取每个格子的值比对模板的锁定规则发现异常就拒绝入库并提示用户刷新重填。5.5 中文文档少善用类型定义和官方仓库Univer 的中文文档目前还不算全很多 API 只能靠猜或者靠搜索引擎翻旧 issue。我的经验是三个地方最有用node_modules/univerjs/sheets/lib里的.d.ts类型定义文件能告诉你所有命令参数的结构官方仓库里的 Storybook 示例和测试用例几乎覆盖了所有常见场景源码里的 Playground搜索你需要的关键词比如protect、lock、>const values [ [1, 2, 3], [4, 5, 6], ]; sheet.getRange(0, 0, 2, 3).setValues(values);一次调用搞定性能差距很大。你要是用循环填 500 行页面色块会闪烁严重时甚至假死。6. 数据回收与服务端对接填完之后怎么办6.1 保存整个工作簿快照填表数据最终要收集回来。Univer 有一个非常便利的特征整个工作簿本身就是一个可序列化的 JSON 对象。你可以直接取出完整快照存到数据库里以后重新打开时原样渲染const snapshot workbook.getSnapshot(); // 将 snapshot 字符串化后传给后端保存 const json JSON.stringify(snapshot);这个方案的好处是连样式、合并单元格、公式、保护状态都一起保存了。用户填到一半退出下次打开还能恢复到原来的状态对长表单特别友好。6.2 读取指定范围的值如果不需要保存整个工作簿而是只想取关键数据那就用getRange().getValues()const data sheet.getRange(2, 2, 18, 3).getValues(); // 返回一个二维数组按行、列排列 console.log(data);这种方式适合直接把数据对接业务系统。比如把 C 列当成“姓名”D 列当成“部门”读取后直接插入公司内部的数据库表。我这里建议在读取之前先做数据校验循环检查必填项是否为空、格式是否符合预期。宁可多写几个 if也不要让脏数据流到服务端。6.3 导出 Excel 备份有些业务方还是要一份 Excel 文件存档不可能完全抛弃。Univer 社区有univerjs/sheets-export插件可以在前端直接导出.xlsx。如果你的项目是服务端处理也可以把工作簿快照发给后端再用 ExcelJS 或 PHPExcel 把值遍历出来生成 Excel 文件。导出时注意一点导出的表格文件只是原始数据的副本不包含 Univer 的保护状态和校验规则所以不要指望用导出的 Excel 再回填。导出文件夹主要用于归档和人工审计。6.4 监听变更做实时入库如果想要实时感知用户改了哪个单元格可以监听 Univer 的命令事件。Univer 中所有对数据的修改都会经过 command service你可以监听SetRangeValuesMutation在回调里拿到修改的范围和新值。不过我在项目里没有做太复杂的实时入库原因是多人填报场景中填表通常是一次性动作不需要像在线文档那样做实时倒灌。我选择在用户点击“提交”后一次性读取全部区域的值再传给后端。这样逻辑最简单也不容易被撤销栈干扰。6.5 给填表人一个清晰的提交入口不要在表格角落让用户自己找提交按钮。我是在页面上方固定一个自定义工具栏里面只放两个按钮“保存草稿”和“提交”。提交后锁定全部区域禁止二次修改如果填错了由管理员在后台解锁后再退回修改。这个模式比开放实时编辑更容易管理。Univer 的 UI 插件支持自定义工具栏和按钮写起来也不复杂。最后再分享一点实际感受整个项目从选型到上线我前后用了一周多时间。Univer 的底子确实扎实Canvas 渲染流畅插件体系清晰权限模型熟悉数据快照也方便。但如果你只想快速实现“用户填写指定单元格”不要被它表面丰富的功能迷惑——先理清自己的三层需求再按“模板设计 → 解锁填写区 → 开启保护 → 校验回收 → 服务端归档”这条主线来做就不会绕路。另外如果你要做的填表场景是“1000 人同时在线填写”我的建议是给每人分发独立的工作簿副本而不是让所有人挤在同一张表上。前者对服务端压力小数据隔离也干净还能自然支持“不同人看到不同内容”。这也是我在后续版本里准备用上的方案。