news 2026/10/10 7:57:13

告别乱码红叉:国产化CMS如何无缝兼容帝国CMS的Word导入

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
告别乱码红叉:国产化CMS如何无缝兼容帝国CMS的Word导入

做国产化CMS替代这几年,我跟各种“老系统搬家”打过交道。如果说数据库迁移是硬仗,那Word导入这种细活儿就是验收环节最容易翻车的地方。很多单位原来用帝国CMS,编辑们早就习惯了“Word里写好了、复制粘贴进后台、图片自动传、排版基本不变”,结果换到国产化CMS,粘贴进去的不是一堆乱码就是满屏花样式,图片全部变红叉,表格东倒西歪。领导一句“以前帝国CMS不是好好的吗”就能让项目组集体陷入沉默。

这篇文章就围绕“国产化CMS怎么兼容帝国CMS的Word导入功能”这件事展开。我会先把帝国CMS那个“Word导入”背后的机制拆清楚,再讲兼容方案的整体设计,然后给出一套可直接落地的实操改造流程,最后附上我在项目中实打实踩过的坑和排查清单。内容主要面向正在做信创替代、CMS迁移、内容中台建设的开发者和运维人员,也适合产品经理拿去评估工作量。

1. 帝国CMS的Word导入到底做了什么

1.1 从“写好了贴进去”说起

帝国CMS后台的Word导入,表面上是个不起眼的功能,但背后是两层处理逻辑在配合。

第一层是编辑器粘贴处理。用户在Word里复制内容时,系统剪切板里其实同时存了两份数据:一份是text/plain纯文本,一份是text/html富文本。Word生成的HTML和浏览器里普通网页的HTML完全不是一个东西,里面会有大量以mso-开头的内联样式、微软命名空间标签、VML图形标记,甚至还有<!--[if !mso]>之类的条件注释。帝国CMS自带编辑器在接收到这段HTML后,会执行一次清理动作:把微软专属的标签和样式剥掉,只留下p、div、span、strong、em、table这些基础结构,再把内容插入编辑区。

第二层是图片处理。Word粘贴出来的图片通常以base64形式嵌在HTML里,帝国CMS的编辑器会把这段base64数据抽取出来,通过后台脚本转成服务器上的实际图片文件,写入附件表的同时把编辑器里的图片地址替换成站点路径。这一步完成后,用户看到的就是图片正常显示、排版基本可用的文章。

另外还有一条“上传Word文档导入”的路径,用户直接doc/docx文件丢到后台,服务端解析文档XML结构,把文字和图片抽取出来拼成HTML片段。老版本帝国CMS在Windows环境里会调用COM组件直接驱动Word完成转换,这也是后来国产化环境里最让运维头疼的一段逻辑。

1.2 国产化之后为什么直接翻车

搞清楚原始机制,再看国产化环境翻车的原因就非常清晰了。

国产化CMS大多跑在Linux平台上,PHP或Java技术栈,数据库从MySQL换到达梦、人大金仓、OceanBase这些国产库。帝国CMS老站很多还是GBK字符集,国产化CMS几乎默认UTF-8。这几个基础差异叠加起来,直接导致原先那条走得通的链路断掉了。

首先是COM组件不可用。Windows专属的Word自动化解析,到了麒麟、统信服务器版上根本没得跑,老代码里凡是new COM("Word.Application")这种写法直接报错。其次是编辑器清理逻辑丢失。很多国产化CMS用的是开源的wangEditor、UEditor、TinyMCE,默认没有针对Word剪贴板的深度清理,或者清理算法不够狠,导致粘贴后样式乱飞。再次是浏览器变化。办公终端换成信创浏览器之后,一些老编辑器依赖的document.execCommand和选区API行为跟IE时代完全不一样,粘贴事件拿不到预期的HTML数据。

最隐蔽的坑在数据库类型转换。帝国CMS的附件表和正文表如果直接搬到国产库,原MySQL里的MEDIUMTEXT字段到了达梦对应的是CLOB,TEXT对应关系要仔细核对,字段长度限制不同,分页语法也不同。正文HTML本身是文本,随便放倒是没问题,但如果你在迁移时把附件路径、图片URL这些字段的长度定义搞错了,后期Word导入传图的时候就会频繁报错,而且报错信息非常不直观。

所以兼容这件事,不是装个插件就完事,而是要把“剪贴板接收、HTML清洗、图片上传、数据库落位”这一整条链路重新搭一遍。

2. 兼容方案怎么设计才不返工

2.1 三条路线的取舍

在做技术选型之前,我先把市面上能走通的路梳理了一遍,大致是三条路线。

第一条是编辑器粘贴适配。保持国产化CMS现有的富文本编辑器不变,前端监听paste事件拦截剪贴板数据,自己写HTML清洗逻辑,把base64图片抽出来异步上传,最后把处理好的干净HTML放进编辑器。这条路开发量适中,日常操作体验最接近帝国CMS原生效果,适合绝大多数内容编辑场景。

第二条是服务端文档转换。用户上传原始docx文件,后端调用LibreOffice无头模式或者Apache POI解析,转换成HTML片段后入库。这条路胜在批量导入能力强,排版保真度更高,适合处理历史存量文档,但服务器上要额外部署转换组件,转换结果依然需要二次清洗。

第三条是前端解析docx。用mammoth.js这类库在浏览器端直接解析上传的Word文件,优点是省掉了服务端组件,但处理大文件时浏览器会明显卡顿,复杂表格和嵌套样式保真度一般,适合内部小流量场景。

三条路我实际对比下来,没有哪条是银弹。如果你只做编辑器粘贴适配,存量Word文档还是得人工重新编辑;如果只做文档上传解析,日常“复制粘贴”的工作流又没覆盖到。所以我在项目里基本采用双轨结构。

2.2 我推荐的“双轨并行”结构

所谓双轨,就是把“从Word里复制粘贴”和“上传Word文档”两条路径分开处理,但共享同一套清洗服务和图片上传接口。

日常编辑场景走粘贴适配:前端拦截剪贴板,拿到HTML后先在前端做粗清洗,把微软垃圾样式剥掉,图片转成Blob异步上传,再插入编辑器。用户完全感知不到中间发生了什么,体验跟帝国CMS一样顺滑。

批量导入场景走LibreOffice转换:后台新增一个“Word导入”按钮,用户上传docx文件,服务端转换为HTML集合,经过同样的清洗逻辑后进入正文编辑区。这里的关键点不是把LibreOffice转出来的HTML直接入库,而是要让转换结果先经过清洗和图片上传,确保所有图片URL、附件路径都符合国产化CMS的存储规范。

双轨共用的清洗服务,最好独立成一个内部接口。即使前端某些浏览器兼容性出问题,服务端还能兜底再清一遍。我在几个项目里都是这样做的,后面扩展新编辑器或者改版的时候,只需要替换前端那一层,后端清洗逻辑完全不用动。

2.3 接口先定好,后面都好办

设计接口时不用搞很复杂的协议,重点是稳定和语义清晰。我一般固定这样三个接口。

POST /api/word/upload-img接收multipart图片文件,返回包含图片URL的JSON。这个接口不是给普通编辑器上传图片用的,它专门服务Word导入场景,所以后端可以加独立的目录前缀,方便后续排查问题。

POST /api/word/clean-html接收原始HTML文本,返回清洗后的HTML。服务端用PHP或Java做兜底清洗,专门处理那些前端清洗漏掉的情况。

POST /api/word/import-doc接收docx文件,服务端完成转换、清洗、图片上传之后,返回可直接插入编辑器的HTML片段。这个接口给批量导入场景用。

接口定义先定好,前后端并行开发时就不会互相卡住。尤其是图片上传接口,一定要约定好返回的是相对路径还是绝对路径。我建议统一返回相对路径,这样以后换域名、加CDN时不用全表刷数据。

3. 实操:从Word粘贴到干净HTML的完整改造

3.1 前端先接住剪贴板里的HTML

改造的第一步,是在编辑器初始化之后挂上paste事件监听。核心代码不复杂,关键是不要依赖event.clipboardData.items去读文件,因为部分国产浏览器对这个属性的支持不太稳定,用getData('text/html')最保险。

editor.addEventListener('paste', function (e) { var html = e.clipboardData && e.clipboardData.getData('text/html'); if (!html) { var text = e.clipboardData.getData('text/plain'); if (text) { insertHtml('<p>' + escapeHtml(text).replace(/\n/g, '<br>') + '</p>'); } return; } var cleaned = cleanWordHtml(html); processImages(cleaned, function (htmlWithImages) { insertHtml(htmlWithImages); }); e.preventDefault(); });

第二种情况判断。Word粘贴时剪贴板里一定会有text/html,拿不到基本不是真的Word复制,而可能是从某个文本框复制的纯文本。这时候取纯文本包一层p标签再转行,至少保证内容不丢。

拿到HTML之后,要立刻执行e.preventDefault()阻止编辑器默认的粘贴行为,否则浏览器原生粘贴和我们的自定义处理会打架,最后页面上出现两份内容。原编辑器自带的粘贴事件处理器如果已经被绑定,需要确认是否会冲突,通常用命名空间或者直接替换处理器。

3.2 清洗Word垃圾代码:别迷信正则

Word生成的HTML脏到什么程度呢?打开一份典型文档转换出来的源码,能看到xmlns:o、xmlns:v、xmlns:w这些命名空间声明,还有v:shape、v:imagedata、o:OLEObject这类VML对象标签,以及大量style="mso-xxx:yyy"的微软私有样式。如果把这些原样塞进编辑器,页面会变得不可控。

我的建议是不要用正则硬清,正则处理嵌套标签时很容易误伤。靠谱的方式是用DOMParser把HTML解析成DOM树,然后遍历所有节点,对照白名单和黑名单做增删。

var FORBIDDEN_TAGS = ['script', 'style', 'xml', 'link', 'meta', 'v:shape', 'v:group', 'v:imagedata', 'v:textbox', 'o:OLEObject', 'w:anchorlock']; var WHITE_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'DIV', 'SPAN', 'STRONG', 'EM', 'U', 'S', 'UL', 'OL', 'LI', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'CAPTION', 'IMG', 'A', 'BLOCKQUOTE', 'BR', 'HR']; function cleanWordHtml(rawHtml) { var doc = new DOMParser().parseFromString(rawHtml, 'text/html'); var all = doc.body.getElementsByTagName('*'); for (var i = all.length - 1; i >= 0; i--) { var node = all[i]; var tag = node.tagName.toLowerCase(); if (FORBIDDEN_TAGS.indexOf(tag) >= 0) { node.parentNode.removeChild(node); continue; } if (WHITE_TAGS.indexOf(node.tagName) < 0) { var span = doc.createElement('span'); while (node.firstChild) { span.appendChild(node.firstChild); } node.parentNode.replaceChild(span, node); } } return doc.body.innerHTML; }

这里有个细节:遍历删除节点时要倒序处理,因为getElementsByTagName返回的是动态集合,正序删除会跳过节点。白名单之外未知标签统一降级成span,内容不丢失,只是语义层级没有了。

样式清理不是把整个style属性删掉,而是删掉其中以mso-、_mso-开头的声明块,再删掉word-break、word-wrap这类Word专属属性。保留颜色、字号、对齐方式这些常见样式,用户视觉体验会好很多。我处理时还会顺手把空的class、id属性清掉,避免引入未知样式污染。

3.3 图片从base64变成服务器文件

清洗完HTML之后,页面里嵌着的base64图片如果不处理,会出现两个问题:一是base64内容撑爆编辑器数据,文章保存时请求体积巨大;二是图片不落库,以后文章导出、迁移、归档都很被动。

处理逻辑很简单:遍历所有img节点,发现src以data:image开头的,就把base64转成Blob,通过FormData上传到图片接口,拿到返回URL后回填到img的src属性。

function handleBase64Image(img, done) { var src = img.getAttribute('src'); if (!src || src.indexOf('data:image') !== 0) { done(); return; } var parts = src.split(','); var mime = parts[0].match(/data:(.*?);/)[1]; var bytes = atob(parts[1]); var ab = new ArrayBuffer(bytes.length); var ia = new Uint8Array(ab); for (var i = 0; i < bytes.length; i++) { ia[i] = bytes.charCodeAt(i); } var blob = new Blob([ab], { type: mime }); var fd = new FormData(); fd.append('file', blob, 'word-image-' + Date.now() + '.' + (mime.indexOf('png') >= 0 ? 'png' : 'jpg')); fetch('/api/word/upload-img', { method: 'POST', body: fd }) .then(function (res) { return res.json(); }) .then(function (res) { if (res.status === 0) { img.setAttribute('src', res.url); } done(); }) .catch(function () { done(); }); }

上传时要注意几个点。并发控制:一篇Word文档里二三十张图片很常见,如果全部同时发请求,国产服务器上的Nginx和小型应用容器很容易被打满连接数,我习惯用并发上限5的队列逐批处理。尺寸压缩:如果是超过1MB的高清图片,在后端做一次等比压缩生成缩略图,正文里默认展示压缩图,原图保留在附件库里。图片比例:Word粘贴出来的图片经常自带固定的width和height属性,如果原文档模板宽高写死,到了移动端会溢出,清洗时如果img没有明确百分比,就给加一个max-width:100%的样式。

3.4 后端入库和附件路径处理

图片上传接口后端部分,用PHP或者Java实现差别不大,核心是安全处理和路径规范。接收文件后先校验MIME类型,只允许image/jpeg、image/png、image/gif、image/webp这几种,禁止用扩展名做唯一判断。文件名不要用用户上传的原名,因为中文名、超长名、特殊符号在国产Web容器里容易触发编码问题,统一用日期加随机串重新生成。

$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); $allow = ['jpg', 'jpeg', 'png', 'gif', 'webp']; if (!in_array($ext, $allow)) { exit(json_encode(['status' => 1, 'msg' => 'not allowed'])); } $filePath = '/data/cms/upload/word/' . date('Ymd') . '/' . md5(uniqid()) . '.' . $ext; move_uploaded_file($_FILES['file']['tmp_name'], $filePath); echo json_encode(['status' => 0, 'url' => '/upload/word/' . date('Ymd') . '/' . basename($filePath)]);

图片落库后,正文存储的URL我坚持用站点相对路径,不拼域名。这样文章迁环境、换HTTPS、接CDN都不受影响。保存文章时,正文内容里如果还有残留的http://旧站域名/upload/...这类历史绝对路径,入库前先做一次正则替换,把它们统一成相对路径。

附件表的设计上,除了常规的id、文件名、路径、类型、大小、上传时间之外,我建议加一个source_type字段,标识这条附件是Word粘贴导入、LibreOffice转换还是普通上传,出问题排查时能一眼看出来源。biz_id字段关联文章ID,方便后续做附件清理。

3.5 国产数据库适配的细节

国产化CMS的数据库适配,看着是DBA的事,但Word导入链路写库时能不能顺畅跑起来,开发也必须心里有数。

我这里踩过的坑主要有三个。第一个是正文大字段类型。MySQL里文章正文常用MEDIUMTEXT,最大16MB,到了达梦里对应的不是TEXT而是CLOB,人大金仓则要看初始化时的兼容模式。建表语句如果直接照搬MySQL的TEXT定义,导入时遇到超长内容会报错。第二个是自增主键。达梦使用IDENTITY定义自增列,人大金仓可以用SERIAL,语法和MySQL的AUTO_INCREMENT不一样,Entity层的插入语句也要跟着调。第三个是分页语法。帝国CMS老代码里的LIMIT $offset, $rows写法,在达梦非兼容模式下会报语法错误,需要改成标准SQL的分页写法,或者在初始化数据库时开启MySQL兼容模式。

这些适配建议在项目启动阶段就别拖到联调才算,否则Word导入功能测得好好的,一到大数据量回归就炸,定位半天才发现是数据库方言问题。

4. 批量历史文档导入:LibreOffice头转换

4.1 为什么选LibreOffice

日常编辑走粘贴适配已经覆盖了大多数场景,但历史存量Word文档不可能让编辑团队重新复制粘贴一遍,必须提供批量导入入口。我在生产环境里比较推荐LibreOffice,原因是完全免费、活跃维护、对docx解析比较稳定,而且国产化CPU架构(ARM、LoongArch)上也有对应的安装包,规避了商业组件在信创环境下的授权和兼容问题。

有人可能会问为什么不用Apache POI。POI在纯Java环境里很灵活,但处理复杂版式时效果一般,尤其带文本框、公式、批注的文档,解析结果经常漏内容。LibreOffice是完整办公套件渲染,转换出来的HTML结构完整度更高。

4.2 服务器部署和转换命令

以麒麟V10服务器版为例,安装LibreOffice后,核心转换命令是:

soffice --headless --convert-to html --outdir /data/convert /data/upload/guide.docx

--headless表示无界面运行,适合服务器环境。转换完成后,/data/convert目录下会生成一个同名的HTML文件,和一堆与文档同名的静态资源文件夹,图片都在里面。

实际生产环境里有几个坑要提前规避。LibreOffice默认不允许Root用户执行soffice,如果没有专用服务账号,要用-env:UserInstallation=file:///tmp/lo_profile参数指定临时用户配置目录。并发转换多个任务时,soffice偶发锁冲突,我的做法是用一个排队队列串行执行,或者用--norestore参数关闭文档恢复功能降低锁冲突概率。超时控制也必须有,30页的大文档转换耗时可能超过一分钟,接口层和前端都要容忍这个延迟,不然用户会以为是卡死了。

4.3 从转换结果到正式入库

LibreOffice转出来的HTML,同样不能直接用。它的标签结构和Word原始HTML不一样,但依然会有大量内联样式和相对路径的图片引用。我的处理方式是:先读取转换产生的HTML文件,把其中的img的src从相对路径替换成服务器实际路径,然后调用前文提到的清洗接口做规范化处理,最后把处理好的HTML片段返回给前端编辑器。

这里特别要注意资源目录的归属处理。转换产生的图片资源,需要从临时目录拷贝到CMS统一的/upload/word/目录,并且文件名避免用原文档资源目录里的随机名。我把这个拷贝动作放在了转换成功后立即执行,确保临时目录被清理后文章正文里的图片仍然可访问。

批量导入时,如果文档数量较多,建议一次性导入控制在50份以内,超时就分批,否则服务器上的soffice进程和数据库写入会把IO拖垮。

5. 常见问题与排查技巧实录

5.1 问题速查表

我把项目中遇到的高频问题整理成了一张速查表,基本覆盖了绝大多数Word导入兼容的坑。

症状可能原因处理思路
粘贴后样式全乱,满屏彩字编辑器没有清理mso样式DOMParser白名单清洗,去mso-前缀样式
图片全部显示红叉base64图片未上传,或上传接口路径返回错误检查img上传流程和返回URL格式
文章变成一行,没有分段剪贴板只取到纯文本优先读text/html,纯文本按换行转br
中文乱码,出现“锟斤拷”数据库或页面编码不一致连接串强制UTF-8,源库GBK数据先转码
大文档粘贴卡死浏览器base64图片同步转上传改异步并发上传,限制并发数
表格整体溢出屏幕表格宽度用像素写死清洗时重置table宽度为百分比,加max-width
后台提示“您还未登录”后台session超时或统一登录未对接延长session有效时间,对接SSO重写拦截逻辑
发布者显示不是当前用户登录态串了或用户ID透传错误发布时重新从session取用户信息,不用隐藏域
转换服务偶发超时soffice并发锁冲突加队列串行执行,指定独立UserInstallation目录

5.2 一条排查思路和两个预防习惯

排查这类问题,我自己的习惯是先把链路切成三段看:前端剪贴板数据是否拿到、清洗后HTML是否符合预期、上传和入库是否成功。每一段都留日志,尤其是清洗前后的HTML对比。没有日志时,很多问题看起来都像“玄学”,有了日志之后基本都能定位到具体环节。

两个预防习惯非常值得养成。第一个习惯是上线前准备100份真实业务Word文档做回归测试,不要自己随便写几份就提交。真实文档里什么奇怪的样式都有,编号列表、嵌套表格、文本框、流程图,清洗规则暴露得越早越好。第二个习惯是保留清洗规则的单元测试样例,每次升级清洗逻辑时先把以前的样例跑一遍,防止改一处坏一片。

另外还有一个容易被忽略的登录态问题。国产化CMS如果做了统一登录对接,后台编辑器接口一定要确认token的传递方式。我遇到过前端编辑器里图片上传成功、正文保存却报“您还未登录”的情况,原因是上传接口走了独立的鉴权中间件,而保存接口走的是另一套session校验,两套鉴权体系没有打通。这种问题在联调阶段就要重点测。

最后再分享一个操作细节

我做国产化迁移这几年,最深的体会就是“兼容”不是功能层面的照搬,而是把老功能背后的用户习惯一起接过来。帝国CMS的Word导入之所以被高频使用,是因为老编辑们早就形成了肌肉记忆,你换了新系统,这个肌肉记忆没换。国产化CMS要兼容的不只是一堆HTML和图片上传接口,而是那种“我在Word里写完了,粘贴进后台就能发”的无缝感。

最后分享一个我一直在用的小技巧:Word粘贴内容的清理脚本里,正则不要做得过猛。保留一部分常见的内联样式,尤其是字体颜色、加粗、对齐,用户会明显觉得比以前那个“全变成纯文本”的导入功能好用。很多项目不是死在技术上,而是死在导入后内容“看起来不一样了”带来的信任危机。

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

Log4j2、Logback、Log4j1对比:架构演进与异步性能实测

1. 三个框架的血缘与定位&#xff1a;Log4j1为何仍在、Logback为何主流、Log4j2为何激进Java 生态里能同时存在三个长期共存的日志框架&#xff0c;本来就是一件很罕见的事。Log4j1、Logback 和 Log4j2 看起来都在做同一件事——输出日志&#xff0c;但它们的出生年代、设计思路…

作者头像 李华
网站建设 2026/10/10 7:56:21

DeepSeek大模型工程实践:Dify+PaddleOCR智能文档系统落地指南

1. 这不是“笔记”&#xff0c;而是一份大模型工程实践手记“DeepSeek大模型学习笔记”这个标题听起来像学生时代的课堂记录&#xff0c;但实际翻看技术社区里那些被高频引用的所谓“笔记”&#xff0c;你会发现它们根本不是摘抄定义、默写公式——而是带着明确问题意识、踩过真…

作者头像 李华
网站建设 2026/10/10 7:54:14

DesCTF Misc WriteUp:从图片隐写到内存取证的全流程复现

CTF里的Misc&#xff0c;一直是我觉得最考验选手“信息嗅觉”的一个分类。它不考你某个渗透框架用得多熟&#xff0c;也不考某个漏洞利用链背得多全&#xff0c;它考的是你对“数据载体”本身的敏感度——一张照片的像素最低位、一段音频的频谱图、一坨看起来毫无规则的流量包、…

作者头像 李华
网站建设 2026/10/10 7:54:10

Codex反复重连5/5?从心跳机制到日志定位的完整排查指南

如果你也在用 Codex 跑一些耗时比较长的工程任务&#xff0c;大概率遇到过这样一个画面&#xff1a;任务进行到一半&#xff0c;终端底部突然出现一行Reconnecting...&#xff0c;计数器从 1 慢慢爬到 5&#xff0c;你以为它要恢复了&#xff0c;结果数字停在 5/5 没多久&#…

作者头像 李华
网站建设 2026/10/10 7:53:43

基于SpringBoot的城区不动产综合服务平台设计与实现

每年到这个时间点&#xff0c;总能看到一大批计算机专业的同学在选题上纠结&#xff0c;尤其是“城市房产信息网”“不动产综合服务平台”这类题目&#xff0c;几乎是毕业设计里的常青树。但要提醒一句&#xff1a;这类系统看着简单&#xff0c;真做起来&#xff0c;十个里面有…

作者头像 李华
网站建设 2026/10/10 7:53:16

Redis替代方案实测:Valkey、Dragonfly、Garnet选型避坑指南

最近后台好多朋友都在问同一个事情&#xff1a;Redis替代产品深度对比&#xff0c;到底该信哪一份结论&#xff1f;Valkey能不能无缝替换&#xff1f;Dragonfly的吞吐量是不是真有宣传的那么夸张&#xff1f;Garnet这种新面孔又敢不敢直接上生产&#xff1f;问的人多了&#xf…

作者头像 李华