先说个真实场景。行政部的同事每个月月底都要收集各部门的数据上报,以前的方式基本上就是:做一张Excel模板,发到钉钉群里,让大家下载、填写、再回传。这个流程听上去没什么问题,真跑起来全是坑——有人改乱了模板格式,有人把单位填成了元又有人填成了万元,还有人压根没下载最新版模板,版本来回覆盖,月底对账对到崩溃。
后来我们干脆在内部系统里自己做了一套“填报应用”,其中核心模块就是自由报表填报。简单说,就是让管理员在网页上像作图一样把表格模板画出来,各部门同事打开链接直接在线填数,填完提交,数据自动汇总导出,全程不用碰Excel。这篇文章就围绕这个功能展开,重点讲第一阶段的整体设计和数据模型怎么搭。如果你也在做类似的系统,或者正打算给团队搭一套线上报表工具,那这篇应该能帮你避掉不少坑。
1. 自由报表填报是什么,解决了什么问题
1.1 从固定表单到自由表格
很多系统里都有表单填报功能,比如请假申请、报销单,这些都属于固定表单——字段是死的,张三填姓名,李四填工号,每个人的填写内容完全一样。这种场景用经典的表单引擎就够了,拖几个输入框、几个下拉框,存进数据库,完事。
但到了真正的数据收集场景,固定表单就完全不够用了。为什么?因为业务表格的格式五花八门,几乎每个月都在变。举个例子,公司做月度预算执行表,有的部门报3个科目,有的部门报15个科目,行数不固定;有的表头要合并两行,有的要合并四列;有的单元格是纯文本备注,有的必须填数字,还有的要下拉选择“已执行/未执行”。你要是拿一个固定表单去套这种需求,每次表结构一变,开发就得跟着改数据库、改前端、改接口,改完还要重新发版。
自由报表填报解决的核心问题,就是“格式归用户,功能归平台”。用户只负责在页面上画出自己想要的表格结构,平台负责把这套结构保存下来,然后渲染成可填写、可校验、可提交、可汇总的在线表格。
1.2 自由报表填报的三个核心环节
我把这套系统拆成了三个核心环节,任何一个环节缺了,整个链路都是断的:
- 模板设计:管理员或操作员在网页上创建一张空白表格,设置行列数、合并单元格、填表头、设置单元格类型和校验规则,最后发布。
- 数据填报:填报人打开链接,看到一张和后台设计完全一致的表格,在可编辑单元格里填数,支持暂存、校验、提交。
- 数据汇总:所有填报人的提交记录汇总到后台,管理员可以查看明细、导出Excel、做统计。
这三个环节对应了三种完全不同的用户角色和交互形态,也决定了系统从一开始就要分模块设计,而不是揉成一锅粥。
1.3 谁在用这套系统,角色怎么分
我这边的实际使用场景是单位内部的数据收集,涉及的角色就三类:
- 模板设计者:通常是行政、运营、财务这样有业务背景、但没有任何开发经验的同事。他们需要一个可视化的设计器,而不是对着JSON写配置。
- 填报人:各部门对接人,他们的诉求就两个字:简单。打开链接能填、能存、能提交,不要有学习成本。
- 系统管理员:负责维护人员权限、查看填报进度、导出结果。他们最关心的是全过程可追踪,谁没报、谁报了错的都能一眼看到。
这三类角色决定了,自由报表填报这个功能不是给程序员用的内部工具,而是一个面向普通用户的高可用产品,所以在交互体验和容错处理上必须花大力气。
2. 整体架构与设计思路拆解
2.1 页面闭环:设计、填报、汇总三条线
第一版我没做太复杂的后台管理系统,就做了三个页面,对应前面说的三个核心环节:
- 模板设计器页:核心是一个可编辑表格,支持右键菜单、单元格选中、合并、类型配置。左侧是属性面板,选中哪个单元格,右侧就显示它的属性。
- 填报工作台页:核心是一个只读表格模板 + 可编辑单元格。页面上方显示报表名称、填报人、提交状态,下方是表格主体。提交之后表格整体进入只读模式。
- 汇总管理页:表格列表 + 填报记录的表格明细展示,支持按填报人筛选、按状态筛选、导出Excel。
我刻意没有做复杂的菜单体系,一进来就是报表列表,选中报表看是去设计还是去填报,路径非常短。实际使用下来,行政同事基本不用培训就能上手。
2.2 技术选型:渲染层、存储层、接口层
这是最开始我比较纠结的部分,因为在线表格这个方向已经有很多现成的轮子了。
前端选型,我认真比较过三套方案:纯自研用Canvas绘制、直接上桌面级表格引擎(比如SpreadJS这类商业组件)、用开源表格库(Luckysheet、Handsontable)做二次开发。
我的最终方案是自研DOM表格,基于contenteditable或输入框做单元格编辑。为什么没选现成的?一是商业组件贵,按开发席位算一年下来不是小数目;二是开源表格库的功能重心在“类Excel操作”,比如公式引擎、图表、条件格式,而我的需求其实很聚焦——就是让用户填几个数、选几个选项,不需要完整的Excel能力。引入一个庞大的组件库,光是样式冲突和学习成本就能拖慢不止一周的进度。
后端存储,我直接采用了JSON字段存全部结构。一张报表模板的所有行、列、合并单元格、样式信息、校验规则,全部序列化成一个JSON字符串存数据库。填报的数据同样也存成JSON快照,而不是拆成一行一行的明细表。这个方案看起来不够“正规”,但对这种表格格式不确定、结构多变的场景,反而是最灵活、最省事的方案。
2.3 为什么第一阶段优先做数据模型
很多团队做这类系统,上来就画页面、调接口,结果做到一半发现数据结构设计错了,又大规模返工。
我的顺序是先定义清楚三个JSON模型:模板模型、填报数据模型、汇总视图模型。把这三个模型反复推演和评审,确认能覆盖所有已知的业务表格需求之后,才动手写设计器页面。
这么做的好处是,等真正写代码的时候,前后端接口基本就是模型之间的转换,不会有结构性的撕扯。而且模板设计器做出来的时候,只要模型是对的,页面上就绝对不会出现数据类型对不上的低级bug。
3. 核心数据模型走读(第一阶段重点)
3.1 报表模板模型
模板是整个自由报表填报的地基。你要是一张表的行列数、合并区域、单元格属性都没存对,后面所有填报、汇总都是错的。
我的报表模板表结构大致是这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 模板主键ID |
| name | varchar(100) | 报表名称,比如“2024年7月预算执行表” |
| code | varchar(50) | 报表编码,用于对外链接 |
| status | tinyint | 状态:0草稿、1已发布、2已停用 |
| version | int | 版本号,每保存一次+1 |
| config | json | 完整模板配置JSON |
| creator_id | bigint | 创建人ID |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
核心是config这个JSON字段,它长这样:
{ "rowCount": 15, "colCount": 8, "defaultRowHeight": 32, "defaultColWidth": 100, "mergedCells": [ { "row": 0, "col": 0, "rowspan": 2, "colspan": 2 } ], "cells": { "0_0": { "value": "部门", "type": "text", "readonly": true, "align": "center", "fontWeight": "bold", "validate": null }, "0_1": { "value": "", "type": "number", "readonly": false, "align": "left", "fontWeight": "normal", "validate": { "required": true, "min": 0, "max": 99999999 } } } }这里有一个关键设计:单元格不是存数组,而是存Map,键是“行号_列号”的字符串。为什么这么做?因为JSON对象天然支持按坐标O(1)查找,而数组必须遍历。对于一个15行8列的小表无所谓,但接口返回后前端要频繁按坐标取单元格内容,用Map存效率高得多。
3.2 单元格类型与校验规则
单元格的type字段决定了填报端用什么组件来编辑。第一阶段我只做了四种类型,没有做公式:
- text:纯文本输入框,最多500字
- number:数字输入框,支持设置范围
- date:日期选择器,存储格式统一为yyyy-MM-dd
- select:下拉选择,可选值存在validate.options里
校验规则挂在单元格上,而不是挂在列上。
"validate": { "required": true, "type": "number", "min": 0, "max": 100000, "message": "金额不能小于0或大于10万" }实际踩过的坑是:校验必须分两层。前端校验是为了体验好,用户填了不合法的数据立刻红框提示;后端校验是为了安全,接口层必须再校验一次。前端可以被绕过,后端才是最后一道防线。
3.3 填报数据模型
填报数据是另一张表,和模板分开存。这里有一个非常重要的原则:填报数据必须存快照,不能实时关联模板。
比如7月的预算表模板是15行,到了9月你给模板加了一行,那7月已经提交的数据绝对不能跟着变。所以每一条填报记录要保存一份“当时填写时”的完整数据快照。
我的填报数据表结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 填报记录ID |
| template_id | bigint | 模板ID |
| template_version | int | 填报时的模板版本号 |
| user_id | bigint | 填报人ID |
| status | tinyint | 0草稿、1已提交、2已撤回 |
| data | json | 填报数据快照 |
| submit_time | datetime | 提交时间 |
| version | int | 乐观锁版本号 |
填报数据快照的JSON长这样:
{ "0_1": "市场部", "0_2": "50000", "1_1": "市场部7月投放费用", "1_2": "32000" }这个结构看起来简单,但有一个隐藏要点:填报数据必须按单元格坐标存实际填写的内容,不能只存有值的部分然后靠模板默认值来补空。因为模板一发布就不能改,但万一有特殊原因改了,历史数据必须完整自洽。所有单元格内容都显式存,是最稳妥的方式。
3.4 版本隔离:模板版本和填报记录的分手逻辑
对自由报表来说,版本管理是迟早要面对的问题。我第一期就想清楚了这条规则:模板发布后不可编辑,只能复制出新版本。
比如“2024年7月预算执行表”已经发布了,你发现表头写错了一个字,不能直接改,而是复制出一份新模板,改完重新发布。旧的填报记录依然关联旧版本,新填报面向新版本。
从数据库上看就是record表同时存template_id和template_version,即使模板被复制导致template_id变了,也能通过version来追踪是哪一批人的填报记录。
这个规则在业务上有时候会让人觉得烦,但从长远看绝对是省事的。真实业务里最怕的就是模板改了,历史数据全乱了,大家在群里吵三天三夜都不够。
4. 模板设计器实操要点
4.1 表格初始化:行列管理有讲究
第一版设计器里,我初始化给了一张15行8列的空表,打开就能编辑。这个默认值不是拍脑袋定的,而是统计了业务侧现有20多张Excel表格的行列数,取的一个中间值。
行列操作上,最核心的是插入、删除、合并。我建议前期不要做拆分单元格——不要问为什么,拆分的逻辑复杂度至少是合并的两倍以上,第一期完全没必要。
插入行的逻辑是:在选中的行上方插入一行,所有从该行开始的单元格坐标都下移,所有合并区域只要起始行大于等于插入行的位置,也都要跟着调整。
这部分如果不用合适的方案做,后期处理合并单元格时会非常痛苦。我建议最好抽象一个adjustMerges(row, col, offsetType, offsetVal)的统一方法,插入、删除行列都复用这一套逻辑。
4.2 合并单元格:最容易翻车的设计
合并单元格是我第一期踩坑最多的环节,说它是第一个技术难点也不为过。
设计思路是这样的:合并区域记录在主配置的mergedCells数组里,每个区域只记录左上角单元格的坐标和跨度。然后渲染的时候,被合并掉的单元格不渲染,只渲染左上角的那个主单元格,跨行列撑开。
但这里有个细节:合并单元格的保护问题。你的单元格Map里存了比如"0_1"的值,但它实际上是被合并掉的占位格,填报用户不应该能编辑它。怎么处理?
我的解法是,在加载模板的时候,后端把所有不在mergedCells主单元格坐标上的单元格标记为disabled: true。填报端一拿到这个标记,就知道这个格子不能点、不能编辑。
合并区域的坐标错位问题一定要写单元测试。尤其是连续合并多个相邻区域时,比如第一行合并了A1到C1,第二行合并了D2到F2,如果调整逻辑没写对,第二行的区域会被第一行的合并且顶飞。
4.3 单元格类型与默认值设置
单元格属性面板我做了这几项:
- 内容对齐:左对齐、居中、右对齐
- 字体加粗:用于表头
- 单元格类型:文本、数字、日期、下拉
- 是否必填
- 数字范围最小/最大值
- 下拉选项(动态输入)
- 只读模式
实际开发中有一个很容易忽略的交互:单元格类型的修改时机。比如你选中了一个已经有文本内容的文本单元格,把类型改成数字,那这个单元格的旧文本怎么处理?
我建议直接清空旧内容。因为类型变了代表填值的定义变了,旧值大概率不合法。宁可让用户重新填,也不要保存一个类型和内容不匹配的数据。
选择下拉选项方面,我给select类型留的是Enter分隔的文本域,用户一行一个选项,提交后自动转成数组。这个交互比弹窗录入列表友好很多,尤其适合不熟悉电脑操作的行政用户。
4.4 模板配色和导出
很多人可能想不到,模板设计器里看起来最不重要的“表头颜色”,反而是用户反馈最多的。
业务侧的Excel模板,表头几乎都有底色,常见的是浅蓝色或浅灰色,字体加粗。我在模板配置里直接加了headerRow和headerStyle两个字段,设计器里勾选“首行作为表头”,自动应用表头全局样式。
导出Excel我用的是前端库直接生成文件的方式,保留行列宽和合并区域。这里踩过一个小坑:Excel的最大列数是256列(XLS格式),虽然现在XLSX支持更多,但老用户用WPS打开时依然兼容性参差不齐。所以我的列数上限设成了50,远远超过业务需求,但不会触发兼容问题。
5. 填报端与提交流程实现
5.1 填报页的数据加载与渲染
填报端拿到的数据大概是这样的:
{ "templateConfig": { "...": "模板完整配置" }, "myData": { "0_1": "市场部", "1_2": "32000" }, "status": "draft" }渲染逻辑的核心就是两件事:第一,根据templateConfig里的mergedCells和cells生成一张带合并区域的表格;第二,把myData里的值塞回对应的单元格坐标中。
拉取数据是整体拉取,不做分页。实测下行列控制在200行50列以内,接口返回的JSON也就几KB,渲染一次在100毫秒以内,完全够用。
5.2 暂存、保存、提交三态流转
第一阶段我设计了三个状态,有点类似草稿箱逻辑:
- 暂存:用户填一半关掉页面,数据保存在后端,下次打开继续填。暂存可以无限次。
- 保存:保存比暂存多做了一遍前端校验,不合法的单元格直接红框提示,保存后依然可以继续编辑。
- 提交:提交是最重的操作。先全量校验,全部通过才允许提交,提交后记录状态变为已提交,整个表格进入只读,用户不能再改动。
这三个状态的实现其实只是接口status字段的不同,但前端交互完全不同。提交按钮按下时,我加了一个二次确认弹窗,明确提示“提交后不可修改”,很多用户第一次点提交时会犹豫一下,这个设计从使用反馈来看很成功。
5.3 并发控制:版本号乐观锁
填报场景并发量不高,但一定要防止两个人同时打开同一张表格填报,后者覆盖前者的数据。
我用的是简单的乐观锁。每条填报记录有一个version字段,保存和提交时都带这个字段,后端执行:
UPDATE report_data SET data = ?, version = version + 1 WHERE id = ? AND version = ?受影响行数为0就说明冲突了,返回“该报表数据已被他人更新,请刷新后重试”,前端弹窗提示。第一期没做复杂的多人协同编辑,实际业务场景一个人填一张表就够了,没必要引入WebSocket和冲突合并这些复杂机制。
5.4 校验时机与用户体验
填报端的校验我分成两种触发时机:
- 失焦校验:填完一个单元格鼠标点到别处时,立刻校验这个单元格。不需要弹窗,直接在单元格右下角显示红角标,鼠标悬停看错误原因。
- 提交校验:把所有非法单元格标红,并在页面顶部提示“还有X处未通过校验”。
用户体验上有一个好用的方式:提交校验失败后,自动滚动到第一个错误单元格,并高亮闪烁一下。这样用户不用一格格找问题,体验顺畅很多。
6. 常见问题与排查技巧实录
6.1 模板改了,老数据全乱了
这个问题的根源就是没有版本隔离。我第一次原型机就是直接改模板,改一行,之前所有填报记录的单元格坐标全部错位,市场部填的5万到了人事部的格子里。
解决方案前面已经说了:发布后禁止编辑模板,要改就复制新版本。同时填报接口返回的数据必须带template_version,前端如果发现当前打开的模板版本和填报记录版本不一致,给一个提示“该报表有新版本,是否查看新版本或继续编辑当前草稿”。
6.2 合并单元格的数据错位
设计器里连续合并多个区域后,填报端出现数据加载错位:填在左上角窗格里的数跑到了别的地方。
排查思路是先在控制台把mergedCells数组打出来,手动算一遍每个合并区域的主单元格。发现问题是合并区域的坐标在行列插入后没有联动更新,插入一行后,后面所有合并区域仍然按老的坐标渲染。
修复就是在插入行列的方法里同时更新merges数组。写一个方法,循环所有合并区域,判断起始行列是否受影响,统一调整。并且写几个测试用例,覆盖插入、删除、行列同时操作三种情况。
6.3 大报表渲染卡顿
有同事一次塞了600行60列的表格,设计器直接卡到动不了。排查下来主要是两个问题:一是所有单元格一次性渲染DOM节点,600×60就是3.6万个节点,浏览器撑不住;二是每次点击单元格更新属性,会全表重新渲染。
第一版的方案是限制最大行列数:设计器最大200行、50列,填报端如果模板超过300行就分页渲染,一屏只显示40行左右,滚动时创建新行、销毁旧行。这是一个简易的虚拟滚动。实测200×50的模板,在普通办公电脑上的渲染时间是300到500毫秒,可以接受。
6.4 提交接口偶发超时,用户重复点击造成重复提交
自由报表提交接口内部做了全量校验和落库,正常情况下也就几百毫秒,但偶尔会超过3秒。用户一看没反应就又点了几下,结果同一张报表生成了多条已提交记录。
前端加了提交按钮的loading状态,置灰加上菊花转圈。后端加了幂等处理:提交接口要求带一个前端生成的requestId,数据库给template_id + user_id + requestId建唯一索引,重复请求直接返回第一次提交成功的结果。这个方案治标也治本,值得所有人抄作业。
写在最后
自由报表填报这个功能,做起来最大的感受就是:看起来就是网页上的Excel,实际上坑全藏在细节里。合并单元格、版本隔离、校验时机、并发控制,每一个拿出来都是要仔细设计的点。
第一版我踩的最大的坑就是没在一开始就定死数据模型,导致设计器快做完的时候补了两次表结构。所以哪怕你现在的需求很明确,也建议先把所有可能出现的表格格式都列出来,再推演一遍三个核心JSON模型,改模型永远比改代码便宜。
下一篇我会具体讲模板设计器的交互实现,包括单元格框选、右键菜单设计、合并的撤销与重做,还有怎么处理用户在表格里来回划线这种手残操作。如果你也在做类似的填报系统,欢迎看完有问题随时留言交流。