ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Univer开源办公SDK深度解析:构建可被AI Agent操纵的在线表格

Univer开源办公SDK深度解析:构建可被AI Agent操纵的在线表格

1. 为什么我会盯上 Univer 这个开源办公 SDK

先说结论:如果你的团队正准备做办公类产品,或者你想给现有业务系统塞进一套能写能算、还能被 AI Agent 操作的表格能力,Univer 是目前开源阵营里最值得认真评估的一个方案,没有之一。

我最初注意到它,是因为在一个 To B 项目里被表格折腾得够呛。客户要求能在网页端在线编辑 Excel 格式的数据表,同时还要支持多人协作、权限控制,后来产品经理又补了一刀——希望后续能接入 AI 能力,让智能体直接帮用户填表、算数、生成汇总。这个需求链条走下来,传统的方案基本都卡壳了。用阿里/腾讯的在线表格 SaaS,数据都放在别人那儿,私有化部署谈不拢;用 Luckysheet 这类老牌开源库,功能覆盖得不错,但底层对 AI Agent 的支持几乎没有,所有交互都得自己二次封装。

后来调研到 Univer,算是眼前一亮。这货的定位不是“又一个在线表格”,而是“办公应用 SDK”,也就是说它从一开始就想让你把表格、文档、幻灯片这三个办公核心能力,当成积木一样嵌进你自己的应用里。而且它对 AI Agent 场景做了不少原生设计,我很早就想写一篇东西好好聊聊它的架构和实战接入,今天就把我踩过的坑和摸清楚的门道一起倒出来。

如果你符合下面任何一种情况,这篇内容应该能帮上忙:

  • 你想在自有系统里嵌入一套在线表格,但不想从头造轮子;
  • 你正在规划 AI Agent 类产品,希望 Agent 能“看懂”表格,能直接读写单元格;
  • 你研究过开源的 SpreadJS、Luckysheet、SheetJS,想找一个综合能力更强的替代方案;
  • 你对“前端 SDK”这个词有点发怵,想知道这玩意儿到底能干什么、怎么接、有没有坑。

接下来我会先拆 Univer 的整体设计思路,然后讲核心能力,再给一段完整的接入实操,最后把常见问题整理成速查表。整个过程尽量说人话,遇到概念会用类比解释,保证你不翻文档也能听懂。

2. 整体架构与设计思路拆解:Univer 到底解决了什么问题

2.1 办公 SDK 和传统“表格插件”的本质区别

先聊清楚一个底层问题:为什么我们需要一个“办公应用 SDK”,而不是继续用传统的“表格插件”。

传统表格插件,比如老牌的 Handsontable、SheetJS,本质上是“一个独立的表格实例”,你把它嵌到页面里,它自己有一套数据模型、一套渲染引擎、一套 API。你想让它跟外部的数据源实时同步,得手动调 API;你想让两个用户同时编辑同一份表格,得自己搭 WebSocket 服务、自己实现冲突合并;你想让 AI 读表格内容,得让 AI 先去解析 JSON 或 XLSX 文件。

Univer 的设计起点不太一样。它把自己定义成“一套桌面级办公应用的运行时”,架构上拆成了核心(Core)、渲染引擎(Canvas Renderer)、UI 层(Sheet/Docs/Slide UI)、**插件体系(Plugin System)**四个层次。换句话说,你拿到的不是“一个表格控件”,而是一个可以承载文档、数据、协作、扩展的完整框架。打个比方:传统插件是你租了一间装修好的房间,想改结构得砸墙;Univer 是给你一套毛坯房加标准管线,水电网都预留好了,你想在哪个房间加个浴室、把客厅改卧室都行,它给你提供了合法的改造接口。

这个思路带来的直接好处是:和 AI Agent 的集成不靠“事后解析”,而是靠“原生 API 操作”。Agent 可以直接调用 Univer 的 Command 系统去改单元格、插公式、读选区,每一步操作都有日志、有回滚、有权限校验。这是后面所有 AI 场景能够落地的基石。

2.2 为什么选 Canvas 渲染,而不是 DOM 表格

Univer 的渲染层是用 Canvas 重写的,这一点涉及很多人的直觉盲区。传统的 Luckysheet 也是 Canvas 渲染,所以不算新事,但 Univer 把它做成了统一的渲染引擎,表格、文档、幻灯片共用一套 Canvas 渲染管线,这就有意思了。

用 Canvas 渲染的核心理由是性能可控。一个 10 万行 × 100 列的 Excel 文件,如果用 DOM 表格渲染,浏览器光是生成 1000 万个 DOM 节点就能把内存吃干净;Canvas 只需要按视口区域绘制可见部分,滚动的时候不断重绘,内存占用跟数据总量基本无关,只跟可视区域有关。实测下来,Univer 打开几万行的数据表,滚动、筛选、编辑的交互基本能保持 60fps 的流畅度。

但这个选择也有代价。Canvas 渲染意味着文本框、下拉列表、批注这类交互组件不能用标准 DOM 方案去做,Univer 需要在 Canvas 内自己模拟一套 UI 组件的交互逻辑。所以你会注意到,Univer 的单元格编辑、下拉菜单、右键菜单,视觉上跟原生 HTML 控件有一点点微妙的差别,那不是 bug,是 Canvas 渲染的固有特点。

我在接入的时候也担心过这个体验问题,但实际测试下来,交互手感已经做得相当接近原生了,没有那种“网页模拟器”的廉价感。尤其是单元格内编辑、拖拽填充、Ctrl+C/V 复制粘贴这些高频操作,流畅度完全能打。

2.3 插件体系:为什么说它天生适合 AI Agent 扩展

Univer 的插件系统是它区别于所有竞品的核心特征。它把功能模块切成一个个插件,比如核心表格引擎是@univerjs/sheets,公式引擎是@univerjs/engine-formula,复制粘贴是@univerjs/sheets-ui,甚至连撤销重做都是独立的@univerjs/undo-redo插件。你在初始化的时候按需加载,不需要的功能就根本不打包进去。

这个设计对 AI Agent 场景来说非常关键。我们知道,AI Agent 操作表格的时候,最怕的是操作不可控——它改错了单元格、删了不该删的公式、把格式搞乱了,你得能回滚。Univer 因为所有操作都走 Command 系统,每个 Command 都天然兼容撤销重做,所以 Agent 的操作可以被自动记录、自动回滚。这就给了我们一个非常重要的能力:AI 操作可以随时撤销。

更进一步,Univer 提供了beforeCommandExecute和afterCommandExecute这类拦截器接口。我们可以在命令执行前加一层自定义校验:比如某些单元格设了保护锁,AI 想改就会被拦截;比如某些列的数据必须符合规范格式,AI 写入的值不合法就直接拒绝。这层能力给了开发者极大的自由度,几乎可以针对任何业务场景做定制规则。

另外,Univer 官方设计了一个跟大模型配合的接口模式,叫“Univer 支持用户定义表格,然后让用户去填写一些单元格,其他的单元格用户无法修改”。翻译一下就是:表格的所有者可以指定某些单元格为可编辑区,其余全部锁定,AI Agent 或者普通用户只能在允许的范围内操作。

这个功能听起来简单,但对真实业务场景来说价值巨大。举个例子:你想做一个“员工报销申请单”,表格里员工只需要填“报销金额”“费用类型”“报销事由”这三列,其他信息(单号、状态、审批人、审批时间)由系统自动生成或由其他人填写。如果直接用普通表格,员工一不小心就能把整张表改得乱七八糟;用 Univer 的单元格保护功能,把不可编辑区域全部锁掉,员工打开表格就只能填那几个空单元格,填完了还不需要额外的提交按钮——这本质上就是一种“无代码表单引擎”。

更关键的是,这个“单元格保护”不是前端视觉上的锁,而是数据层的强制约束。即便是 AI Agent 调 API 去写单元格,只要该单元格被设为可读或锁定,写入操作就会被服务端拒绝。这一点对并发场景非常重要——如果两个 AI 同时操作同一份表格,一个想改 A 列,一个想改 B 列,它们之间不会因为单元格被锁定而互相冲突,也不会因为多端写入而出现脏数据。

2.4 自研公式引擎的能力边界

Univer 没有直接用浏览器的 eval 去跑公式,而是自研了一套完整的公式计算引擎。它支持包括 IF、VLOOKUP、SUMIFS、数组公式、跨表引用等 300+ 函数,基本覆盖了 Excel 的常用计算需求。

这里我想补充一点由公式引擎引申出来的 AI 场景思考。传统表格的数据计算逻辑是“写在某个单元格里的公式”,这种逻辑对 AI Agent 来说比较难理解——它需要先解析公式语法,才知道这个单元格的数值是怎么算出来的。Univer 的公式引擎提供了一种更友好的方式:通过@univerjs/engine-formula的 API,可以拿到公式的依赖树和计算链路。也就是说,AI 可以直接问表格“D15 这个值是怎么来的?”答案是“B15 × C15,而 B15 又来自另一个工作表”。这个能力叫“公式溯源”,对 AI 理解表格数据结构非常有帮助。

如果后续你想做更复杂的 Agent 场景,比如“让 AI 根据历史数据自动生成预测报表”,这套公式溯源能力可以把“AI 计算出来的结果”和“公式计算出来的结果”同时保留在表格里,且两者能互相验证,确保 AI 输出的数值不会破坏原有的数据链。

2.5 数据层设计对并发场景的友好

说到并发,这也是我在实际项目里最担心的问题之一。AI Agent 如果做成多用户同时使用,后端就得处理并发写入。传统表格方案里,并发写入的冲突检测基本得靠后端自己实现——你要给每一行每一列加版本号,要写 diff 算法,要处理复杂的事务回滚。

Univer 在这方面有现成的解决方案。它定义了统一的文档数据模型(Document Model),所有变更都走 CRDT 或者类似的操作日志模式,多人同时编辑时,操作会被序列化成命令流,同步到服务端后进行合并和执行。你可以把多端对表格的修改理解成 Git 里的多个分支提交,每个提交都是基于同一个基线的增量操作,服务端根据操作顺序和冲突规则决定最终结果。

对 AI Agent 来说,这个模型的好处是:并发写入不一定需要悲观锁。比如两个 Agent 同时执行任务,Agent A 改第 5 行的几个单元格,Agent B 改第 8 行的几个单元格,这两个操作天然没有冲突,可以并行提交。只有当两个 Agent 同时改同一个单元格时,才会触发冲突解决逻辑。我们的实践是把 Univer 的 WebSocket 同步服务部署在有自动扩展能力的节点上,单节点上跑几千个并发连接跟玩一样。

3. AI Agent 接入 Univer:核心能力解析与实操要点

3.1 搭建一个“可被 AI 操作”的表格应用

先说一个最基础的任务:把 Univer 嵌入到你的 Web 应用里,让它能跟 AI Agent 通信。

Univer 提供了一套基于 React 的组件库,也支持无框架引入。我这里以 React 为例,先搭建一个最简的表格应用,然后再挂接 AI 能力。项目环境要求:

  • Node.js 16.20 或以上版本
  • React 17 或以上版本(实践中 18/19 都兼容)
  • npm 或 pnpm 包管理器

先安装核心依赖:

npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui @univerjs/engine-formula @univerjs/engine-render

这里注意一个细节:Univer 的包名带有版本区分,一定要装配套版本,别混装。我踩过这个坑,拿着最新版的@univerjs/core配合一个老版本的@univerjs/sheets-ui,结果渲染层直接报错。

项目里还需要引入样式文件:

import '@univerjs/ui/lib/index.css';

初始化代码:

import { Univer } from '@univerjs/core'; import { UniverSheets } from '@univerjs/sheets'; import { UniverSheetsUI } from '@univerjs/sheets-ui'; import { UniverFormulaEngine } from '@univerjs/engine-formula'; import { UniverRenderEngine } from '@univerjs/engine-render'; const univer = new Univer(); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverFormulaEngine); univer.registerPlugin(UniverSheets); univer.registerPlugin(UniverSheetsUI);

注册完插件之后,创建单例:

import { LocaleType } from '@univerjs/core'; univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: 'AI Demo Sheet', locale: LocaleType.ZH_CN, styles: {}, worksheets: { 'sheet1': { name: '员工报销表', rowCount: 200, columnCount: 50, cellData: {} } } });

到这里,一个能在网页里运行的在线表格就算搭建完成了。你别小看这一步,它能跑通说明整个渲染链路已经打通。接下来才是重头戏——如何实现“用户只能改某些单元格”的权限控制,以及如何让 AI Agent 介入操作。

3.2 单元格保护的实现:用户只有少量可编辑区

如前面提到的,Univer 支持用户定义表格,然后让用户去填写某些单元格,其他单元格用户无法修改。我现在把它落到具体代码上,这是整个接入过程里最有价值的部分。

Univer 里实现单元格只读有两种方式:

方式一:利用工作表/单元格属性锁定(Protect)

这种方式需要在 worker 初始化时注册一个特殊插件,或者调用 Univer 的命令系统。不过现在 Univer 的前端 API 对“保护”这层逻辑封装得还不够统一,我更建议用下面这种方式——通过权限感知的拦截器来控制写入。

方式二:利用beforeCommandExecute拦截器 + 自定义权限校验函数

这是我在多个项目里用的方式,胜在灵活可控。我们先注册一个拦截器:

import { CommandType, ICommandService } from '@univerjs/core'; import { SetRangeValuesCommand } from '@univerjs/sheets'; const univer = new Univer(); const permissionMap = { 'sheet1': { editableRows: [2, 3, 4], // 第 3-5 行可以编辑 editableCols: [1, 2], // 第 2-3 列可以编辑 readOnly: false } }; univer.getCommandService().beforeCommandExecute((command) => { if (command.type === CommandType.MUTATION && command.id === SetRangeValuesCommand.id) { const { unitId, subUnitId, range, value } = command.params as any; const sheetId = subUnitId || 'sheet1'; const config = permissionMap[sheetId]; if (config?.readOnly) return false; // 检查需要写入的单元格是否在可编辑范围内 const { startRow, startColumn, endRow, endColumn } = range; for (let r = startRow; r <= endRow; r++) { for (let c = startColumn; c <= endColumn; c++) { const rowOk = config.editableRows.includes(r); const colOk = config.editableCols.includes(c); if (!rowOk || !colOk) { // 拒绝写入 return false; } } } return true; } return true; });

这个拦截器的逻辑很简单:用户想写任何单元格,都得先过一遍这个函数,如果发现任何一个单元格不在可编辑行列范围内,整次写入操作被拒绝。这个方案的好处是,它同时限制了 UI 操作和 AI 操作——因为所有操作最后都会发成 Command,Command 一定会触发拦截器。

但我要提醒一个细节:上面的代码只是前端层面的硬性校验,如果用户懂一点前端技巧,理论上可以绕过。真正生产级的权限控制,必须在服务端再做一层校验——比如 WebSocket 服务端接到 mutation 命令时,也要跑一遍同样的权限判断逻辑。Univer 的官方同步服务@univerjs/network和前端命令系统是配套的,服务端同样可以挂 command 拦截器。只有前端和服务端双层拦截,才算是真正的安全。

另外还有一个体验细节:当用户尝试编辑一个“只读”单元格时,默认的拦截会直接吞掉命令,用户会觉得“点了没反应”,体验很糟糕。更好的做法是:前端拦截时给一个 toast 提示“该单元格已被锁定,仅可查看”,然后在 UI 层将锁定单元格标灰。Univer 提供了单元格样式能力,初始化时可以将只读区域背景设为浅灰,这样用户一眼就能看出哪些区域不可编辑。

3.3 让 AI Agent 通过 API 操作表格

单元格只读只是权限的门槛,真正的重头戏是让 AI Agent 能以自然语言操作表格。Univer 本身没有内置“大模型接口”,它提供的是一个操作面——AI Agent 通过调用 Univer 的命令服务,把自然语言转成结构化指令。

我这里提供一个可落地的架构方案。整体链路分成三部分:

  1. Agent 前端:用户输入自然语言指令(比如“把 A2 单元格设为 100 并锁定 B 列”),前端把指令文本送给大模型 API,大模型返回结构化的工具调用参数。
  2. AI Function Call 层:把大模型返回的结果映射为 Univer 的 Command。这一步是核心——大模型并不会直接操作 Univer,而是输出类似{"action": "setCellValue", "range": "A2", "value": 100}的 JSON,前端再用这些 JSON 去拼装 Univer 命令。
  3. 执行与回滚:命令执行后,把结果回传为大模型可读的文本摘要,并记录到操作日志里,支持随时撤销。

具体代码结构大致如下:

type AIAction = | { action: 'setCellValue'; range: string; value: string | number } | { action: 'getCellValue'; range: string } | { action: 'setFormula'; range: string; formula: string } | { action: 'lockRange'; range: string } | { action: 'unlockRange'; range: string }; async function handleAIAction(action: AIAction): Promise<string> { const cellRange = parseRange(action.range); // 把 'A2' 转为 {row: 1, col: 0} switch (action.action) { case 'setCellValue': { const command = { type: CommandType.MUTATION, id: SetRangeValuesCommand.id, params: { unitId: currentUnitId, subUnitId: currentSheetId, range: { startRow: cellRange.row, startColumn: cellRange.col, endRow: cellRange.row, endColumn: cellRange.col }, value: { [cellRange.row]: { [cellRange.col]: { v: action.value } } } } }; await univer.getCommandService().executeCommand(command); return `已设置 ${action.range} 为 ${action.value}`; } case 'getCellValue': { const value = univer.getCurrentUnit()?.getSheetData()?.getCell(cellRange.row, cellRange.col)?.v; return `${action.range} 当前值为 ${value}`; } case 'lockRange': { // 调用拦截器更新 permissionMap permissionMap['sheet1'].editableRows = permissionMap['sheet1'].editableRows.filter(r => r !== cellRange.row); return `已将 ${action.range} 锁定`; } // ... 其他 action 类似 } }

上面的代码只是一个最小骨架,但它演示了关键的模式:AI Agent 不直接触摸 DOM,而是通过命令服务控制表格数据。这样做的好处是:

  • 每次操作都会被 command 系统记录,撤销重做天然可用;
  • 权限拦截器对 AI 无效(AI 没有人工用户那样的“视觉约束”,必须由代码强制);
  • 扩展性极好——你新增一个动作,只需要加一个 case,不需要改表格底层逻辑。

3.4 一个完整的 Agent 调用场景:填表 + 计算 + 汇总

把上面的架构串起来,我们做一个完整的例子。假设 Alice 有一个收货登记表,表格结构如下:

产品编码(只读)产品名称(只读)到货数量(可编辑)到货日期(可编辑)验收状态(可编辑)备注

产品编码和产品名称是从 ERP 同步的,用户不能改;到货数量、日期、状态、备注由用户填写,或者由 AI 代理来填。AI 的 prompt 可以是:

你是仓库管理员助手。这张表格中第 1 行是标题,第 2-10 行是需要填写的到货记录。请根据用户的自然语言指令填入对应单元格,每次操作前先检查该单元格是否可编辑。

用户对 AI 说:“货号 B2001 的到货数量填 300,日期填今天,状态改成待检。”

Agent 先把这条指令发给大模型,大模型输出:

{ "action": "setCellValue", "range": "B3", "value": 300 }

等等,这个 JSON 里其实缺失了一个点——Agent 怎么知道“货号 B2001”对应哪一行?因为表格里找不到整个表格的结构信息给大模型。

因此在实际项目中,我们还需要一个表格上下文读取的步骤。在把表格内容发给大模型前,Agent 会先把表格的只读列和可编辑列描述为一个“schema”,比如:

{ "columns": [ {"name": "产品编码", "colIndex": 0, "readOnly": true}, {"name": "产品名称", "colIndex": 1, "readOnly": true}, {"name": "到货数量", "colIndex": 2, "readOnly": false}, {"name": "到货日期", "colIndex": 3, "readOnly": false}, {"name": "验收状态", "colIndex": 4, "readOnly": false}, {"name": "备注", "colIndex": 5, "readOnly": true} ] }

然后结合表格原始数据一起发给大模型:

{ "data": [ ["B2001", "稳压芯片", "", "", "", ""], ["B2002", "电容套件", "", "", "", ""] ] }

大模型根据这些信息查找到 B2001 在第 1 行(索引 1),结合可编辑列范围,生成正确目标坐标。最终写入命令指向(row: 1, col: 2)。

这一步的实操经验是:千万别把整个 200 行的表格一股脑发给大模型,上下文窗口很容易爆。我们在生产项目里只发送“当前视口可见行+标题行”的子集,或者只发送包含用户查询的关键行,这样 token 成本低、响应快、准确率还高。

3.5 Agent 操作的额外安全设计:操作白名单

除了权限拦截,我还建议做一层“操作白名单”。在 AI 场景下,AI 的操作频率远高于人工,而且它的行为不受视觉限制——如果大模型对某一列的理解出偏差,它可能一口气把几千个单元格都改掉,而人工操作时至少能先看看结果。

我在项目里的做法是给 AI 操作加上“范围限制”:

  • 单次 AI 操作最多影响 100 个单元格,超了直接拒绝并返回提示;
  • 非空单元格的覆盖操作必须二次确认(可以通过一个模拟的confirm支持,也可以在 prompt 里要求“如果会覆盖已有内容,请先询问用户”);
  • 所有 AI 操作都写入操作日志,每次操作前后都可以做快照对比,保证可回滚。

这些规则不是拿文档里抄的,是真实项目里被 AI “乱改”搞怕了之后补上的。当前的模型对表格结构的理解还不稳定,尤其面对多级表头、合并单元格、隐藏列时,很容易算错行列坐标。白名单机制至少能把损失控制在可接受范围内。

4. 接入实操:从 0 到 1 完成一个 AI 智能填表应用

4.1 环境准备与依赖安装

这个环节我直接给出完整流程,避免大家在版本问题上浪费时间。

第一步,确认 Node 版本。Univer 的构建依赖较新的 ES 特性,建议 Node 18+,我用的是 20.11。第二步,初始化一个 Vite + React + TypeScript 项目:

npm create vite@latest univer-ai-demo -- --template react-ts cd univer-ai-demo npm install

第三步,安装 Univer 全家桶。从实践来看,目前稳定版本是0.2.x左右(具体以官方 npm 仓库为准),安装时最好锁定主版本:

npm install @univerjs/core@latest \ @univerjs/sheets@latest \ @univerjs/sheets-ui@latest \ @univerjs/ui@latest \ @univerjs/engine-formula@latest \ @univerjs/engine-render@latest

注意:不同包的版本必须一致,Univer 官方用的是统一版本号策略,我早期混装 0.1.x 和 0.2.x 导致“Cannot read properties of undefined”报错,折腾了一下午才排查出来。

第四步,在src/App.tsx里写入初始化逻辑。完整代码如下:

import React, { useEffect, useRef } from 'react'; import { Univer, LocaleType, UniverInstanceType } from '@univerjs/core'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverFormulaEngine } from '@univerjs/engine-formula'; import { UniverSheets } from '@univerjs/sheets'; import { UniverSheetsUI } from '@univerjs/sheets-ui'; function App() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const univer = new Univer(); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverFormulaEngine); univer.registerPlugin(UniverSheets); univer.registerPlugin(UniverSheetsUI); // 创建一张工作表 univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: '物料到货登记表', locale: LocaleType.ZH_CN, styles: {}, worksheets: { 'sheet1': { name: '到货记录', rowCount: 500, columnCount: 20, cellData: { // 第 1 行是标题 0: { 0: { v: '产品编码' }, 1: { v: '产品名称' }, 2: { v: '到货数量' }, 3: { v: '到货日期' }, 4: { v: '验收状态' }, 5: { v: '备注' } }, // 示例预置数据 1: { 0: { v: 'B2001' }, 1: { v: '稳压芯片' } }, 2: { 0: { v: 'B2002' }, 1: { v: '电容套件' } } } } } }); return () => { univer.dispose(); }; }, []); return ( <div ref={containerRef} style={{ width: '100%', height: '800px' }} /> ); } export default App;

这个效果已经能展示:页面里会出现一个完整可编辑的表格,标题和数据都正常渲染。接下来我们给它加上只读控制。

4.2 配置可编辑区域:把表格变成“智能填单控件”

现在我们来落实“用户只允许填特定单元格”的需求。结合上面 3.2 节的拦截器方案,我们需要做两件事:

  1. 在初始化时,把只读区域的单元格背景设为浅灰色,让用户在视觉上知道这里不能改;
  2. 注册beforeCommandExecute拦截器,强制在数据层拒绝写入。

第一步,修改 cellData 初始化,加上样式:

0: { 0: { v: '产品编码', s: 'gray_bg' }, 1: { v: '产品名称', s: 'gray_bg' }, 5: { v: '备注', s: 'gray_bg' } }, 1: { 0: { v: 'B2001', s: 'gray_bg' }, 1: { v: '稳压芯片', s: 'gray_bg' } }

同时在创建 unit 时注册样式:

styles: { gray_bg: { bg: { rgb: '#f3f4f6' }, cl: { rgb: '#9ca3af' } } }

这样只读列的背景是浅灰色,可编辑列保持白色,界面直观。

第二步,在App.tsx的 useEffect 里加入拦截逻辑:

import { CommandType, ICommandService } from '@univerjs/core'; import { SetRangeValuesCommand } from '@univerjs/sheets'; // 定义可编辑的行列范围 const editableConfig = { sheet1: { editableRows: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], // 第 4 行到第 15 行,具体按需求 editableCols: [2, 3, 4] // 到货数量、到货日期、验收状态 } }; univer.getCommandService().beforeCommandExecute((command) => { if (command.id === SetRangeValuesCommand.id) { const { subUnitId, range } = command.params as any; const sheetName = subUnitId || 'sheet1'; const config = editableConfig[sheetName]; if (!config) return true; const { startRow, startColumn, endRow, endColumn } = range; for (let r = startRow; r <= endRow; r++) { for (let c = startColumn; c <= endColumn; c++) { if (!config.editableRows.includes(r) || !config.editableCols.includes(c)) { console.warn(`[权限拦截] 单元格 (${r}, ${c}) 不可编辑`); return false; } } } } return true; });

这里有一个我踩过的坑要特别提醒:beforeCommandExecute对“先选中多个单元格再批量输入”的场景会一次执行一个大 range。用户可能会选中一整列然后粘贴,这时候range覆盖的单元格数量会非常大。如果拦截器对每个单元格都做includes判断,性能会有明显损耗;大表格场景下甚至会卡顿几秒。

优化方式是:把editableRows和editableCols改成区间集合,或者直接比较“请求范围是否完全被可编辑范围的并集覆盖”。更简单的是先判断两个矩形是否相交,再逐单元格校验——这样可以避免对大量无关单元格做无谓判断。

第三步:如果用户在 UI 上试图编辑只读单元格,默认交互是“命令被拦截,无任何反应”。我建议再加上一个视觉反馈。在拦截器里返回 false 之前,同时触发一个 toast:

import { message } from 'antd'; // 在拦截器命中时调用 message.warning('该单元格已锁定,仅可查看');

实测下来,这种“直接拦截 + 文案提示”的组合,用户学习成本最低——很快就能搞明白哪些能填哪些不能填。

4.3 对接 AI Agent:从自然语言到表格操作

现在这个表格已经能被用户手动填写,且只读区域无法被修改了。接下来再加一个聊天输入框,让 AI 也能直接操作这张表。

这里我建议直接用 OpenRouter 或者 OpenAI 兼容的 Function Calling 接口,流程是:

  1. 用户在聊天窗口输入:“把 B2001 的到货数量改为 350,状态改为待检。”
  2. 前端请求大模型接口,并在请求里带上一个“工具定义”,比如:
{ "name": "edit_sheet", "description": "修改当前电子表格的单元格内容。调用前请先阅读表格结构,判断目标单元格是否可编辑。", "parameters": { "type": "object", "properties": { "sheetName": { "type": "string", "description": "工作表名称,默认 sheet1" }, "rowIndex": { "type": "integer", "description": "0 基的行索引" }, "colIndex": { "type": "integer", "description": "0 基的列索引" }, "value": { "type": "string", "description": "要写入的内容" } }, "required": ["rowIndex", "colIndex", "value"] } }
  1. 大模型返回一个 tool call:
{ "name": "edit_sheet", "arguments": "{ \"rowIndex\": 1, \"colIndex\": 2, \"value\": \"350\" }" }
  1. 前端解析这个 tool call,把它转成SetRangeValuesCommand参数并执行:
const args = JSON.parse(arguments); await univer.getCommandService().executeCommand({ type: CommandType.MUTATION, id: SetRangeValuesCommand.id, params: { unitId: currentUnitId, subUnitId: args.sheetName || 'sheet1', range: { startRow: args.rowIndex, startColumn: args.colIndex, endRow: args.rowIndex, endColumn: args.colIndex }, value: { [args.rowIndex]: { [args.colIndex]: { v: args.value } } } } });
  1. 执行成功后,把结果文本回传给大模型:“已写入成功”,大模型再组织语言回复用户:“已经帮您把 B2001 的到货数量更新为 350,验收状态设为待检。”

这套流程看起来不复杂,但落地的坑不少。我拣几个重要的说。

第一个坑:大模型算行号是从 0 还是从 1 开始?经验是:人工用户看到的表格,第一行是 Excel 行号 1,但 Univer 内部 API 的行索引是从 0 开始。大模型若没有在 prompt 里明确被告知,很容易把“第 1 行”写成 rowIndex=1,结果写到第二行去了。我的做法是在发给大模型的系统提示里写清楚:表格行号从 1 开始,内部索引从 0 开始,换算关系为内部索引 = 表格行号 - 1。

第二个坑:大模型对“只读单元格”的判断。如果你的工具定义里没有说明哪些单元格不可变,AI 会尝试修改只读列,然后被拦截器拒绝。为了减少无效调用,我会在每次请求前先动态生成一份“表格 schema”,把它放到系统提示里:

function generateSheetSchema() { return [ '产品编码(col 0) 只读', '产品名称(col 1) 只读', '到货数量(col 2) 可编辑', '到货日期(col 3) 可编辑', '验收状态(col 4) 可编辑', '备注(col 5) 只读' ].join('\n'); }

这样大模型在规划操作时就知道哪些不要碰,准确性会提升一个台阶。

第三个坑:错误提示要结构化。当权限拦截器拒绝写入时,前端不能只返回一个泛泛的“失败”,应该把被拒绝的单元格坐标和原因返回给大模型:

// 拦截器内记录原因 const deniedCells: string[] = []; // ... 推送进数组 return { success: false, deniedCells: deniedCells };

这样大模型收到失败信息后,会自主调整策略,比如改问用户“该单元格只读,需要我改别的吗?”。如果没有结构化错误信息,大模型通常是瞎猜,可能连续几次调用都撞同一个坑,浪费 token 还让用户觉得 AI 很蠢。

4.4 服务端鉴权与并发限制

如果你只是本地演示,前端做点拦截也够了;但要上生产,必须考虑服务端。这里有两种做法,我分别说下适用场景。

做法一:纯前端 + 后端 AI 代理

你的表格数据只用于展示和轻量协作,同事之间不要求实时同步。这种情况下,可以把所有写入操作集中在后端一个 API 上。前端调用这个 API 时携带用户身份 token,后端先校验权限,再校验目标单元格是否在可编辑白名单里,最后执行SetRangeValuesCommand。这种方式简单直接,适合表格数据量不大、并发不高的场景。

做法二:全量 Univer WebSocket 同步

如果你的业务需要多人同时协同编辑,前端和后端都部署完整的 Univer 服务。前端通过 WebSocket 连接到一个“文档同步服务”,这个服务是 Univer 官方维护的一个 Node.js 模块。它负责接收客户端的 mutation 命令,做冲突合并和持久化。你需要在这个服务里挂一个中间件,专门检查 AI 或用户的写入权限。

关于 AI Agent 的并发,我在实际项目中得出的经验是:不要让两个 Agent 同时写同一份表格。即使技术上能支持,也会因为操作顺序问题给用户带来困惑。更稳妥的做法是加一个“表单操作锁”:在同一时刻,只能有一个 Agent 对同一张表进行写操作;其他 Agent 的写请求排队等待。这个锁不需要复杂设计,用 Redis 的 SETNX 命令就能实现,过期时间设置为 30 秒,到期后自动释放。

为什么锁能大大降低复杂度?因为 Univer 的 Command 系统本身是线性执行的,但多个客户端并发提交 mutation 时,服务端需要合并操作。合并策略在遇到“同一单元格被两个 Agent 修改”的情况时,通常默认取后写入者胜。你无法控制大模型之间谁先谁后,但你可以用锁保证同一时刻只有一个 Agent 在写,不给冲突机会。

5. 常见问题与排查技巧实录

我在接入 Univer 的过程中,实际踩过不少坑,有些是文档里根本没有的。我把高频问题整理成速查表,每个问题都附上我当时排查的思路。

问题现象可能原因解决方案
Cannot read properties of undefined (reading 'xxx')Univer 各包版本不一致,或者插件注册顺序不对用统一版本号,按RenderEngine → FormulaEngine → Sheets → SheetsUI顺序注册
表格初始化后一片空白没有创建UniverInstanceType.UNIVER_SHEET,或者容器宽度高度为 0检查容器有没有非零宽高,createUnit 一定要调用
设置了只读拦截器,但用户还是能改单元格拦截器只拦截了SetRangeValuesCommand,但用户用的是粘贴、填充、拖拽等命令需要同时拦截PasteCommand、FillCommand、SetWorkSheetRowHeightCommand等所有 mutation 命令
AI 调用executeCommand后返回成功,但表格没变化Command 注册的 unitId 与实际 unitId 不一致用univer.getCurrentUnit()获取当前 unit 的 ID 并传参
小表格没问题,大表格操作卡顿拦截器循环里用了includes,复杂度 O(n)用 Set 或区间判断代替数组 includes;先矩形相交判断再逐格判断
多人协作时刷新页面,数据丢失没有配置服务端持久化或数据库存储需要把 WebSocket 同步服务和数据库对接,或者用官方提供的@univerjs/sheets-api自定义存储
中文输入法敲字时,候选框不显示Canvas 渲染不支持部分 IME 弹层升级到最新版,新版接入了 IME 适配;如果还不支持,检查浏览器版本

这里我想重点展开两个问题,因为它们的排查过程比较有代表性。

第一个:“只读拦截器失效”。最初的代码只拦截了SetRangeValuesCommand,我以为这就够了。结果用户选中一个只读单元格,直接 Ctrl+V 粘贴一大段文本,竟然写进去了。排查时发现,Univer 的粘贴操作走的是另一个命令 ID,比如PasteCommand或者MOVE_RANGE之类的 mutation,根本不会经过SetRangeValuesCommand的拦截器。这提醒我们:Univer 的权限控制不能只拦截“单格赋值”,你得把所有能改变数据的 mutation 命令都纳入检查范围。一个比较稳妥的做法是监听所有 mutation 类型的命令,然后统一调用一个权限评估函数。

第二:“大表格卡顿”。当表格数据量超过 5000 行、50 列时,拦截器里逐单元格循环的性能问题会非常明显。原因很简单——某个 range 可能覆盖 1000 个单元格,你都要去遍历校验。我用区间集合优化后,把可编辑行和可编辑列各做成一个区间列表,每次校验时先对区间做一次二分查找,性能从 O(n) 降到 O(log n),至少快了一个数量级。如果你的场景真的需要频繁并行校验,还可以考虑 Web Worker,把校验逻辑挪到 worker 线程,避免阻塞 UI。

6. 最后的实操总结与个人体会

这篇文章从 Univer 的架构设计聊到了实际接入,从单元格只读控制聊到了 AI Agent 工具调用,最后又整理了排查经验。我不打算再泛泛总结一遍,毕竟上面每一段都有可以直接抄走的代码和思路。我只说三点真实体会。

第一,Univer 是为“应用嵌入”而生的 SDK,不是给普通用户用的独立产品。这意味着如果你只想要一个给员工用的表格工具,它的学习成本在前期比较高;但如果你是要把它嵌进你自己的业务系统,这种“所有东西都可控”的底层设计,收益是长期的。

第二,AI Agent 接入表格,安全策略必须前置设计。业内最常见的翻车事故,就是开发者在跑通 Agent 操作后,立刻放开了权限,结果 AI 在测试环境把数据表清空了。权限拦截器、操作白名单、操作日志、撤销回滚,这四个东西一个都不能少,而且要在接入早期就做进去。后期再补,你会面临改一处牵连一大片的痛苦。

第三,大模型和表格的结构对齐远比想象中难。即便 Univer 提供了完整的 API,你依然需要自己做“表结构 schema → prompt”转换、行列索引换算、表格子集发送。这些工作看起来琐碎,却直接决定 AI 操作表格的成功率。

最后分享一个小技巧:调试 AI 操作表格时,建议把 Univer 的 Command 监听器打开,打印每一次命令的完整 JSON。我见过太多开发者盯着大模型的返回结果分析问题,结果等了半天才发现是前端 Command 拼装错了。数据从 AI 到表格的每一步都可视化,问题定位速度能快十倍。

Univer 这个项目还在快速迭代中,技术方案选型的时候不要被“开源”两个字冲昏头脑。先拿一个最小 demo 跑通流程,再评估它跟你的业务场景匹配度。如果你正打算给产品加一个“AI 可操作”的表格能力,希望这篇内容能给你省点时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进