简介:面向SIMATIC WinCC组态开发人员与工业自动化数据报表学习者,这份文档围绕VBScript与ActiveX控件实现SQL报表查询展开,覆盖从SQL Server 2005建库建表、WinCC全局脚本读写数据库,到日报表画面组态、查询与打印脚本编写的完整知识链路,适合具备WinCC基础、希望补齐数据库报表能力的中级读者。包内仅含1个doc文件,压缩包约1.11MB,以图文步骤和附录VBS代码示例组织内容,便于对照工程实践查阅。目前已有1682人学习。读者可从中获取MSFlexGrid与MS DTPicker控件的注册配置要点、按日期区间查询SQL记录的脚本思路,以及将表格数据转存Excel并打印的实现参考,对搭建WinCC生产数据日报、理解变量入库与报表展示的衔接具有直接借鉴价值。
1. 班组长要的那张日报:WINCC 里的 VBS 脚本 + SQL 直连能补上什么
凌晨两点交接班,班组长要前一天的设备占用率日报,值班的人还在 WINCC 画面里一页页翻历史趋势,手抄进 Excel 再排版。这份《基于 VBScript 和控件实现 WINCC SQL 报表查询功能》针对的就是这个环节:用 VBS 脚本把 WINCC 内部变量按周期写进 SQL Server 的报表表,画面里挂上 MSFlexGrid 和 MS DTPicker 两个 ActiveX 控件,选日期、点查询、点打印,数据直接落到 Excel 表格里走打印。整条链路不引额外报表服务器,全局脚本负责写、画面脚本负责读,改需求就是在项目里改几行代码。适合已经在维护 WINCC 项目、想自己补一块历史数据查询能力的人,也适合刚接手旧项目、需要先把这套控件体系跑通的新手。下面按建库、组态、查询脚本、排错、打印的顺序拆。
2. 建库与写库脚本:time/cpu/ram/hd 四列宽表怎么定、全局脚本怎么落
这套方案的数据底座很朴素,一张宽表、四个字段。选型上稍微走岔一步,后面的查询和打印都会跟着别扭。常见做法是照着概要先把表建完就收工,等数据攒到几十万行、查一个月日报要转十来秒,才回头补索引和字段类型,那时候改起来就得停机迁数据了。
2.1 表结构与字段类型:先把 datetime 和浮点定准
建库这一步原文写得很直白:打开 SQL Server Management Studio 连上实例,在 Databases 上右键 New Database,名字填一个项目用的库名(原文示例是SQL-Report);然后在 Tables 上右键 New Table,逐个填 Column Name 和 Data Type,保存时给表名。字段一共四个,我一般把类型定成这样:
| 列名 | 类型 | 说明 | 备注 |
|---|---|---|---|
| time | datetime | 采集时刻,写库时取 Now | 建议写成[time],避免和类型名撞 |
| cpu | float | CPU 占用百分比 | 0–100,允许小数 |
| ram | float | 内存占用百分比 | 同上 |
| hd | float | 硬盘占用百分比 | 同上,注意是占用率不是剩余量 |
time这个列名在 T-SQL 里虽然能直接用,但和系统类型名同名,写INSERT INTO VBSReport(time,...)多数情况能过,换成别的数据库或者加计算列时容易报错,加方括号最省事。类型上不推荐用varchar存时间,看着省事,实际上在BETWEEN比较时会按字符串比,"2024-9-5"和"2024-09-05"排出来的顺序不对,日报直接错行。用datetime还有一个好处:写脚本时不用管秒和毫秒,比较运算符天然支持。
建完表立刻在time上建一个非聚集索引。理由很实际——查询脚本的WHERE条件永远只落在时间区间上,数据量上来之后有没有这个索引,差别就是几百毫秒和几秒。索引建在 SQL 端,和 WINCC 侧没关系,不会影响画面响应。
提示:建表时把 cpu/ram/hd 的默认值设成 0 或者留 NULL 都行,但写库脚本里三个变量读失败时返回的是空字符串,插进去会变成 0,报表上就是一串平线。读变量那一步要做空值判断,别指望数据库兜底。
2.2 写库全局脚本:连接串、INSERT 与日期格式
原文附录里那段action函数是整套东西的入口,放在全局脚本里,由触发器周期调用。核心就三步:读变量、连库、拼 INSERT。真正需要动脑的是连接串和日期格式这两处。
Option Explicit Function action Dim a, b, c, d, ts Dim strcn, cn, is_SQL a = Now ' 采集时刻 b = HMIRuntime.Tags("cpu").Read ' 三个内部变量,同步读取 c = HMIRuntime.Tags("ram").Read d = HMIRuntime.Tags("hd").Read ' 不走格式本地化,手工补零拼成 ISO 风格,SQL 端一定认得 ts = Year(a) & "-" & Right("0" & Month(a), 2) & "-" & Right("0" & Day(a), 2) & " " & _ Right("0" & Hour(a), 2) & ":" & Right("0" & Minute(a), 2) & ":" & Right("0" & Second(a), 2) ' Integrated Security=SSPI 走 Windows 身份验证,不落账号密码在脚本里 strcn = "Provider=SQLOLEDB.1;Integrated Security=SSPI;Persist Security Info=False;" & _ "Initial Catalog=SQL-Report;Data Source=.\wincc" Set cn = CreateObject("ADODB.Connection") cn.ConnectionString = strcn cn.Open is_SQL = "INSERT INTO VBSReport([time], cpu, ram, hd) VALUES ('" & ts & "'," & _ b & "," & c & "," & d & ")" cn.Execute is_SQL cn.Close Set cn = Nothing End Function参数逐个说一下。Provider=SQLOLEDB.1是 SQL Server 的 OLE DB 提供程序,WINCC 这种老版本环境上最稳的一档;Integrated Security=SSPI表示用当前 Windows 账户认证,全局脚本跑在 WINCC 运行态的服务账户下,所以这个账户要在 SQL 实例上有对应登录名和目标库的db_datawriter权限,否则报“登录失败”。Data Source=.\wincc里的点表示本机,后面跟的是实例名——WINCC 自带安装的 SQL 实例名通常是固定的,实际项目里按本机SELECT @@SERVERNAME的结果改。Initial Catalog就是目标库名。
把时间手工补零这件事,是这套脚本里最容易被忽略的地方。VBScript 的Month()、Day()返回的是不带前导零的数字,直接拼出来是2024-9-5 8:3:7,SQL Server 在部分语言设置下能认,在另外一些设置下会当成非法日期直接抛错,或者更糟——被解析成2024-09-05之外的日子。踩过一次之后,我在这类脚本里一律手工补零。
2.3 触发条件:周期、变量触发和写库频率的取舍
全局脚本写好了不会自己跑,得挂触发器。WINCC 的全局脚本触发器常见有三类:定时器(按固定周期)、变量触发(某个变量变化时)、事件触发。写历史数据用定时器最直接,周期填多少要看两个约束。
一是数据密度。日报表按天聚合,一分钟一条就是 1440 行,一个月四万多行;如果只是看趋势,五分钟一条、一天 288 行就够,查询和导出的速度都会好很多。二是变量读取的代价。HMIRuntime.Tags("cpu").Read是同步调用,一次读三个变量就是三次往返,周期压到一秒以内,脚本本身的执行时间会开始挤占运行态的时间片,画面切页会肉眼可见地卡。我一般把周期放在 30 秒到 5 分钟之间,看工艺节奏定。
还有一个容易翻车的地方:触发器配了周期,但脚本函数名必须和触发配置里调用的名字一致,且函数要放在“项目模块”而不是“标准模块”之外的位置时注意作用域。原文特意标了一句“注意:全局脚本触发条件”,说明当时就是在这上面卡过。判断脚本有没有真的跑起来,最快的办法是去 SSMS 里SELECT TOP 10 * FROM VBSReport ORDER BY [time] DESC,看最新一条的时间戳是不是刚刚——画面上的报表是查出来的,库里有数据才有得查,链路排查永远从写库这一端开始。
3. 画面组态与两个 ActiveX 控件:MSFlexGrid 和 MS DTPicker 怎么挂上去
写库通了,接下来是给人看的那一半。新建 WINCC 项目、建变量、建主画面这些是常规操作,真正耗时间的是把 MSFlexGrid 和 MS DTPicker 这两个 ActiveX 控件塞进画面并且让它们听话。这两个控件都不在 WINCC 自带控件库里,属于 VB6 时代的遗留物,能不能用、怎么注册,取决于运行这台机器的系统状态。
3.1 控件注册:regsvr32 与 MSFLXGRD.OCX
在画面里右键 ActiveX Controls,选 Add/Remove,从弹出的列表里勾上Microsoft FlexGrid Control, Version 6.0和Microsoft Date and Time Picker Control 6.0 (SP4)。列表里如果没有,说明这台机器还没注册。
原文给的处理路径是:装了 VB6 开发环境的机器可以直接选,因为安装时已经把控件注册进系统;没装的,把MSFLXGRD.OCX拷到系统目录,然后在 Windows 的“开始 → 运行”里执行注册命令:
regsvr32 MSFLXGRD.OCX regsvr32 /u MSFLXGRD.OCX第一条是注册,第二条/u是反注册,卸载或换版本的时候用。执行完会弹一个对话框,成功会明确告诉你注册成功;如果提示“找不到指定的模块”或者“模块加载失败”,基本是两个原因——文件没放到系统目录,或者放的是 64 位系统下的错误目录。WINCC 这类组态软件运行在 32 位进程里,控件必须放在 32 位系统目录(C:\WINDOWS\system32)并对 32 位注册器执行命令,别把文件丢进SysWOW64就以为完事,路径对不上注册一定失败。
# 32 位系统目录下执行,注册成功会弹确认框 cd C:\WINDOWS\system32 regsvr32 MSFLXGRD.OCX注册完成后回到画面编辑器,重新点一次 Add/Remove,列表里就应该出现这两个控件了。拖进画面后,在属性里改控件名——脚本里通过ScreenItems("MSFlexGrid1")和ScreenItems("Date1")拿对象引用,控件名和脚本里的字符串必须严格一致,改名字就得同步改脚本,这是最没技术含量但也最常见的“查询没反应”。
注意:控件注册是机器级操作,换一台运行机就得重来一遍。项目交付时把 OCX 文件、注册命令和依赖说明一起打包,比口头交代一句“装一下控件”靠谱得多。
3.2 DTPicker 的取值与日期区间拼装
日报表画面的逻辑起点是 DTPicker:用户选起始日和结束日,脚本把两个控件的Value取出来,拼成 SQL 能用的时间边界。原文里用Year()、Month()、Day()三段拼字符串,思路是对的,坑在补零和边界。
Dim Date1, Date2 Dim By, Bm, Bd, Ny, Nm, Nd Dim BeginDate, EndDate Set Date1 = ScreenItems("Date1") ' 起始日期控件 Set Date2 = ScreenItems("Date2") ' 结束日期控件 By = Year(Date1.Value) Bm = Month(Date1.Value) Bd = Day(Date1.Value) Ny = Year(Date2.Value) Nm = Month(Date2.Value) Nd = Day(Date2.Value) ' 手工补零,避免 2024-9-5 这种写法被 SQL 误解析 BeginDate = By & "-" & Right("0" & Bm, 2) & "-" & Right("0" & Bd, 2) & " 00:00:00" EndDate = Ny & "-" & Right("0" & Nm, 2) & "-" & Right("0" & Nd, 2) & " 23:59:59"Date1.Value返回的是一个完整的日期时间值,DTPicker 默认带时间部分,但用户只关心日期,所以起始日固定补00:00:00、结束日固定补23:59:59,用闭区间把当天的数据全兜进来。这里的23:59:59有个精度边界——如果time列存的是datetime类型,理论精度到 3.33 毫秒,23:59:59.997这类值会落在区间外。稳妥的写法是结束边界用下一天的00:00:00配<比较,或者写23:59:59.997。日报表这种场景丢几毫秒不影响结论,但如果哪天要做秒级对账,这个边界一定会出来咬人。
日期先后顺序的校验在原文里有了,但那行逻辑判断的运算符优先级有隐患:
' 原文写法:And 的优先级高于 Or,不加括号时判断结果可能不符合预期 If By > Ny Or By = Ny And Bm > Nm Or By = Ny And Bm = Nm And Bd > Nd Then MsgBox "输入的时间不正确", vbOK, "错误的起始时间" End If ' 我一般改成这样,把每组条件用括号包起来 If (By > Ny) Or (By = Ny And Bm > Nm) Or (By = Ny And Bm = Nm And Bd > Nd) Then MsgBox "输入的时间不正确", vbOK, "错误的起始时间" Exit Sub End If顺带加了Exit Sub。原来的写法只弹框不中断,用户看到提示之后脚本继续往下跑,照样去查一个反的区间,结果为空,还得再弹一次“没有找到符合条件的数据”。两次弹框,用户会以为是两个不同的错。
3.3 MSFlexGrid 的行列、合并与列宽设置
MSFlexGrid 的配置看着琐碎,其实就三件事:给够行数、定好列宽、把表头和数据落到对的格子里。它是 0 基索引的,TextMatrix(row, col)第一个参数是行、第二个是列,第 0 行留给合并后的标题,第 1 行放列名,第 2 行开始才是数据。
Set MSFlexGrid1 = ScreenItems("MSFlexGrid1") MSFlexGrid1.Clear MSFlexGrid1.Cols = 5 ' 数据行 + 标题行 + 列名行 + 表尾 MAX/MIN/AVG 三行 + 余量 MSFlexGrid1.Rows = oRs.RecordCount + 6 MSFlexGrid1.ColWidth(0) = 900 MSFlexGrid1.ColWidth(1) = 1900 MSFlexGrid1.ColWidth(2) = 1000 MSFlexGrid1.ColWidth(3) = 1000 MSFlexGrid1.ColWidth(4) = 1100 ' 合并第 0 行的 0~4 列,做整行标题 MSFlexGrid1.MergeCells = 4 MSFlexGrid1.MergeRow(0) = True MSFlexGrid1.TextMatrix(1, 0) = "NO." MSFlexGrid1.TextMatrix(1, 1) = "Time" MSFlexGrid1.TextMatrix(1, 2) = "CPU%" MSFlexGrid1.TextMatrix(1, 3) = "RAM%" MSFlexGrid1.TextMatrix(1, 4) = "HardDisk%"行数这里+6不是拍脑袋:1 行标题、1 行列名、N 行数据、3 行表尾统计,再加 1 行余量。少一行,表尾的 AVG 就写不进去,TextMatrix赋值的行号超界时会静默失败或者报错,具体表现取决于版本,反正不是好事。MergeCells = 4是允许按行合并,MergeRow(0) = True指定第 0 行合并,两行必须配着写,只写一行不生效。
列宽的单位是 twip,不是像素,1000 左右大概是能放下四个汉字的宽度。时间列给 1900 是因为要显示yyyy-mm-dd hh:mm:ss,短了会被截断成####。想让内容自适应,也可以用AllowUserResizing让操作工自己拖,但报表画面一般是要打印的,固定列宽反而更稳。
4. 查询按钮的 VBS 脚本:一条明细 SQL 加一条聚合 SQL
“查询”按钮的点击脚本是整套东西里最长的一段,动辄上百行。抛开变量声明,逻辑其实是两条 SQL 加一次填表:一条查明细,一条算最大值、最小值、平均值。分开查而不是在明细里算,是因为聚合结果只需要一行,混在游标里循环累加既慢又容易算错。
4.1 明细查询:CONVERT 格式化时间与 BETWEEN 区间
Dim Sql, Sql1 Sql = "SELECT CONVERT(char(19), [time], 20) AS shijian, cpu, ram, hd " & _ "FROM VBSReport WHERE [time] BETWEEN '" & BeginDate & "' AND '" & EndDate & "' " & _ "ORDER BY [time]"CONVERT(char(19), [time], 20)里的样式 20 是 ODBC 标准格式yyyy-mm-dd hh:mm:ss,正好 19 个字符。好处在于返回值已经是字符串,VBS 侧拿到就能直接往格子里塞,不用再处理日期对象的本地化显示。如果直接SELECT [time],Recordset 的字段值是日期类型,转成字符串时会按运行机的区域设置走,同一条数据在不同机器上显示成2024/9/5或者9/5/2024都有可能。
BETWEEN ... AND ...是闭区间,两头都包含,配合前面拼好的00:00:00和23:59:59正好覆盖一整天。ORDER BY [time]必须加,没有排序的SELECT返回顺序依赖存储引擎的物理分布,同一天的数据可能乱序出现,报表上看着就是时间跳来跳去。
4.2 聚合查询:MAX/MIN/AVG 一次取回
Sql1 = "SELECT AVG(cpu) AS ylp, AVG(ram) AS wdp, AVG(hd) AS llp, " & _ "MIN(cpu) AS ylx, MIN(ram) AS wdx, MIN(hd) AS llx, " & _ "MAX(cpu) AS yld, MAX(ram) AS wdd, MAX(hd) AS lld " & _ "FROM VBSReport WHERE [time] BETWEEN '" & BeginDate & "' AND '" & EndDate & "'"一条语句把九个聚合值全拿回来,比跑三次SELECT快,也少两次连接往返。别名起得短是为了后面赋值方便,oRs1("ylp")这样的写法比oRs1.Fields(0).Value可读性高一些,改字段顺序的时候也不会错位。
原文里这段赋值写在循环内部,每循环一行就把九个聚合值重写一遍。功能上没问题,但属于典型的“能跑但没必要”——把聚合值的赋值挪到循环外面,只写一次,逻辑更清楚。填表尾的行号用oRs.RecordCount + 3、+4、+5,对应前面+6的行数分配,这几个数字必须联动修改,动一处就得全动。
4.3 空结果、RecordCount 与游标类型
Set conn = CreateObject("ADODB.Connection") conn.ConnectionString = strcn conn.CursorLocation = 3 ' adUseClient,客户端游标才能稳定拿到 RecordCount conn.Open Set oCom = CreateObject("ADODB.Command") oCom.CommandType = 1 ' adCmdText Set oCom.ActiveConnection = conn oCom.CommandText = Sql Set oRs = oCom.Execute n = oRs.RecordCountCursorLocation = 3这行很关键。默认的服务端游标(adUseServer)在结果集上拿RecordCount经常返回-1,因为驱动不知道总数,要等你一条条走完才有准数。客户端游标会把结果先拉到本地,RecordCount一次就准。代价是内存,报表场景一天几千行,完全不是问题。
拿到n之后先判断If n = 0 Then弹框提示并Exit Sub,不要让它继续往下填表。原文的写法是先弹框再继续执行,后面那段填表代码在空结果集上跑,Rows = 0 + 6还是能设,但列名和表尾统计会填进一堆空值,用户看到的是一个只有表头的空壳,比直接提示“没数据”更让人困惑。
还有一个隐藏问题:oRs.Requery原文里在填表前调了一次。Requery会重新执行一遍 SQL,等于同样的查询跑两次,在数据量大的时候会明显加长点击到出结果的等待。除非有“表结构可能变化”这类特殊需求,这一行可以直接删掉。
5. 排查清单:五个在现场反复出现的坑
这套链路涉及 SQL、VBS、ActiveX 三层,出错的时候现象往往指向错误的地方。下面五条是这类项目里反复遇到的,按现象、原因、解决三段记下来,下次遇到能省不少时间。
5.1 画面与控件类
现象:点 Add/Remove 找不到 MSFlexGrid,或者拖进画面后显示成一个空白方块,运行时点查询直接报“对象不支持该属性或方法”。
原因:控件没注册,或者注册了但版本不匹配。装了 VB6 的机器上同时存在多个版本,勾选时选错版本号,属性名就会对不上。
解决:先在系统目录确认MSFLXGRD.OCX存在,用regsvr32重新注册一次,注册成功后关闭画面编辑器再打开。仍然不行就看控件属性里的版本号,脚本里用到的MergeCells、TextMatrix这些属性在老版本上名字可能不同,按实际版本改脚本,别硬套。
5.2 链接与数据类
现象:写库脚本跑着不报错,但去 SSMS 里查表一条数据都没有。
原因:触发器没挂上,或者脚本里连的库名、表名和实际建的不是同一个。原文里写库用的是VBSReport表、SQL-Report库,而查询脚本连的是Example-report库、查的是report表,两段代码如果直接混用,写进去的数据查询端根本看不到。
解决:把写库和查询两段脚本里的Initial Catalog、表名、列名逐字对齐,做成一个地方维护。全局脚本触发器的状态单独确认一遍,最快的验证方式是手工在脚本编辑器里执行一次action,看是否立刻多出一行。
现象:查询能出结果,但数据比实际少一天,或者某个时间段的记录整段缺失。
原因:日期边界问题。结束日拼成23:59:59丢掉了毫秒部分;或者月份没补零,2024-9-5在某些语言设置下被解析成了别的日期。
解决:结束边界改成次日00:00:00配<比较,或者直接写23:59:59.997;拼接日期统一用Right("0" & 月份, 2)补零。改完拿一个已知有数据的日期做对照,比对着 SSMS 里直接查的结果看行数是否一致。
5.3 运行环境与导出类
现象:点“打印”之后 Excel 起来了,表格也填进去了,但关闭画面后任务管理器里残留一堆EXCEL.EXE,机器越跑越慢。
原因:脚本里ExcelApp.Quit没执行,或者执行顺序不对——先Quit再Close,对象引用没释放,进程就挂在后台。原文里ExcelBook.Close和ExcelApp.Quit是注释掉的,调试阶段没问题,上线必须打开。
解决:按Workbook.Close→Application.Quit→ 置Nothing的顺序收尾,中间任何一步抛异常都要保证后面的清理能走到。稳妥的写法是把清理放在一段独立的错误处理里,先释放对象再退出。
现象:脚本在工程师电脑上跑得好好的,装到现场运行机上就报“未找到提供程序”或者“登录失败”。
原因:运行机没装对应的 OLE DB 提供程序,或者 WINCC 运行态使用的 Windows 账户在 SQL 实例上没有登录名。Integrated Security=SSPI是拿进程身份去认证的,工程师用管理员账户调试,运行态换成受限账户,权限就断了。
解决:在运行机上确认提供程序存在;在 SQL 实例的安全节点下给运行账户建登录名,并授予目标库的读写权限。这一步最好在项目初期就确认,别等交付前一天才发现。
6. 打印导出:把 MSFlexGrid 搬进 Excel 的收尾技巧
打印按钮的脚本本质是一次表格搬运:遍历 MSFlexGrid 的每个单元格,Trim之后写进 Excel 对应位置,再统一排版。这段代码不长,但排版参数特别容易调半天,下面几个是我常用的取值。
Set MSFlexGrid1 = ScreenItems("MSFlexGrid1") Set ExcelApp = CreateObject("Excel.Application") Set ExcelBook = ExcelApp.Workbooks.Add Set ExcelSheet = ExcelBook.Worksheets(1) ExcelApp.Visible = True ' 逐格搬运,Trim 掉控件里的占位空格 For irow = 0 To MSFlexGrid1.Rows - 1 For icol = 0 To MSFlexGrid1.Cols - 1 ExcelSheet.Cells(irow + 1, icol + 1) = Trim(MSFlexGrid1.TextMatrix(irow, icol)) Next Next z = MSFlexGrid1.Rows ExcelSheet.Range("A1:E1").Merge ExcelSheet.Range("A1:E" & z).Borders(1).Weight = 2 ' 四周加边框 ExcelSheet.Range("A1:E" & z).Borders(2).Weight = 2 ExcelSheet.Range("A1:E" & z).Borders(3).Weight = 2 ExcelSheet.Range("A1:E" & z).Borders(4).Weight = 2 ExcelSheet.Rows(1).RowHeight = 0.75 / 0.035 ' 磅换算成行高 ExcelSheet.Rows(1).Font.Name = "宋体" ExcelSheet.Rows(1).Font.Bold = True ExcelSheet.Rows(1).Font.Size = 10 ExcelSheet.Cells.HorizontalAlignment = 3 ' 居中 ExcelSheet.Cells.EntireColumn.AutoFit ExcelSheet.PrintPreview ' 调试期先看分页 ' ExcelSheet.PrintOut ' 上线后换成直接打印 ExcelBook.Close ExcelApp.Quit Set ExcelSheet = Nothing Set ExcelBook = Nothing Set ExcelApp = NothingRowHeight = 0.75 / 0.035这个除法是在做单位换算,把厘米换算成 Excel 的行高单位磅,0.75 厘米大概是打印出来比较舒服的表头高度。AutoFit要在所有内容写完之后调,否则宽度按空单元格算,导出来全是窄条。调试期用PrintPreview,上线再换PrintOut,这个切换我一般做成脚本里注释掉一行、放开另一行,交付时在说明里写清楚改哪一行。
最后一次翻车是这样一个细节:表格搬运时没Trim,MSFlexGrid 里填充的空格被原样带进 Excel,看着没内容,导出后列宽被撑得很怪,AutoFit也跟着算错。从那以后我每次做这类导出,都强制先走一遍PrintPreview看分页和列宽,确认没问题再换成直接打印,多花十秒钟,省掉一次现场返工。希望帮到你。
本文还有配套的精品资源,点击获取