news 2026/9/8 4:36:34

WinCC嵌入式Excel报表:VBS脚本读取归档数据实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WinCC嵌入式Excel报表:VBS脚本读取归档数据实战

半夜接到值班电话,说车间报表抓取不到数据,第二天要交班记录。我打开WinCC一看,归档数据明明都在趋势图里画得好好的,但导出的CSV文件用Excel打开,时间列全变成了"####"或者科学计数法,操作工看了直摇头。这种时候你就能明白,为什么那么多工控人宁愿自己折腾一个嵌入Excel的报表系统,也不愿意用WinCC自带的报表导出功能。

这篇内容就是把我做WinCC嵌入式Excel报表的完整思路和代码拆开讲一遍:如何让WinCC画面里直接嵌一个Excel界面,读取历史归档数据,按班次、按日期一键生成报表。适合正在被WinCC报表功能折磨的工程师、调试人员,也适合打算自己搭一套轻量报表体系的朋友参考。

1. 为什么我坚持在WinCC里嵌Excel做报表:两个绕不开的痛点

1.1 WinCC自带报表功能的真实体验

西门子WinCC本身不是不能做报表,只是做起来总觉得"差口气"。用过WinCC Online Table Control和Online Trend Control的朋友应该深有体会:这两个控件查趋势、看历史数据确实没问题,在画面上拉一条曲线、摆一张实时表都很方便,但一旦涉及"把数据导出去加工"这个动作,体验就断崖式下跌。导出格式虽然可以选CSV或者Excel,但实际导出来的文件格式非常"原始"——列宽不自动调整、时间格式需要手工设置、标题行缺失、同一个变量在不同时间点的数据混在一起。你拿到这个导出文件之后,还要在Excel里做大量二次加工,才能做成一份能交给领导的报表。

更麻烦的是,WinCC自带的报表组件(比如Report Designer)虽然能设计打印模板,但它的设计界面跟Excel完全是两套逻辑。组态工程师本来花一小时就能在Excel里排好一张班报表模板,放到Report Designer里可能要一下午,而且调试预览、打印对齐这些环节特别消耗耐心。对于大多数中小型项目来说,为了一套报表功能去专门培训组态人员使用Report Designer,投入产出比实在太低。

1.2 Excel嵌入式方案的适用边界

所以我的做法很直接:与其把Excel数据导来导去,不如让报表直接长在画面里。用一个嵌入式Excel控件,让WinCC操作员画面里直接出现一个Excel界面,通过脚本查询归档数据库,把历史数据填进指定单元格,然后让操作工一键打印或者另存为。操作工不用学任何新东西——他们本来就会用Excel,打开画面就能上手。

不过先说清楚这个方案的适用范围,免得有人拿着它去跟商业报表软件硬碰硬。如果你的项目有几十台PLC、上千个归档变量、需要复杂的权限审批和审计追踪,那该上PI System或者AVEVA historian这类专业历史库,这不是Excel该干的活。Excel嵌入式报表最适合的场景是:单机或小型SCADA系统,变量数量在几十到几百个之间,报表主要是班报、日报、月报,需要的格式灵活多变,而且你希望用户自己就能调整模板。这种场景下,Excel嵌入式方案无论是开发成本还是后期维护成本,都比上商业报表工具划算得多。

我自己测试下来的经验是,当归档变量的采集周期在1秒以上、单次查询的数据量在几千行以内时,VBS脚本连数据库查询并填充Excel,整个过程基本可以控制在两秒以内完成,完全能接受。如果变量多到上万点、每次查询几十万条记录,那Excel处理起来就开始吃力了,这时候你就该考虑换方案。

2. 整体技术方案与数据链路:从归档库到Excel单元格

2.1 方案选型:为什么最终选了VBS脚本而不是C脚本

WinCC支持C脚本和VBScript两种脚本方式,很多做WinCC组态的老工程师习惯写C脚本,因为WinCC的C脚本功能确实强大,能操作画面对象、系统函数、内部变量,而且执行效率比VBS高。但在"读写Excel报表"这个具体场景里,我反而建议优先用VBScript。

原因很实际:Excel本身提供了一套完整的COM自动化接口,VBScript作为微软亲儿子,调用COM对象时语法更自然,代码更简洁。比如创建Excel对象、打开工作簿、写入单元格、设置格式,这些操作在VBS里只需要几行代码就能完成。C脚本调用COM当然也能做,但需要处理一堆VARIANT类型转换,代码量至少翻一倍,而且C脚本调试起来没有VBS那么直观。

另外还有一个切入点:WinCC自带的ANALYSC控件的内部逻辑也是基于VBS的,很多现成的查询函数可以直接用VBS调用,生态上更顺。所以我最终定的方案是:WinCC画面用VBS脚本作为"粘合剂",一边通过ADO对象连接WinCC的历史归档数据库,另一边通过Excel.Application对象操作嵌入画面里的Excel控件。

2.2 数据流向与核心架构

这套报表系统的数据链路可以画成一条清晰的流水线:

第一段是数据源。WinCC在运行时会自动把归档变量写入它自带的SQL Server实例,数据库里存放了每个变量的历史值和时间戳。第二段是查询层。我们用VBS脚本里的ADODB组件去执行一条SQL查询语句,按变量名和时间范围从归档表里取出数据。第三段是展示层。查询出来的记录集通过循环写入Excel单元格,Excel负责渲染表格样式、计算统计值、生成图表。最外层就是WinCC的画面,操作工在画面里点击一个按钮,脚本执行,报表生成,整个过程都在同一个窗口内完成。

这段架构里最关键的决策是:数据链路中间不经过任何中间文件。我没用"先导出CSV到磁盘,再让Excel去打开"的方案,因为那样会产生临时文件,而且在WinCC运行状态下频繁读写文件,容易遇到文件占用、权限不足的坑。直接让脚本在内存里把Recordset记录集搬到Excel单元格里,干净利落。

2.3 环境准备:WinCC归档数据库结构速览

要写查询脚本,必须先搞清楚WinCC把数据存在了哪里。WinCC的历史归档数据库来自它捆绑安装的SQL Server,数据库名称的格式一般是CC_计算机名_日期时间这样的动态命名规则,每次项目激活都会生成对应的运行库。查询历史数据时,我们主要关心两类表:一类是变量元数据表(在WinCC 7.x里常见的是PDE开头或META前缀相关的表),用来根据TAG名字查到对应的内部ID;另一类是实际的历史数据表,模拟量归档和数字量归档往往分表存放。

不同版本的WinCC表结构会有一些差异,所以写查询脚本前一定要先用数据库管理工具看一眼实际的表名和字段名。我第一次搞的时候就是照着网上的旧教程写表名,结果查询直接报"对象名无效",后来用管理工具连上去一看,表名后缀跟教程里完全不一样。这一点很重要:别直接抄我的表名字符串,先打开数据库确认你那个版本的实际表结构,再改脚本里的表名。

另外提醒一句,WinCC有一个专门的"WinCC OLDE DB Provider"(也常叫Connectivity Pack),通过它可以更规范地访问归档数据。如果项目里装了Connectivity Pack,建议优先用这个Provider而不是直接连SQLOLEDB,因为它是西门子官方提供的访问接口,做了一些内部优化,查询大数据量时更稳。我下面的示例脚本先用的是SQLOLEDB加数据库直连的方式,这是在没有安装Connectivity Pack时也能跑的最小方案。

3. 核心代码拆解:VBS脚本如何把归档数据“搬”进Excel

3.1 建立数据库连接并查询历史归档表

先看一段最核心的查询骨架,这段脚本我用的是WinCC Global Script里的VBS,直接在WinCC的脚本编辑器中书写即可:

Dim sConn, sSQL, strComputer Dim oConn, oRS strComputer = "localhost\WinCC" ' SQL Server实例名,注意以实际项目为准 sConn = "Provider=SQLOLEDB;Data Source=" & strComputer & ";Integrated Security=SSPI;" & _ "Initial Catalog=CC_MyProject_2024_06_30;" Set oConn = CreateObject("ADODB.Connection") oConn.ConnectionString = sConn oConn.Open sSQL = "SELECT ValueName, Value, TimeStamp " & _ "FROM PDE#TAG " & _ "WHERE ValueName = 'YourTagName' AND TimeStamp >= '2024-06-30 00:00:00'" Set oRS = CreateObject("ADODB.Recordset") oRS.Open sSQL, oConn, 1, 1

这段代码有四个地方需要根据实际环境替换:Data Source里的SQL Server实例名、Initial Catalog里的数据库名、表名PDE#TAG、以及查询条件里的变量名。我记得在多次项目里,卡住大家基本都是卡在这四个参数上。建议第一次写的时候,先用管理工具查清楚实际库名和表名,再回来改脚本,不要凭直觉推断。

如果你装了Connectivity Pack,连接串可以换成这样,原理类似,但提供程序会优雅一些:

sConn = "Provider=WinCC.OLEDB.1;Data Source=localhost\WinCC;" & _ "Initial Catalog=CC_MyProject_2024_06_30;"

3.2 FILETIME时间戳转换:最容易翻车的环节

WinCC归档表里的时间戳通常以FILETIME格式存储,这是一种从1601年1月1日零点开始按100纳秒为单位计数的Windows内部时间格式。直接把原始数字弄到Excel里,你得到的是一个天文数字,得先做转换才能变成人可以读的日期时间。

善用SQL语句在查询阶段就把时间算好,是效率最高的办法。FILETIME转成易于读取的小数,核心思路是:用FILETIME除以一天的100纳秒总数(864000000000),得到"从1601年算起的天数",再减去1601年到1899年12月31日之间的天数差值(109205),就得到Excel可以直接识别的日期序列号。最后的数值在VBS里用CDate转换一下就能得到日期时间对象。

我在脚本里是这样实现的:

' 假设查询结果里有一列是FILETIME的数值 Dim dblFileTime, dblExcelDate, dtmTime dblFileTime = oRS.Fields("TimeStamp").Value dblExcelDate = dblFileTime / 864000000000# - 109205 dtmTime = CDate(dblExcelDate)

注意,这里的前提是数据库返回的TimeStamp字段是标准FILETIME数值。我在不同版本的WinCC上遇到过字段返回类型不一致的情况,有返回双精度浮点的,也有返回整数大数的。稳妥起见,你可以先调试输出一下这个字段的值,和WinCC在线趋势里的时间比对一下,确认转换公式是否吻合。如果对不上,多半是返回类型有差异,需要调整除法因子。

如果你不想在VBS里处理这层转换,还有一个替代方案:直接在SQL查询里用DATEADD函数算好,把可读的时间字符串返回给VBS。这样做的好处是脚本里少一段转换逻辑,坏处是SQL语句在不同版本SQL Server上的函数行为略有差异,调试SQL时要留意。

3.3 数据写入Excel并完成基本格式处理

数据库查询到记录集之后,写Excel的核心就是遍历记录集、把字段值赋给单元格。下面这段代码演示了如何把变量名、时间、数值三列写进Sheet的A、B、C列:

Dim oExcel, oSheet, iRow Set oExcel = CreateObject("Excel.Application") Set oSheet = oExcel.Workbooks.Open("D:\ReportTemplate.xlsx").Sheets(1) iRow = 2 ' 假设第1行是标题行 Do While Not oRS.EOF oSheet.Cells(iRow, 1).Value = oRS.Fields("ValueName").Value oSheet.Cells(iRow, 2).Value = CDate(oRS.Fields("TimeStamp").Value / 864000000000# - 109205) oSheet.Cells(iRow, 3).Value = oRS.Fields("Value").Value iRow = iRow + 1 oRS.MoveNext Loop oExcel.ActiveWorkbook.Save oExcel.Quit

这段代码只是一个最小示范,实际项目里需要补充很多细节:写入前先清空目标区域旧数据、设置单元格列宽和字体、为数值列设置数字格式等。我习惯的做法是准备一个制作好的Excel模板,在模板里把标题行、边框、公式都预先设好,脚本只需要往数据区域填充原始值,格式问题交给模板解决。这样可以把脚本里的大量格式代码抽掉,脚本逻辑简单很多,后期调整报表样式也不容易影响脚本。

特别注意:操作Excel对象时,脚本结束后一定要调用oExcel.Quit释放进程,否则时间久了你会发现运行WinCC的机器上残留一大堆Excel.exe进程,每个进程占用几十MB内存,最终把工控机拖垮。

4. Excel嵌入画面的两种落地方式与画面布局细节

4.1 方式一:OLE对象直接嵌入画面

WinCC画面编辑器里可以直接插入OLE对象,然后在对象类型里选择"Microsoft Excel Worksheet"。这种方式最直接,在开发模式下你可以直接双击进入Excel编辑状态,像操作普通Excel文件一样调整表头、设置公式,运行时它就是画面里的一块"活的Excel区域"。

但这种方式有个非常讨厌的限制:运行时操作工能看到的Excel菜单栏和工具栏极其有限,WinCC OLE容器只暴露了很少的操作接口。如果你的需求仅仅是"打开报表看数据、点打印按钮打印",那问题不大;但如果操作工希望能在画面上直接调整列宽、筛选数据、改公式,OLE方式就非常束手束脚。而且OLE对象在画面刷新时有概率出现重绘闪烁,这在现场大屏幕上是比较影响观感的。

4.2 方式二:Spreadsheet控件方式(更推荐)

后来我改用了支持COM调用的嵌入式表格控件方案,这种方式本质上是通过第三方表格控件托管在WinCC画面中,脚本通过控件暴露的接口访问其工作簿。WinCC本身也自带一个叫"Spreadsheet Control"的控件(有些版本默认安装,有些需要在安装时勾选),它可以嵌入WinCC画面,而且脚本可以直接操作控件的Workbook对象。

相比纯OLE嵌入,Spreadsheet控件的优势很明显:脚本层面能精准控制数据填充、格式设置和刷新,运行时画面更干净,不会弹出Excel乱七八糟的菜单。操作工看到的就是一个嵌入在画面里的干净表格控件,数据由后台脚本填充,不需要接触底层的Excel进程。

如果你没有装WinCC自带的Spreadsheet控件,也可以用MSFlexGrid、VSFlexGrid等第三方表格控件来替代,原理都一样——控件负责显示,脚本负责数据填充。选择控件时关注两点:一是支持单元格合并和边框设置,很多报表样式依赖这两个能力;二是能调整行高列宽且性能不差,几千行数据滚动起来要流畅。

4.3 画面布局与启动时的初始化建议

在实际工程里做画面布局时,我建议不要把报表区域铺满整个画面。一个好的交互是:画面底部或右侧留出一块区域放查询条件(变量选择框、起始时间输入框、查询/打印按钮),报表表格区域占画面主体。操作工的思路是"先选条件,点查询,再看到结果",这比一打开画面就全屏刷数据要舒服得多,也避免了一启动就大量查询拖慢画面加载速度。

这里还有一个非常容易忽略的细节:画面进入时不要立刻初始化Excel对象。WinCC画面加载过程中调用外部COM组件的启动速度不可控,如果在PictureOpen事件里同步创建Excel对象,有可能让整个画面卡住几十秒。我的经验是把初始化动作放到"查询"按钮的Click事件里,或者放到一个单独的初始化按钮上,操作工真正需要看报表时才触发创建。这样既保证了画面切换流畅,也避免WinCC运行时候因为Excel启动占资源导致报警闪烁卡顿。

5. 实测中踩过的坑与应对方案

5.1 查询结果为空,但曲线图里明明有数据

我在项目现场遇到过最诡异的问题:WinCC画面里的趋势曲线显示历史数据没问题,但报表脚本查询同一时间段的归档记录,返回的记录集却是空的。

排查下来发现的共性原因是:归档变量在WinCC里的存储是有分段条件的,有些变量在某个时间段里根本没有归档,或者归档变量名与查询语句里的名字大小写不一致。WinCC的归档变量名是区分大小写的,你从变量管理里复制出来的名字后面偶尔会带空格或者隐藏字符,肉眼看不出来,但SQL查询一比对就查不到。

排查方法很简单:先用数据库管理工具手动执行一遍SQL语句,看看能不能查出数据。如果手动查询有数据、脚本查询没有,问题几乎肯定出在连接串或者变量名的隐藏字符上。把变量名字符串用Len()函数打印出来数一下字符数,和变量管理器里的名字对比,就能找出问题。

5.2 Excel进程残留导致文件被锁死

Excel对象如果脚本报错没有走到Quit那一行,会留下一个Excel.exe僵尸进程,而且它可能还占用着模板文件句柄。下次脚本再打开同一个模板时,文件被锁,报"Permission denied"。很多人在现场被这个问题搞得头大,重启WinCC进程也未必能释放,因为Excel进程是独立的,只能去任务管理器手动杀掉。

我在框架里统一加了异常处理,不管脚本是否出错,都要执行Excel退出动作:

On Error Resume Next ' ... 中间是Excel操作 ... If Not oExcel Is Nothing Then oExcel.ActiveWorkbook.Close False oExcel.Quit Set oExcel = Nothing End If

如果程序已经异常退出留下了残进程,可以用一行命令行批量杀掉:

taskkill /F /IM EXCEL.EXE

不过要小心,如果工控机上操作工正好开了别的Excel文件在办公,这行命令会把他打开的文件也一起关掉。所以这只是应急手段,根本解法还是脚本里把错误处理和资源释放写规范。

5.3 跨日期查询的时间边界问题

做日报、月报时,查询时间范围的下边界和上边界经常出问题。比如查某一天的数据,习惯上写TimeStamp >= '2024-06-30 00:00:00' AND TimeStamp <= '2024-06-30 23:59:59',但实际操作中这种写法偶尔会漏掉最后一秒的归档点,或者因为时间精度问题把边界卡得太死。

保险的写法是右边界用"小于下一天零点":

TimeStamp >= '2024-06-30 00:00:00' AND TimeStamp < '2024-07-01 00:00:00'

这样不管时间戳的精度是秒还是毫秒,都不会漏数据,也不会多算。这是一个很小但很实用的经验,尤其做跨月报表时,时间边界处理不对会导致月底最后一天的数据对不上账。

5.4 工控机性能波动导致查询超时

有的老工控机配置不高,数据库查询本身可能就要消耗一两秒,再加上Excel启动、数据填充、单元格格式重算,整个报表生成过程可能长达十几秒。期间如果WinCC画面动画还在高频刷新,CPU就会飙高,甚至造成查不到数据的假象。

针对这个情况,我在脚本里加了一个查询超时控制,并在线程级给查询加上重试机制。有条件的项目,也可以把采集周期从500毫秒拉长到1秒,归档量直线下降,查询性能会明显改善。实在资源紧张,还有一个妥协方案:报表数据提前在后台线程里查询好并缓存,操作工点查询时只做数据加载,不做实时SQL查询。

6. 再进一步:报表模板化与定时导出的扩展思路

6.1 把固定报表改造成用户可配置模板

脚本如果硬编码了变量名和起始时间,后期换项目几乎等于重写。我一开始也是这么干的,后来接手第二个项目时发现重写一遍的代价太大了,于是改成模板驱动的方式。具体做法是在Excel里单独建一个"配置"Sheet,里面规定哪些单元格放什么变量、查询哪张归档表、时间范围是哪个单元格。脚本启动时先读配置Sheet,再动态生成SQL和填充位置。这样换一个项目时,只需要改Excel模板,脚本本身一行都不用动。

这个思路做通之后非常省心。哪怕同一个项目里,不同班组可能有各自的报表格式偏好,只要复制模板改一下配置Sheet,就能快速生成新报表,不需要组态工程师介入。我在多个项目里验证下来,这个"模板即配置"的模式比在代码里维护一堆报表格式参数要稳定得多。

6.2 定时自动生成Excel报表

除了手动点按钮查询,还可以加一个定时器,实现"每班结束自动生成上一班次报表并存到共享目录"。具体做法是在WinCC的全局脚本里创建一个周期触发的动作,时间到了就调用报表生成函数,把生成的Excel文件保存到指定路径,并自动加上班次日期作为文件名后缀。

不过定时自动执行有个风险:凌晨三点生成报表时如果Excel对象创建失败或被杀毒软件拦截,报表就会丢失,而且操作工未必知道。建议生成成功后在WinCC报警消息里提示一次,生成失败也提示一次,这样值班人员能第一时间知道报表有没有落盘。我有一次就是忽略了失败提示,到月底才发现有三天没生成报表,后来补数据补到崩溃。

6.3 配套视频教程里的几个补充细节

因为这次分享的内容比较多,我把完整的演示流程做成了配套视频,视频里特意补了几个没在文章里展开的细节。一个是WinCC变量管理器和数据库表字段名的对应关系,视频里用管理工具一步一步查给你看,这样你就知道该怎么在自己项目里定位表名。另一个是Excel模板制作的过程,包括条件格式怎么在模板里预置、数据有效性下拉框怎么设置,这些在模板阶段做好,脚本阶段就完全不用管格式。

还补充了一个WinCC里按钮触发VBS脚本的挂接方式,以及定时触发动作在全局脚本编辑器里的配置步骤。视频里演示用的示例工程也可以下载,虽然是示例版本,但骨架结构可以直接套用。

到目前为止,这套嵌入式Excel报表系统已经在我手里跑过三个项目,从养殖场的环境监控日报,到生产线的能耗班报,再到水处理厂的水质趋势报表,基本都稳定运行。要说最大的体会,就是千万不要低估Excel模板设计在报表系统里所占的工作量——工程上真正值钱的往往不是那几行脚本代码,而是模板背后对业务逻辑的理解。报表不是把数据堆出来就完了,什么时候要累计、什么时候要平均、哪些数据要标红提醒,这些业务规则才是报表的灵魂。把业务规则在Excel模板里固化成公式和条件格式,让脚本只负责填原始数据,这套系统的可维护性会远超你的预期。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 4:35:22

Python输入输出全解析:从input()到文件读写,避开常见坑

很多刚接触Python的朋友&#xff0c;一上来就盯着变量、循环、函数这些东西猛学&#xff0c;结果真到自己写点小工具的时候&#xff0c;反而卡在“怎么把数据弄进去、怎么把结果拿出来”这个最基础的环节上。这次借着“课件1-3&#xff1a;Python输入输出”这个主题&#xff0c…

作者头像 李华
网站建设 2026/9/8 4:34:48

开源AI编程代理opencode实战:安装配置、IDE联动与团队使用指南

最近好几个读者问我 opencode 到底能不能打&#xff0c;正好我这两天也在鼓捣它&#xff0c;就把安装、配置、接手老项目、接 IDE 这一整套流程都过了一遍。先说结论&#xff1a;opencode 是一个跑在终端里的开源 AI 编程代理&#xff0c;你可以把它理解成 Claude Code、Codex …

作者头像 李华
网站建设 2026/9/8 4:33:27

PHP+MySQL游戏聚合站部署全攻略:源码包到1500+游戏上线

简介&#xff1a;一个以PHP和MySQL为核心技术栈的在线游戏网站源码包&#xff0c;适用于具备基础编程能力、希望完整经历动态网站开发全流程的初学者&#xff0c;也适合正在做课程设计或毕业设计的学生参考。站点主体包含1500余款游戏数据&#xff0c;具备前台展示、分类点选、…

作者头像 李华
网站建设 2026/9/8 4:33:23

架构假设如何变成可执行代码?从评审到测试的落地指南

我做代码评审这些年&#xff0c;最常遇到的场景不是代码写得差&#xff0c;而是架构图画得漂亮&#xff0c;代码却完全是另一回事。架构师在评审会上说&#xff1a;“订单服务必须在 200 毫秒内返回&#xff0c;我们基于这个超时假设做降级。”开发同学点头“明白”&#xff0c…

作者头像 李华
网站建设 2026/9/8 4:31:50

开源雷达周刊2026-W35:开源动态、项目精选与工程避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 4:31:14

Java基础入门:从环境配置到集合框架的完整避坑指南

最近后台收到不少私信&#xff0c;问的全是同一个问题&#xff1a;“想学 Java&#xff0c;到底从哪里开始&#xff1f;为什么我照着教程敲代码&#xff0c;还是一堆报错&#xff1f;” 我太懂这种感觉了&#xff0c;当年第一次配环境变量的时候&#xff0c;照着网上教程一步步…

作者头像 李华