ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

自由报表填报系统:核心数据模型设计实战

自由报表填报系统:核心数据模型设计实战 先说个真实场景。行政部的同事每个月月底都要收集各部门的数据上报以前的方式基本上就是做一张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 报表模板模型模板是整个自由报表填报的地基。你要是一张表的行列数、合并区域、单元格属性都没存对后面所有填报、汇总都是错的。我的报表模板表结构大致是这样字段名类型说明idbigint模板主键IDnamevarchar(100)报表名称比如“2024年7月预算执行表”codevarchar(50)报表编码用于对外链接statustinyint状态0草稿、1已发布、2已停用versionint版本号每保存一次1configjson完整模板配置JSONcreator_idbigint创建人IDcreated_atdatetime创建时间updated_atdatetime更新时间核心是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-ddselect下拉选择可选值存在validate.options里校验规则挂在单元格上而不是挂在列上。validate: { required: true, type: number, min: 0, max: 100000, message: 金额不能小于0或大于10万 }实际踩过的坑是校验必须分两层。前端校验是为了体验好用户填了不合法的数据立刻红框提示后端校验是为了安全接口层必须再校验一次。前端可以被绕过后端才是最后一道防线。3.3 填报数据模型填报数据是另一张表和模板分开存。这里有一个非常重要的原则填报数据必须存快照不能实时关联模板。比如7月的预算表模板是15行到了9月你给模板加了一行那7月已经提交的数据绝对不能跟着变。所以每一条填报记录要保存一份“当时填写时”的完整数据快照。我的填报数据表结构字段名类型说明idbigint填报记录IDtemplate_idbigint模板IDtemplate_versionint填报时的模板版本号user_idbigint填报人IDstatustinyint0草稿、1已提交、2已撤回datajson填报数据快照submit_timedatetime提交时间versionint乐观锁版本号填报数据快照的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模型改模型永远比改代码便宜。下一篇我会具体讲模板设计器的交互实现包括单元格框选、右键菜单设计、合并的撤销与重做还有怎么处理用户在表格里来回划线这种手残操作。如果你也在做类似的填报系统欢迎看完有问题随时留言交流。
返回列表