☰
Excel做数据源、FastReport做排版:Delphi报表链路与避坑
2026/10/28 11:03:55 网站建设 项目流程

简介:这份资料面向Delphi开发者,聚焦FastReport报表工具与Excel数据源的结合应用,适合已掌握基础窗体开发、希望把现成表格数据整理成规范报表的读者参考。压缩包共10个文件,约835KB,内含Delphi工程与窗体源码、可执行示例、Excel表格模板、网页说明和文本注释,能够对照完整工程结构理解报表布局、字段绑定及运行时预览的实现思路。已有574人学习,反映该主题在桌面报表开发中具有一定关注度。读者可从中获得一套可运行的示例程序与配套表格素材,借助源码观察数据连接、数据集定义、字段映射和导出打印的组织方式,并参考说明文档排查配置与数据显示问题,再迁移到自身业务报表中,减少从零搭建环境的摸索成本,对于需要快速验证报表样式的开发者尤其实用。

1. Excel 当数据源、FastReport 当排版器:这条链路适合谁

业务方发来一个 xlsx,说下周要按这个格式出 300 份带抬头的 PDF,数据库排期排到下个月。这种局面下最快的解法不是改库,而是把 Excel 当数据源、把 FastReport 当排版和打印引擎:Delphi 项目里放一个TfrxReport,数据侧用 ADO 或内存表接上 xlsx,版式在设计器里拖一遍,PDF 和 xlsx 两套输出都能出。它适合三类人:手里已经有 FastReport、数据还停留在表格阶段的维护型项目;需要把用户上传的 Excel 直接变成正式单据的内部系统;以及不想为几张报表单独搭一套数据管道的小团队。不适合的是百万行级、要事务和并发的场景,那属于数据库的活。下面按“选通路、翻字段、复刻版式、排错、验收”五步拆。

2. 三条接法的选型:ADO 直连、内存表、版式参照各管什么

先明确一件事:标题里的“用 Excel 做报表”其实混着两个方向。一个是读——Excel 是数据源,报表负责排版;一个是写——报表跑完导出成 xlsx 给下游继续加工。这两个方向的组件、参数、坑完全不同,混在一起谈容易在排错时找不到北。这一章先把读的三条接法摆开,导出放在第 4 章末尾单独说。

2.1 先看一张对比表:三种接法的边界

接法核心组件适合的场景主要代价
ADO 直连TADOConnection+TADOQuery,再挂TfrxDBDataSet表头规范、行数几千到几万、列结构稳定依赖 ACE 数据访问组件;类型推断不可控,脏列会一路传染到报表
内存表接管自己解析后塞进TFDMemTable或TfrxUserDataSet列名带空格括号、要合并多张表、要按业务规则清洗代码量大,游标、排序、空值都得自己维护
版式参照数据仍走数据库,Excel 只提供列宽、行高、分组规则数据已经在库里,只是要“打成那张 Excel 的样子”版式要人工换算,Excel 改一版就得跟一版

判断标准很朴素:如果 Excel 是业务方每天上传的、表头由人手工维护,那基本只能走内存表,因为 ADO 对脏表头的容忍度低到你会怀疑人生;如果 Excel 是系统导出的、格式固定,ADO 直连能省掉八成代码;如果 Excel 只是“客户想要这个样式”,那它跟数据源没关系,别把版式问题当成数据问题。FastReport 自带的 frxADO 组件包里也有TfrxADODataBase、TfrxADOQuery,可以少建一个 DataModule,但连接串的表达能力比原生 ADO 弱,涉及IMEX、区域限定这类参数时我一般还是用原生组件再挂TfrxDBDataSet。

2.2 ADO 直连 xlsx:连接串、工作表名和游标位置

最小可用的一套是这样,连接对象挂在窗体上,查询对象在设计期放好,运行时只改连接串和 SQL。

// 打开指定工作簿的指定工作表,结果集直接给 TfrxDBDataSet 用 procedure TfrmMain.OpenSheet(const AFile, ASheet: string); begin FreeAndNil(FConn); // 换文件时先断开,避免句柄泄漏 FConn := TADOConnection.Create(Self); FConn.LoginPrompt := False; // 无人值守,禁止弹登录框 FConn.KeepConnection := False; FConn.ConnectionString := 'Provider=Microsoft.ACE.OLEDB.12.0;' + 'Data Source=' + AFile + ';' + 'Extended Properties="Excel 12.0 Xml;HDR=YES;IMEX=1"'; FConn.Connected := True; qryOrders.Connection := FConn; qryOrders.CursorLocation := clUseClient; // 客户端游标,报表遍历时不占服务端 qryOrders.SQL.Text := Format('SELECT * FROM [%s$]', [ASheet]); qryOrders.Open; end;

逻辑上就三步:建连接、设游标、发查询。真正决定成败的是连接串里那段Extended Properties,它的三个开关各自管一件事。Excel 12.0 Xml对应 xlsx,老式 xls 要换成Excel 8.0,二进制工作簿又是另一个取值,选错了会直接报“外部表不是预期的格式”。HDR=YES表示首行当列名,设成 NO 的话所有列会变成F1、F2,报表里就没法按名字引用。IMEX=1是把混合类型的列按文本读,它能救回“订单号 0001 变成 1”这类事故,代价是整列失去数值语义,后面做 SUM 会得到 0,这一点在第 5 章还会展开。

工作表名的写法是最容易翻车的地方:FROM [Sheet1$]对,FROM Sheet1$错,FROM [Sheet1]也错,美元符号必须在方括号里面。中文表名一样要方括号,比如[订单明细$]。如果只想读某个区域,可以写成[Sheet1$A1:F200],注意这个区域是含表头的,配合HDR=YES时第一行仍然当列名。取不到表名时别猜,用FConn.GetTableNames(AList, False)把工作簿里所有可读的表名列出来看一眼,隐藏工作表也会出现在这个列表里。

注意:TADOConnection用Create(Self)挂到窗体上,别写成Create(nil)又不释放,报表跑批几百次之后句柄会堆起来,表现为跑着跑着报“未指定的错误”。

2.3 内存表接管:TfrxUserDataSet 的五个事件怎么填

当表头脏到 SQL 没法处理时,把数据先读进一个泛型列表,再让TfrxUserDataSet逐行喂给报表。它不是一个真正的数据集,而是靠事件回调模拟游标,所以至少要实现五个事件,少一个预览和翻页就会错位。

type TOrderRow = record No: string; Customer: string; Qty: Integer; Price: Currency; BookDate: TDateTime; end; // 报表每引用一个字段就回调一次,VarName 是设计器里写的字段名 procedure TfrmMain.dsOrdersGetValue(const VarName: string; var Value: Variant); var R: TOrderRow; begin if (FIndex < 0) or (FIndex >= FRows.Count) then Exit; R := FRows[FIndex]; if SameText(VarName, 'OrderNo') then Value := R.No else if SameText(VarName, 'Customer') then Value := R.Customer else if SameText(VarName, 'Qty') then Value := R.Qty else if SameText(VarName, 'Amount') then Value := R.Qty * R.Price else if SameText(VarName, 'BookDate') then Value := R.BookDate; end; procedure TfrmMain.dsOrdersCheckEOF(var Eof: Boolean); begin Eof := FIndex >= FRows.Count; // 引擎在 Next 之后调用,这里只判断不移动 end; procedure TfrmMain.dsOrdersFirst(Sender: TObject); begin FIndex := 0; end; procedure TfrmMain.dsOrdersNext(Sender: TObject); begin Inc(FIndex); end; procedure TfrmMain.dsOrdersPrior(Sender: TObject); begin Dec(FIndex); end;

OnGetValue是核心,它把“报表字段名”和“内存里的记录”对上号。这里用SameText而不是=,因为设计器里手写的字段名大小写经常和代码不一致,用大小写不敏感的比较能省掉一类“明明有值却显示空白”的问题。返回的Value是Variant,金额建议直接给Currency或Double,不要给格式化过的字符串——一旦给了字符串,报表里的 SUM 就退化成字符串拼接,合计必然是 0。

OnCheckEOF只负责回答“到头了没有”,不要在里面Inc(FIndex)。OnFirst、OnNext、OnPrior才是移动游标的地方,OnPrior看着多余,但报表预览的“上一页”和某些交叉表回扫会用到,不实现的话翻页会跳行。如果你的数据需要排序,别指望TfrxUserDataSet,它没有索引,排序要在FRows填完之后自己做一次,或者在读取阶段就按 Excel 里的顺序固定下来。

2.4 版式参照:数据走库、Excel 只提供尺子

第三种情况经常被误判。业务方给的 Excel 里数据是假的,只有表头、列宽、分组位置是真的,这时候不要为了“跟 Excel 一致”去读这个文件。正确做法是把 Excel 当尺子用:量出每列宽度、行高、页边距,在设计器里按第 4 章的换算表把对象摆好,数据仍然从数据库来。这样做的好处是数据量不受 Excel 限制,坏处是业务方每改一次版式,你就得重新量一次。缓解办法是把列宽、行高这些数字抽到配置里,或者干脆约定“版式变更走需求单”,别让一张截图就触发一次改版。

3. 把 Excel 的列翻译成 FastReport 的字段:类型、别名与计算列

数据能读出来只是第一步,真正决定报表能不能跑起来的是字段层。Excel 没有 schema,列的“类型”是靠前几行数据猜出来的,猜错的后果会一路传到报表表达式里,报错信息还很难懂。这一章处理三件事:列名怎么清洗、别名在哪做、计算列放哪算。

3.1 HDR、IMEX 与列名清洗:数据字典的第一道关

HDR=YES之后,首行就是列名,但 Excel 对“列名”没有约束,于是会出现这些情况:表头为空,驱动补成F1、F2;两列同名,第二列被命名成金额1;列名里带空格、点号、括号、方括号。前两种在报表里还能引用,第三种在 FastReport 表达式里必须写成[frxDBDataset1."金额(元)"]这种形式,方括号加双引号,少一个就解析失败。手写这种表达式很容易漏引号,所以我一般不让脏列名进到报表层。

先在打开报表之前把 schema 打出来看一眼,比在报错里猜快得多。

// 打开工作簿,列出所有可读表名和首行字段的类型 procedure TfrmMain.DumpSchema(const AFile: string); var Conn: TADOConnection; Rs: TADOQuery; Tabs: TStringList; i: Integer; begin Conn := TADOConnection.Create(nil); Tabs := TStringList.Create; try Conn.LoginPrompt := False; Conn.ConnectionString := 'Provider=Microsoft.ACE.OLEDB.12.0;Data Source=' + AFile + ';' + 'Extended Properties="Excel 12.0 Xml;HDR=Yes;IMEX=1"'; Conn.GetTableNames(Tabs, False); // 隐藏工作表也会列出来 Memo1.Lines.Assign(Tabs); Rs := TADOQuery.Create(nil); try Rs.Connection := Conn; Rs.SQL.Text := 'SELECT TOP 1 * FROM [' + Tabs[0] + ']'; Rs.Open; for i := 0 to Rs.FieldCount - 1 do Memo1.Lines.Add(Format('%s -> %s', [Rs.Fields[i].FieldName, Rs.Fields[i].DataType.ToString])); finally Rs.Free; end; finally Tabs.Free; Conn.Free; end; end;

GetTableNames拿到的名字已经带$,可以直接拼进 SQL。DataType是驱动猜出来的类型,ftWideString说明这一列被判成文本,即使里面全是数字——这就是IMEX=1的典型结果,也是合计为 0 的根源。看到整列都是ftWideString而业务上应该是数值,就该考虑去掉IMEX或者在 SQL 里显式转换。

清洗的规则我固定成三条:表头必须一行、不能有合并单元格、不能有空行夹在中间。这三条不满足时,ADO 读出来的列会整体错位,而且不报错,只是数据全跑到隔壁列。与其在代码里做容错,不如在导入入口卡一道校验,把不合格的文件打回去。

3.2 别名视图:把“订单号”“金额(元)”挡在报表之外

别名有两种做法。一种是在 SQL 里用AS直接改,这是首选,因为它把脏名字挡在数据层,报表里永远只见得到干净的英文名。

// 用 ACE SQL 做一次投影:改列名、补空值、统一类型 qryOrders.SQL.Text := 'SELECT [订单号] AS OrderNo, ' + ' [客户名称] AS CustomerName, ' + ' IIF(ISNULL([数量]), 0, [数量]) AS Qty, ' + ' IIF(ISNULL([单价]), 0, [单价]) AS Price, ' + ' [下单日期] AS BookDate ' + 'FROM [订单明细$]';

IIF和ISNULL在 ACE 引擎里是可用的,作用是把空单元格变成 0,避免NULL传染到后面的乘法。注意AS之后的名字不能带空格,也别用 SQL 关键字,Date、Value、Order这类词在某些驱动版本上会被解析成保留字。如果某一列在 Excel 里确实缺失,SELECT会直接报错,这反而是好事——比报表跑到一半发现字段是空的强。

另一种做法是在TfrxDBDataSet上做映射。TfrxUserDataSet靠OnGetValue里的VarName分支天然就是一层别名,想叫什么就叫什么;TfrxDBDataSet有FieldAliases属性也能做类似的事,但不同版本的行为有差异,跨版本升级时容易出问题。我的习惯是别名统一在数据层做完,报表层只认一套固定的字段名,这样换数据源(Excel 换数据库)时报表模板不用动。

3.3 计算列放在哪算:SQL、内存、报表表达式的取舍

“金额 = 数量 × 单价”这种列,三个地方都能算,选错了会在后期付出代价。

位置写法适合风险
SQL 里算[数量]*[单价] AS Amount行内四则运算、简单汇总驱动类型推断差,空值会传染,除零直接抛异常
内存里算读入后统一用Currency计算需要精度控制、要多表合并、要写单元测试代码量增加,得自己保证每行都算到
报表表达式<frxDBDataset1."Qty"> * <frxDBDataset1."Price">临时展示、调试期快速验证每页重复计算,空值要显式判断,出错时定位困难

金额这类涉及钱的计算,我一律放在内存里用Currency算完再给报表,原因是浮点误差在合计行上会放大,Excel 那边显示两位小数看不出来,报表打出来差几分钱就要被财务问。报表表达式适合做展示层的加工,比如“金额大于 1000 标红”,判断条件写在对象的Highlight里,而不是把计算结果塞回数据层。

如果非要在报表里处理空值,表达式得写成IIf(<frxDBDataset1."Qty"> == Null, 0, <frxDBDataset1."Qty">)这种形式,注意 FastReport 的表达式用==做比较、用<>做不等,和 Pascal 的=不是一个写法,这一点在切过来的头几天特别容易写错。

4. 版式复刻:让 FastReport 打出来像那张 Excel

数据和字段都通了,剩下的就是“像不像”。这一章的四个点按影响从大到小排:列宽行高决定整体骨架,分组决定分段小计对不对,交叉表决定透视结构能不能复刻,导出参数决定交付物会不会被打回来。

4.1 列宽行高换算:从 Excel 字符宽到报表毫米

Excel 的列宽单位是“默认字体下的字符数”,FastReport 的对象坐标默认按毫米走,中间要过一次像素。经验公式是像素 ≈ 列宽 × 7 + 5,7 是默认字体下最大数字的宽度,字体换了要重新校准。行高更直接,Excel 按磅给,1 磅 = 96/72 像素。

Excel 里的值含义折合像素报表侧设成
列宽 8.43默认标准列宽约 64 px16.9 mm
列宽 1212 个字符约 89 px23.5 mm
列宽 2020 个字符约 145 px38.4 mm
行高 15默认行高20 px5.3 mm
页边距 1.9 cmExcel 默认左右边距—页面左右各 19 mm

换算完还要过一道宽度校验:A4 纸宽 210 mm,减去左右边距各 20 mm,可用宽度只有 170 mm。Excel 里八列各 25 mm 就是 200 mm,直接搬到报表上必然横向溢出,打印时会被截断或者挤到第二页。这时候只有三条路:改横向纸张(可用宽度变成 277 mm)、压列宽、或者把不关键的列挪到第二页的续表里。我一般先试横向,因为压列宽会让长文本换行,行高跟着变,反而更难对齐。

页面边距还有个细节:FastReport 的页面默认四边各 20 mm,Excel 默认是 19 mm,差的这 1 mm 在页脚位置会表现为半行偏移,套打的时候能看出来。要么把报表页边距改成 19 mm,要么把页脚对象整体上移 1 mm,别两个都改,会变成 2 mm。

4.2 分组小计与主从表:GroupHeader 替代 Excel 的分段

Excel 里那种“每个客户一段、段尾一行小计”的结构,在 FastReport 里对应GroupHeader+GroupFooter。做法是把数据集按客户排序,放一个 GroupHeader,Condition设成客户字段的表达式,Footer 里放SUM聚合。

// 按客户分组,组头打印客户名,组尾打印该客户的小计 frxGroupHeader1.Condition := '<frxDBDataset1."CustomerName">'; frxMemoCust.Text := '[frxDBDataset1."CustomerName"]'; frxMemoSubTotal.Text := '[SUM(<frxDBDataset1."Amount">, MasterData1, 1)]';

Condition的表达式语法和普通字段引用一样,但要写在<>里面。SUM的第二个参数指定统计范围所在的 band,第三个参数1表示按组内统计(0 是整页、2 是整报表),这个参数写错会表现为“每组小计都是总数”,排查时先看它。分组要求数据已经排好序,ADO 那边可以在 SQL 里加ORDER BY,TfrxUserDataSet就得自己在内存里排,否则同一客户会被拆成好几段。

主从表是另一个高频需求:一个订单头带若干明细行。最稳的写法不是依赖TfrxDBDataSet.Master,而是在主表的 band 打印前手动过滤从表。

// 主表每打印一行前,把从表定位到当前订单 procedure Tf <p> <a href="https://download.csdn.net/download/u013275409/10746739" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询