【集团级富文本编辑器国产化集成项目纪实——从需求拆解到全栈信创落地】
2023年X月X日 周X 上海·浦东新区
一、项目背景与核心需求
作为某集团技术负责人,近期承接政府数字化项目时,客户提出关键需求:
-
功能需求:
- Word粘贴/导入:支持从Word复制内容(含表格/形状/字体样式)粘贴至编辑器,图片自动上传至华为云OBS
- 微信公众号抓取:解析公众号文章HTML,自动下载图片并替换为OBS链接
- 多格式导入:Word/Excel/PPT/PDF全格式支持,保留原始排版和图片
-
技术约束:
- 前端:Vue2-cli + wangEditor4(需无缝集成,不破坏现有JSP架构)
- 后端:JSP + Servlet(需兼容IE8及国产浏览器)
- 部署:华为云ECS(CentOS 7) + OBS对象存储(未来扩展至阿里云/Azure)
-
合规要求:
- 信创兼容:支持中标麒麟/统信UOS等国产OS,龙芯/飞腾等国产CPU
- 授权模式:买断源代码(预算≤58万),避免年度续费和商务流程
二、技术选型与风险评估
1. 富文本编辑器插件方案对比
方案 | 优势 | 风险 | 国产化适配 | 成本(买断) |
---|---|---|---|---|
wangEditor4扩展 | 原生支持Vue2,API开放 | 需自行开发Office解析模块 | 完全兼容 | ¥18万 |
UEditor信创版 | 政府项目经验丰富 | 基于Flash(IE8需polyfill) | 部分兼容 | ¥45万 |
自定义开发 | 完全可控 | 开发周期长(预估8人月) | 需适配 | ¥80万+ |
WordPaster源码版 | 完全开源(下载源码) | |||
功能完整 | ||||
集成简单 | ||||
部署简单 | ||||
使用简单 | 需要终端安装插件 | 完全兼容 | ¥9999 |
决策:选择WordPaster源码版,理由:
- 集团年项目量1000+,买断授权可摊薄成本至单项目¥0.058万
- 华为云OBS SDK提供Java/JS双版本,与JSP后端无缝集成
2. 信创环境验证清单
- 浏览器:
- CPU架构:
# 龙芯3A5000测试命令 cat /proc/cpuinfo | grep "model name" java -version # 验证龙芯JDK运行环境
三、核心功能开发实现
1. 前端集成(Vue2 + wangEditor4)
步骤1:扩展编辑器工具栏
// src/components/RichTextEditor.vue
import E from 'wangeditor'
export default {
mounted() {
const editor = new E('#editor-container')
// 添加自定义按钮
editor.config.menus = [
...editor.config.menus,
'word-paste',
'wechat-paste'
]
// 拦截粘贴事件
editor.config.pasteTextHandle = (content) => {
return this.processClipboardData(content)
}
editor.create()
},
methods: {
async processClipboardData(html) {
// 处理Word粘贴内容
const doc = new DOMParser().parseFromString(html, 'text/html')
const images = doc.querySelectorAll('img[src^="data:image"]')
for (let img of images) {
const blob = await this.dataURLtoBlob(img.src)
const formData = new FormData()
formData.append('file', blob, 'paste-image.png')
// 调用JSP后端上传接口
const res = await fetch('/file/upload', {
method: 'POST',
body: formData
})
const url = await res.text()
img.src = url
}
return doc.body.innerHTML
},
dataURLtoBlob(dataUrl) {
// BASE64转Blob实现(略)
}
}
}
步骤2:微信公众号内容抓取(跨域代理)
// Web.xml配置代理Servlet(解决跨域问题)
WechatProxy
com.group.proxy.WechatProxyServlet
WechatProxy
/proxy/wechat/*
2. 后端实现(JSP + OBS SDK)
文件上传接口:
// src/com/group/servlet/FileUploadServlet.java
@WebServlet("/file/upload")
@MultipartConfig
public class FileUploadServlet extends HttpServlet {
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
Part filePart = req.getPart("file");
String fileName = UUID.randomUUID().toString() + ".png";
// 华为云OBS配置
String endPoint = "obs.cn-east-3.myhuaweicloud.com";
String ak = "your-access-key";
String sk = "your-secret-key";
ObsClient obsClient = new ObsClient(ak, sk, endPoint);
obsClient.putObject("group-bucket", fileName, filePart.getInputStream());
// 返回OBS访问URL
String url = String.format("https://%s.obs.%s.myhuaweicloud.com/%s",
"group-bucket",
endPoint.split("\\.")[1],
fileName);
resp.setContentType("text/plain");
resp.getWriter().print(url);
}
}
Office文档解析(Apache POI):
// 处理PPT中的图片
public void extractImagesFromPpt(InputStream pptStream, String outputDir) throws IOException {
XMLSlideShow ppt = new XMLSlideShow(pptStream);
for (XSLFSlide slide : ppt.getSlides()) {
for (XSLFShape shape : slide.getShapes()) {
if (shape instanceof XSLFPictureShape) {
XSLFPictureData picData = ((XSLFPictureShape) shape).getPictureData();
byte[] data = picData.getData();
Files.write(Paths.get(outputDir, picData.getFileName()), data);
}
}
}
}
四、信创环境专项优化
1. 中文字体渲染优化
- Linux服务器配置:
# 统信UOS安装微软雅黑字体 mkdir -p /usr/share/fonts/windows wget https://example.com/msyh.ttf -O /usr/share/fonts/windows/msyh.ttf fc-cache -fv
2. 多云存储抽象层
public interface CloudStorage {
String upload(byte[] data, String fileName);
void delete(String key);
}
public class ObsStorage implements CloudStorage {
// 华为云OBS实现
}
public class OssStorage implements CloudStorage {
// 阿里云OSS实现(预留)
}
五、项目交付与验收标准
-
功能验收:
- 在龙芯3A5000 + 奇安信浏览器中成功导入200页PPT
- 微信公众号图片抓取准确率100%
-
性能指标:
- 50MB Word文件导入耗时≤5秒
- 并发上传支持50QPS
-
交付物:
- 完整源代码(含前端Vue组件+JSP后端服务)
- 《信创环境部署指南》(含龙芯/飞腾适配说明)
- 压力测试报告(LoadRunner脚本及结果)
最终成本:通过开源组件复用+华为云OBS免费额度,项目总成本控制在42万元内,较预算节约27.6%。
【项目总结】
- 选型关键:优先选择支持国产化的成熟开源框架,避免重复造轮子
- 兼容性策略:采用"渐进增强"方案,核心功能支持IE8,新特性面向现代浏览器
- 授权管理:建立集团内部开源代码库,实现授权一次复用无限
(附:项目私有GitLab仓库地址已同步至集团知识管理系统,授权码:GOV-GROUP-2023-XXXXX)
复制插件文件
安装jquery
npm install jquery
导入组件
import E from 'wangeditor'
const { $, BtnMenu, DropListMenu, PanelMenu, DropList, Panel, Tooltip } = E
import {WordPaster} from '../../static/WordPaster/js/w'
import {zyCapture} from '../../static/zyCapture/z'
import {zyOffice} from '../../static/zyOffice/js/o'
初始化组件
//zyCapture Button
class zyCaptureBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="截屏">
<img src="../../static/zyCapture/z.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
window.zyCapture.setEditor(this.editor).Capture();
}
tryChangeActive() {this.active()}
}
//zyOffice Button
class importWordBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入Word文档(docx)">
<img src="../../static/zyOffice/css/w.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
window.zyOffice.SetEditor(this.editor).api.openDoc();
}
tryChangeActive() {this.active()}
}
//zyOffice Button
class exportWordBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导出Word文档(docx)">
<img src="../../static/zyOffice/css/exword.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
window.zyOffice.SetEditor(this.editor).api.exportWord();
}
tryChangeActive() {this.active()}
}
//zyOffice Button
class importPdfBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入PDF文档">
<img src="../../static/zyOffice/css/pdf.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
window.zyOffice.SetEditor(this.editor).api.openPdf();
}
tryChangeActive() {this.active()}
}
//WordPaster Button
class WordPasterBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="Word一键粘贴">
<img src="../../static/WordPaster/w.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor).Paste();
}
tryChangeActive() {this.active()}
}
//wordImport Button
class WordImportBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入Word文档">
<img src="../../static/WordPaster/css/doc.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor).importWord();
}
tryChangeActive() {this.active()}
}
//excelImport Button
class ExcelImportBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入Excel文档">
<img src="../../static/WordPaster/css/xls.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor).importExcel();
}
tryChangeActive() {this.active()}
}
//ppt paster Button
class PPTImportBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入PPT文档">
<img src="../../static/WordPaster/css/ppt1.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor).importPPT();
}
tryChangeActive() {this.active()}
}
//pdf paster Button
class PDFImportBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="导入PDF文档">
<img src="../../static/WordPaster/css/pdf.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor);
WordPaster.getInstance().ImportPDF();
}
tryChangeActive() {this.active()}
}
//importWordToImg Button
class ImportWordToImgBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="Word转图片">
<img src="../../static/WordPaster/word1.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor).importWordToImg();
}
tryChangeActive() {this.active()}
}
//network paster Button
class NetImportBtn extends BtnMenu {
constructor(editor) {
const $elem = E.$(
`<div class="w-e-menu" data-title="网络图片一键上传">
<img src="../../static/WordPaster/net.png"/>
</div>`
)
super($elem, editor)
}
clickHandler() {
WordPaster.getInstance().SetEditor(this.editor);
WordPaster.getInstance().UploadNetImg();
}
tryChangeActive() {this.active()}
}
export default {
name: 'HelloWorld',
data () {
return {
msg: 'Welcome to Your Vue.js App'
}
},
mounted(){
var editor = new E('#editor');
WordPaster.getInstance({
//上传接口:http://www.ncmem.com/doc/view.aspx?id=d88b60a2b0204af1ba62fa66288203ed
PostUrl: "http://localhost:8891/upload.aspx",
License2:"",
//为图片地址增加域名:http://www.ncmem.com/doc/view.aspx?id=704cd302ebd346b486adf39cf4553936
ImageUrl:"http://localhost:8891{url}",
//设置文件字段名称:http://www.ncmem.com/doc/view.aspx?id=c3ad06c2ae31454cb418ceb2b8da7c45
FileFieldName: "file",
//提取图片地址:http://www.ncmem.com/doc/view.aspx?id=07e3f323d22d4571ad213441ab8530d1
ImageMatch: ''
});
zyCapture.getInstance({
config: {
PostUrl: "http://localhost:8891/upload.aspx",
License2: '',
FileFieldName: "file",
Fields: { uname: "test" },
ImageUrl: 'http://localhost:8891{url}'
}
})
// zyoffice,
// 使用前请在服务端部署zyoffice,
// http://www.ncmem.com/doc/view.aspx?id=82170058de824b5c86e2e666e5be319c
zyOffice.getInstance({
word: 'http://localhost:13710/zyoffice/word/convert',
wordExport: 'http://localhost:13710/zyoffice/word/export',
pdf: 'http://localhost:13710/zyoffice/pdf/upload'
})
// 注册菜单
E.registerMenu("zyCaptureBtn", zyCaptureBtn)
E.registerMenu("WordPasterBtn", WordPasterBtn)
E.registerMenu("ImportWordToImgBtn", ImportWordToImgBtn)
E.registerMenu("NetImportBtn", NetImportBtn)
E.registerMenu("WordImportBtn", WordImportBtn)
E.registerMenu("ExcelImportBtn", ExcelImportBtn)
E.registerMenu("PPTImportBtn", PPTImportBtn)
E.registerMenu("PDFImportBtn", PDFImportBtn)
E.registerMenu("importWordBtn", importWordBtn)
E.registerMenu("exportWordBtn", exportWordBtn)
E.registerMenu("importPdfBtn", importPdfBtn)
//挂载粘贴事件
editor.txt.eventHooks.pasteEvents.length=0;
editor.txt.eventHooks.pasteEvents.push(function(){
WordPaster.getInstance().SetEditor(editor).Paste();
e.preventDefault();
});
editor.create();
var edt2 = new E('#editor2');
//挂载粘贴事件
edt2.txt.eventHooks.pasteEvents.length=0;
edt2.txt.eventHooks.pasteEvents.push(function(){
WordPaster.getInstance().SetEditor(edt2).Paste();
e.preventDefault();
return;
});
edt2.create();
}
}
h1, h2 {
font-weight: normal;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
测试前请配置图片上传接口并测试成功
接口测试
接口返回JSON格式参考
为编辑器添加按钮
components: { Editor, Toolbar },
data () {
return {
editor: null,
html: 'dd',
toolbarConfig: {
insertKeys: {
index: 0,
keys: ['zycapture', 'wordpaster', 'pptimport', 'pdfimport', 'netimg', 'importword', 'exportword', 'importpdf']
}
},
editorConfig: {
placeholder: ''
},
mode: 'default' // or 'simple'
}
},
整合效果
导入Word文档,支持doc,docx
导入Excel文档,支持xls,xlsx
粘贴Word
一键粘贴Word内容,自动上传Word中的图片,保留文字样式。
Word转图片
一键导入Word文件,并将Word文件转换成图片上传到服务器中。
导入PDF
一键导入PDF文件,并将PDF转换成图片上传到服务器中。
导入PPT
一键导入PPT文件,并将PPT转换成图片上传到服务器中。
上传网络图片
一键自动上传网络图片,自动下载远程服务器图片,自动上传远程服务器图片