☰
帆软FR报表开发:用JS实现前端分类汇总实战详解
2026/10/19 8:33:09 网站建设 项目流程

最近在做帆软FR(FineReport,以下简称FR)的报表需求时,接到一个很典型的活儿:用户希望在一张订单明细报表里,不查数据库、不重新取数,直接在页面端按“产品类别”做一次分类汇总,把每个类别的销售总额、数量合计显示在报表下方。如果放在以前,我的第一反应是改成SQL或者加一个数据集,但这次现场环境不允许动查询逻辑,于是我把目光转向了前端——用js实现分类汇总。我在这块折腾了大概两天,把取数、分组、累加、渲染整条链路都跑通了。这篇文章就把它完整记录下来,给同样被“前端动态汇总”卡住的朋友做个参考。

1. 为什么要在帆软FR里用JS做分类汇总

1.1 FR自带的汇总方式其实不少,但边界也很明显

先说一个容易被忽略的事实:FR本身是支持分组汇总的,而且方式不止一种。

  • 在SQL数据集里写GROUP BY,用SUM、COUNT、AVG等聚合函数直接算好,报表只负责展示。
  • 在单元格中用公式,比如=SUM(B2:B20),对某一列做合计。
  • 利用数据集列的“列表/分组/汇总”属性,或者利用单元格展开后的父子格关系,实现按类别的小计。
  • 在决策报表的图表组件中,直接把维度字段和指标字段拖进去,让图表自己聚合。

这些方案的共同点是:计算逻辑要么在SQL阶段完成,要么依赖FR的扩展机制在服务端/报表引擎里完成。好处很明显——稳定、性能好、后台处理不占浏览器资源。坏处是,一旦需求变成“用户想在前端临时改一改分组条件”,或者“数据还没提交到数据库,就要先看到统计结果”,这些方案就有些水土不服了。

我遇到的那个需求恰恰卡在这个点上:单据是填报模式的,用户可以在页面里新增好几条明细,点击某个按钮后,希望立刻按“类别”看到汇总,但此刻数据还在前端,根本没提交到库里,SQL里写多少GROUP BY都查不到这批新行。这种“数据库里还没有的数据”,只能在前端算。所以,用js实现分类汇总不是炫技,是被业务逼出来的实用方案。

1.2 这些场景下,前端口算比后台SQL更合适

结合我做过的项目,下面三类场景用JS做分类汇总会比改SQL更省事:

第一类是填报未提交时的实时统计。用户需要边录入边看合计,或者录入完成后先预览一下每个分类的汇总再决定是否提交。此时数据对象只在浏览器里,任何后台查询都覆盖不到最新行,必须用前端JS遍历当前页面数据。

第二类是前端动态切换分组维度。比如页面上有一个下拉框,下拉框里可以选“按类别汇总”或“按地区汇总”,用户切换时希望统计块跟着变化,又不想每次切换都刷新整个报表。如果基于数据集和SQL来做,每一次切换都意味着重新取数;而把明细行读到JS数组里之后,改动keyField参数就能重新分组,响应速度是毫秒级的。

第三类是多个非标准数据来源的临时统计。部分模板里的数据不是来自同一条SQL,而是多个数据集甚至几个单元格手工录入拼出来的。比如某个看板里,明细数据分散在几个 sheet 中,为了做一个临时分类汇总去大改SQL,成本高且容易破坏原有模板结构。用JS直接从已经渲染好的单元格区域里抓数据,算完再输出,完全不影响原有结构。

当然,JS分类汇总不是万能钥匙。后面我会把适合用、不适合用的边界说清楚,避免大家走弯路。

2. 动手前先理清技术方案与数据来源

2.1 分类汇总这件事,本质是什么

如果抛开FR,单独看“分类汇总”这四个字,它的算法模型非常简单:

  • 先确定一个或几个字段作为“分类键”,比如产品类别。
  • 再确定一个或几个字段作为“汇总指标”,比如销售额、数量。
  • 遍历全部明细行,把分类键相同的行放进同一组,对汇总指标做累加(SUM),或者进一步统计个数、平均值、最大值等。
  • 最后把每一组的结果按某种顺序输出,形成一张汇总表。

如果用数学一点的话说,这就是一个“分组-聚合”过程。前端JS实现它的天然工具是对象字面量或Map,用分类键做 key,用包含累加结果的对象做 value。遍历一遍数据,遇到一个新的分类键就新建一个组,遇到已有的分类键就把数值累加上去。

我之所以强调先想清楚这个模型,是因为很多人在FR里一上来就写循环,写着写着就把“取数”和“分组计算”混在一起,代码又乱又难调试。把数据源独立出来,把“分组汇总”作为一个纯函数来写,后面接谁的数据都方便:接单元格取值可以,接数据集对象可以,接一段测试写死的数组也可以。

2.2 JS写在FR的哪个位置,效果差别很大

FR里能写JS的地方不少,但不同位置拿到的上下文不一样,这也是新手最容易懵的地方。我按实际项目里的使用频率排个序:

  • 按钮的点击事件:最适合手动触发分类汇总。调试的时候点一下按钮看一次结果,改代码也方便,所以我建议做技术验证时把JS挂在按钮上。
  • 控件值变化事件:比如下拉框选择“按类别汇总”后,立即触发汇总逻辑。这个适合做动态分组维度的场景。
  • 加载结束事件:模板一打开就自动计算汇总显示在页面上。这种适合“数据进来就要直接看到汇总”的场景,但要注意初始化时数据是否已完整渲染。
  • 单元格事件或超链接事件:适合点击某个单元格进入弹窗看汇总,或者双击明细行时在弹层里做统计。

除了位置,还要注意全局对象。在FR的常见版本中,页面里通常有一个contentPane对象,通过它可以访问当前报表实例、单元格数据等。但不同版本、不同场景(比如普通报表和决策报表)下这个对象的暴露方式会有些差异。我的习惯是:先在按钮点击事件里写一句console.log(contentPane),打开浏览器开发者工具看一下这个对象是否存在、里面的单元格取值方法叫什么名字,再继续写业务逻辑。这样做可以少走很多弯路。

3. 完整实操:在帆软FR中JS实现分类汇总

3.1 准备一个最简单的示例模板

为了方便复现,我在一个模拟项目里搭了一个最简单的订单明细报表:数据集名叫ds1,查询结果包含三列:类别、商品名称、销售额。模板中放了一个按钮“分类汇总”,以及一个用于显示结果的空区域。

我把查询结果直接通过单元格扩展展示在报表主体区域里,假定是这样的布局:

  • 第1行是标题行:类别、商品名称、销售额。
  • 从第2行开始,每一行是一条明细数据。
  • 在报表底部放一个按钮和一个占位区域,占位区域用于输出JS计算后的汇总表格。

这个模板很简单,但五脏俱全。真实项目里可能是几十列的大报表,但思路一模一样:确定明细数据落在哪些行和列,把需要的列读出来,组完再放回页面。建议你在做第一次验证时也用这种最简模板,把问题缩小到“JS数据链路”上,不要夹杂太多其他业务逻辑。

3.2 取数:怎么把明细数据从报表里读出来

这是整个方案中最依赖具体环境的一步。FR的每个版本API细节不太一样,我不会把话说死,但会给你一个通用思路和一套可以照着改的模板代码。

思路是:通过FR页面暴露的报表对象,按行列坐标读取单元格的值。假如我已经知道数据从第2行开始到第50行结束,类别在第2列,销售额在第4列,我可以写一个通用函数:

function readDetailRows(startRow, endRow, categoryCol, valueCol) { var rows = []; var pane = window.contentPane; if (!pane) { throw new Error("未获取到contentPane对象,请检查当前环境"); } for (var r = startRow; r <= endRow; r++) { // getCellValue的具体参数在不同FR版本里有差异,以实际控制台为准 var category = pane.curLGP.getCellValue(0, r, categoryCol); var sales = pane.curLGP.getCellValue(0, r, valueCol); if (category !== null && category !== undefined && category !== '') { rows.push({ category: String(category), sales: Number(sales) || 0 }); } } return rows; }

这段代码里最需要注意的有两点:一是pane.curLGP这个访问路径在我的环境里是可行的,但如果你在按钮事件里执行时发现curLGP不存在,就改成pane.getCellValue或者通过FR.remoteEvaluate绕一下;二是我把sales用Number()做了强转,这一步非常关键,后面我会专门讲为什么。

如果你不希望依赖单元格行列坐标,还有另一个变通思路:直接把明细数据渲染到页面上后,用contentPane的单元格定位方式按名字取。比如模板里在第5行放了扩展单元格,类别字段所在的单元格名是B5,销售额是D5,那就用类似pane.curLGP.getCellValue(0, "B5")的方式取。总之,先确认API,再写数据读取层。

3.3 核心JS函数:分组汇总算法

取到明细数组之后,剩下的计算就和FR没有关系了,完全是一个纯前端JS问题。下面这个函数是我常用的通用分组汇总实现,支持按一个分类字段汇总多个数值字段:

function groupSummary(data, keyField, valueFields) { var groupMap = {}; var keyOrder = []; var i, j, key, row, group; for (i = 0; i < data.length; i++) { row = data[i]; key = row[keyField]; if (!groupMap.hasOwnProperty(key)) { groupMap[key] = {}; for (j = 0; j < valueFields.length; j++) { groupMap[key][valueFields[j]] = 0; } keyOrder.push(key); } group = groupMap[key]; for (j = 0; j < valueFields.length; j++) { group[valueFields[j]] += parseFloat(row[valueFields[j]]) || 0; } } var result = []; for (i = 0; i < keyOrder.length; i++) { key = keyOrder[i]; var item = { key: key }; var vals = groupMap[key]; for (j = 0; j < valueFields.length; j++) { item[valueFields[j]] = vals[valueFields[j]]; } result.push(item); } return result; }

几个代码细节我想单独说明一下。

为什么用hasOwnProperty而不是直接if (groupMap[key])?因为当某个分类的汇总值累加到0时,groupMap[key]的值是对象而不是数字,所以其实两种写法都不会出太大问题。但用hasOwnProperty是语义最清晰、最规范的判断方式,它明确表示“这个分类键是否已经出现过”,不受值真假影响。

为什么要额外维护一个keyOrder数组?因为JS对象里字符串 key 的遍历顺序在某些旧浏览器里并不是按插入顺序来保证的。如果我直接用for (var key in groupMap)输出结果,分类顺序很可能乱七八糟。用keyOrder显式记录第一次出现的顺序,输出时按它遍历,结果就是稳定可预期的。

为什么累加时要写parseFloat(row[valueFields[j]]) || 0?因为从报表单元格取出来的值,即使看起来是个数字,在JS里也可能是字符串。字符串相加会直接拼接:'10' + '2'得到'102',而不是12。所以在累加前转成数值,空值、非法值就按0处理,这样才能保证汇总数字正确。

3.4 结果怎么展示:表格、单元格、弹窗三种出口

分组汇总算完,最终要让人看得见。我一般用三种出口,你按场景选就行。

第一种是弹窗/日志输出,适合调试。先不纠结UI,直接console.log(JSON.stringify(summaryRows))或alert出来看结果对不对。

第二种是拼HTML表格写进页面。用按钮触发时,我通常会在按钮旁边放一个HTML容器,然后用jQuery把结果写进去。FR页面里一般自带jQuery,可以直接用$:

function renderSummaryTable(summaryRows, containerId) { var html = '<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;margin-top:10px;">'; html += '<tr><th>分类</th><th>销售额合计</th></tr>'; for (var i = 0; i < summaryRows.length; i++) { var row = summaryRows[i]; var cat = row.key; var amount = row.sales.toFixed(2); html += '<tr><td>' + cat + '</td><td>' + amount + '</td></tr>'; } html += '</table>'; $('#' + containerId).empty().append(html); }

第三种是把结果写回报表的某一个单元格,通过FR的单元格赋值API实现,适合要打印或导出的场景。这一步的API同样是版本相关,核心逻辑就是取到目标单元格并设置其值。如果你的目标是普通报表单元格,可能需要将单元格设置为“其他-控件-标签”或用FR的公式展开来承接JS值,我在实操中更喜欢用容器 + HTML表格的方案,因为它最直观,也不容易受单元格扩展规则干扰。

完整流程串起来就是:按钮点击 ->readDetailRows读明细 ->groupSummary分组汇总 ->renderSummaryTable渲染。三个函数各管一段,哪里出问题都能快速定位。

4. 踩坑实录与排查技巧

4.1 高频问题速查表

JS分类汇总实现起来不算复杂,但在FR里会遇到一些非常“经典”的坑,我把它们整理成了速查表,希望能帮你省时间。

问题常见原因排查思路与解决办法
汇总结果全是0取数时行列坐标不对,或者单元格值为空先打印读出来的原始数据,确认每一行确实有值
数字变成字符串拼接报表单元格取出的值是字符串,JS直接用+累加累加前用parseFloat或Number转换,空值兜底为0
分组顺序乱直接用对象的for in遍历,没有记录插入顺序使用keyOrder数组按首次出现顺序输出
浏览器控制台报语法错误FR旧环境下低版本浏览器不支持ES6语法用var、普通函数、对象,避免let、箭头函数、Map
按钮重复点击后内容叠加页面容器没有清空,一直在追加渲染前对容器执行empty()或html('')
取不到contentPane事件上下文不同,对象位置不对在事件开头console.log(contentPane),观察对象结构后调整API
金额小数位不对浮点数累加容易产生长尾小数展示时用toFixed(2),再做千分位格式化

你发现没有,大多数问题都集中在“数据类型”和“对象访问”两个点上。所以我一直强调:先把读出来的原始数据打出来确认一遍,再进汇总函数。很多时候不是算法写错了,而是进来的数据本身就带着旧浏览器或FR环境的“小脾气”。

4.2 不适合用JS硬扛的几种情况

JS分类汇总虽好用,但我也踩过“硬扛”的坑,现在碰到这些信号我会直接叫停:

  • 数据量超过几千甚至上万行。前端JS遍历加DOM渲染会卡顿,尤其旧浏览器,页面可能直接崩。这时候应该回到SQL聚合,或者用EXCEL导出式的服务端计算。
  • 表格本身极其复杂,数据散落在几十个扩展单元格里。你当然可以一个一个坐标去读,但维护成本太高,下次模板一改位置,代码又要跟着改。
  • 有严格的权限审计要求,不希望前端能修改汇总逻辑。前端JS是运行在浏览器里的,关键统计一旦需要留痕,还是应该在后端算。

判断标准很简单:如果这个计算是一次性的静态报表,优先SQL;如果是动态交互、临时汇总、填报未提交数据,才考虑前端JS。两者不是替代关系,而是互补。

5. 从“能用”到“好用”:扩展与优化建议

5.1 让分组汇总支持平均值、计数、最大值

很多业务不只要“求和”,还想要“这个分类一共出现几次”“平均单价是多少”“最大的一笔销售额是多少”。扩展起来其实不复杂,我在分组对象里再加两个辅助字段:一个记录计数,一个记录最大值。求和值不变,最后算平均值时用总和除以计数。

function groupSummaryExt(data, keyField, sumFields, countFields, maxFields) { var groupMap = {}; var keyOrder = []; var i, j, key, row, group; for (i = 0; i < data.length; i++) { row = data[i]; key = row[keyField]; if (!groupMap.hasOwnProperty(key)) { groupMap[key] = { count: {}, max: {} }; for (j = 0; j < sumFields.length; j++) { groupMap[key][sumFields[j]] = 0; } for (j = 0; j < countFields.length; j++) { groupMap[key].count[countFields[j]] = 0; } for (j = 0; j < maxFields.length; j++) { groupMap[key].max[maxFields[j]] = -Infinity; } keyOrder.push(key); } group = groupMap[key]; // 求和 for (j = 0; j < sumFields.length; j++) { group[sumFields[j]] += parseFloat(row[sumFields[j]]) || 0; } // 计数 for (j = 0; j < countFields.length; j++) { group.count[countFields[j]]++; } // 最大值 for (j = 0; j < maxFields.length; j++) { var v = parseFloat(row[maxFields[j]]) || 0; if (v > group.max[maxFields[j]]) { group.max[maxFields[j]] = v; } } } var result = []; for (i = 0; i < keyOrder.length; i++) { key = keyOrder[i]; var item = { key: key }; var g = groupMap[key]; for (j = 0; j < sumFields.length; j++) { item[sumFields[j]] = g[sumFields[j]]; } for (j = 0; j < countFields.length; j++) { item['avg_' + countFields[j]] = g.count[countFields[j]] === 0 ? 0 : item[sumFields[j]] / g.count[countFields[j]]; } for (j = 0; j < maxFields.length; j++) { item['max_' + maxFields[j]] = g.max[maxFields[j]] === -Infinity ? 0 : g.max[maxFields[j]]; } result.push(item); } return result; }

这只是其中一种组织方式。更健壮的写法是封装一个聚合配置对象,但面对FR里的实际场景,上面的复杂度已经够用。

5.2 排序和数字格式化:让汇总结果更好读

分类汇总默认按首次出现的顺序输出,但用户通常更希望按金额从高到低排,或者按分类名称排。JS里排序很简单,但要注意旧版本浏览器兼容性,不要用箭头函数:

summaryRows.sort(function(a, b) { return b.sales - a.sales; // 从高到低 });

金额格式化也是刚需。toFixed只能保留小数位,不能加千分位。我在项目里用一个简单的正则:

function formatNumber(num, decimals) { var fixed = Number(num).toFixed(decimals || 2); var parts = fixed.split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parts.join('.'); }

把这些小函数和groupSummary放在一起,形成一个前端汇总工具模块,后面新项目直接复制,改改字段名就能用。

5.3 怎么把这套逻辑沉淀成公共资源

如果公司里有多张报表都要用,不建议每张表都复制一遍完整代码。正确做法是把公共函数放到一个独立的JS文件里,然后在FR模板中引入。

  • 在FR的模板资源管理里找到“外部JS资源”或类似的配置入口,不同版本位置不同。
  • 把summaryUtil.js加进去,模板加载后函数就会暴露到全局。
  • 在按钮事件里只需要调用groupSummary和renderSummaryTable,维护成本大幅下降。

团队协作时,我还习惯在文件头部写清楚入参规范和返回值结构:data必须是对象数组,每个对象里的字段名要与keyField、valueFields对齐。这样前后端协作或者交接给其他报表开发时,不容易因字段名不一致而返工。

6. 一点个人实操体会

如果你正打算在帆软FR项目里用js实现分类汇总,我有一个非常实用的建议:开发时先别急着美化页面,先把“取数-分组-渲染”三个函数用console.log串联起来,确认原始数据正常、汇总数字正确,最后再加样式和排序。我在第一个项目里就是因为直接写完整按钮功能,结果有的数据是字符串、有的行列坐标不对,混在一起排查了整整半天。后来改成“先打日志确认数据,再写算法”,效率高了很多。

另外一个小技巧:调试按钮时不要用很复杂的初始化场景,直接在模板里放一个临时按钮,点一次看一次结果即可。等逻辑稳定后,再把按钮删除或隐藏,把同一段JS迁移到“加载结束事件”或“控件值变化事件”里。这样每一步都在可控范围内,不会因为多个事件同时触发而搞不清是哪段代码出的问题。

分类汇总本身不难,难的是在FR这个特定环境里把数据类型、对象访问、DOM渲染几个环节理顺。希望这篇记录能让你少走几个弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询