农业集团信息管理系统富文本增强功能开发全记录
一、需求确认与技术栈梳理
作为湖南某农业集团技术负责人,近期接到客户核心需求:在现有信息管理系统的后台文章发布模块中新增三大功能:
现有技术栈:
- 前端:Vue2.6 + wangEditor 5.1(国产开源编辑器)
- 后端:SpringBoot 2.6.x + MySQL 8.0
- 部署环境:内网私有化部署(需支持离线环境)
- 存储方案:初期采用文件系统存储,预留对象存储接口
二、技术调研与方案选型
1. 编辑器扩展方案评估
wangEditor深度定制:
- 优势:
- 国产编辑器,对中文排版支持优秀
- 模块化设计,插件机制完善
- 与Vue集成度高(官方提供Vue组件)
- 挑战:
- 官方文档对Office粘贴支持不足
- 社区资源较UEditor少
替代方案对比:
| 扩展wangEditor | ★★★★☆ | ★★★★☆ | ★★☆☆☆ | 免费 |
| 迁移TinyMCE | ★★★★☆ | ★★★★★ | ★★★☆☆ | 企业版收费 |
| 引入CKEditor5 | ★★★☆☆ | ★★★★★ | ★★★★☆ | 按功能收费 |
| 泽优WordPaster | ★★★★★ | ★★★★★ | ★☆☆ | 完全开源(下载源码) |
| 完全免费 | ||||
| 免费技术支持(QQ群:223813913) |
决策结果:集成泽优WordPaster插件,不需要改动服务商量,不需要改动客户端,不需要改动现有业务逻辑,集成简单,使用简单,用户体验不错,完全开放源码,能够满足集团自主可控的需求。
2. 文档解析技术选型
核心组件选择:
- Word/PPT解析:Apache POI 5.2.3 + poi-tl 1.12(最新稳定版)
- Excel解析:EasyExcel 3.3.0(阿里开源,内存优化佳)
- PDF解析:PDFBox 2.0.27(Apache许可) + iText 7.2.5(商业使用需授权)
关键技术点:
// Word段落样式提取示例
XWPFDocument doc = new XWPFDocument(inputStream);
doc.getParagraphs().forEach(para -> {
// 获取段落样式
CTParaRPr rPr = para.getCTP().getPPr().getRPr();
if (rPr != null) {
// 处理字体、字号、颜色等
String font = rPr.getRFonts().getAscii();
Integer size = rPr.getSz() != null ?
Integer.parseInt(rPr.getSz().getVal()) / 2 : 12;
// 存储样式信息…
}
});
3. 存储架构设计
初期方案:
/data/storage
/docs
/{year}/{month}
/{docId}/
content.json # 文档元数据
images/ # 二进制图片
{md5}.jpg
对象存储接口设计:
public interface CloudStorageAdapter {
String upload(byte[] data, String ext, String module);
String getUrl(String key);
void delete(String key);
// 实现类示例
public static class AliyunAdapter implements CloudStorageAdapter {
// 实现阿里云OSS上传逻辑
}
}
三、核心功能实现
1. 智能粘贴功能开发
前端实现(Vue组件):
// src/components/editor/SmartPastePlugin.js
export default class SmartPastePlugin {
constructor(editor) {
this.editor = editor;
this.init();
}
init() {
// 监听粘贴事件
this.editor.on('paste', (evt) => {
const html = evt.clipboardData.getData('text/html');
if (!html) return;
// 检测微信公众号内容
if (this.isWechatContent(html)) {
this.processWechatContent(html).then(processed => {
this.editor.cmd.do('insertHTML', processed);
});
return;
}
// 处理Word粘贴
this.processWordPaste(html).then(processed => {
this.editor.cmd.do('insertHTML', processed);
});
});
}
async processWordPaste(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const images = doc.querySelectorAll('img[src^="blob:"]');
// 批量上传图片
const uploadPromises = Array.from(images).map(img => {
return fetch(img.src).then(res => res.blob())
.then(blob => this.uploadImage(blob));
});
const urls = await Promise.all(uploadPromises);
// 替换图片URL
let i = 0;
return html.replace(/]+src="blob:[^"]+"/g, () => {
return ` handlePasteImage(
@RequestParam("file") MultipartFile file) {
// 生成唯一文件名
String filename = UUID.randomUUID() +
StringUtils.getExtension(file.getOriginalFilename());
// 存储二进制数据
String path = storageService.storePasteImage(
file.getBytes(),
filename
);
return ResponseEntity.ok(new UploadResult(path));
}
}
2. 文档导入功能实现
统一导入接口设计:
@Service
public class DocumentImportService {
@Autowired
private WordImporter wordImporter;
@Autowired
private ExcelImporter excelImporter;
@Autowired
private PptImporter pptImporter;
@Autowired
private PdfImporter pdfImporter;
public ImportResult importDocument(MultipartFile file) throws IOException {
String ext = StringUtils.getExtension(file.getOriginalFilename());
switch (ext.toLowerCase()) {
case "docx":
case "doc":
return wordImporter.importDoc(file);
case "xlsx":
case "xls":
return excelImporter.importXls(file);
case "pptx":
case "ppt":
return pptImporter.importPpt(file);
case "pdf":
return pdfImporter.importPdf(file);
default:
throw new IllegalArgumentException("不支持的文档类型");
}
}
}
Word导入核心逻辑:
@Service
public class WordImporter {
@Autowired
private StorageService storageService;
public ImportResult importDoc(MultipartFile file) throws IOException {
try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) {
ImportResult result = new ImportResult();
// 处理段落
doc.getParagraphs().forEach(para -> {
String text = para.getText();
if (StringUtils.isNotBlank(text)) {
result.addContent(text);
}
// 处理段落样式
CTParaRPr rPr = para.getCTP().getPPr().getRPr();
if (rPr != null) {
StyleInfo style = new StyleInfo();
// 提取字体信息
if (rPr.getRFonts() != null) {
style.setFontFamily(rPr.getRFonts().getAscii());
}
// 提取字号
if (rPr.getSz() != null) {
style.setFontSize(Integer.parseInt(rPr.getSz().getVal()) / 2);
}
result.addStyle(style);
}
});
// 处理图片
List pictures = doc.getAllPictures();
for (XWPFPictureData pic : pictures) {
String url = storageService.storeDocImage(
pic.getData(),
pic.suggestFileExtension()
);
result.addImage(url);
}
return result;
}
}
}
四、关键问题解决
1. 样式保留方案
字体映射处理:
// 字体替换表(解决Windows特有字体问题)
private static final Map FONT_MAP = Map.of(
"Calibri", "Arial",
"Microsoft YaHei", "SimHei",
"Times New Roman", "serif"
);
public String resolveFontFamily(String original) {
return FONT_MAP.getOrDefault(original, original);
}
颜色转换工具:
// 前端颜色转换
function convertOfficeColor(officeColor) {
// 处理十六进制颜色(如#FF0000)
if (/^#[0-9A-F]{6}$/i.test(officeColor)) {
return officeColor;
}
// 处理RGB值(如rgb(255,0,0))
const rgbMatch = officeColor.match(/rgb\\((\\d+),\\s*(\\d+),\\s*(\\d+)\\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1]).toString(16).padStart(2, '0');
const g = parseInt(rgbMatch[2]).toString(16).padStart(2, '0');
const b = parseInt(rgbMatch[3]).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
}
return '#000000'; // 默认黑色
}
2. 性能优化措施
图片处理优化:
- 后端启用异步上传队列(使用Spring Batch)
- 前端实现图片懒加载(通过wangEditor的lazy-load插件)
- 大文件分片上传(基于WebUploader组件)
内存管理:
// Excel导入时使用流式处理
public void importLargeExcel(MultipartFile file) throws IOException {
try (InputStream is = file.getInputStream();
ExcelReader reader = EasyExcel.read(is).build()) {
ReadSheet sheet = EasyExcel.readSheet().build();
reader.read(sheet, new PageReadListener(dataList -> {
// 分批处理数据
processBatch(dataList);
}));
}
}
3. 安全加固方案
文件校验机制:
public class FileSecurityValidator {
private static final Set ALLOWED_TYPES = Set.of(
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf"
);
private static final Map FILE_SIGNATURES = Map.of(
"doc", hexStringToByteArray("D0CF11E0A1B11AE1"),
"pdf", hexStringToByteArray("255044462D312E"),
"xls", hexStringToByteArray("D0CF11E0A1B11AE1")
);
public static void validate(MultipartFile file) throws SecurityException {
// 1. 扩展名校验
String ext = StringUtils.getExtension(file.getOriginalFilename());
if (!ALLOWED_TYPES.contains(ext.toLowerCase())) {
throw new SecurityException("不支持的文件类型");
}
// 2. 文件头校验
try {
byte[] header = new byte[8];
try (InputStream is = file.getInputStream()) {
is.read(header);
}
byte[] expected = FILE_SIGNATURES.getOrDefault(ext.toLowerCase(), new byte[0]);
if (expected.length > 0 &&
!Arrays.equals(Arrays.copyOf(header, expected.length), expected)) {
throw new SecurityException("文件内容与扩展名不匹配");
}
} catch (IOException e) {
throw new RuntimeException("文件读取失败", e);
}
}
}
五、部署与测试
1. 私有化部署方案
Docker化部署配置:
# 后端服务
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE=target/doc-manager-*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
# 前端服务
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
MySQL配置优化:
# application.properties
spring.datasource.url=jdbc:mysql://mysql:3306/doc_db?useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=doc_admin
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-timeout=30000
2. 测试用例设计
核心功能测试:
| 粘贴功能 | 从Word复制带图片和样式的内容粘贴 | 图片正确上传,样式保留 |
| 导入功能 | 导入包含复杂表格的Excel文件 | 表格结构正确转换 |
| 性能测试 | 导入50MB的PPT文件(含50张图片) | 10分钟内完成,内存不溢出 |
| 安全测试 | 上传恶意脚本文件 | 系统拒绝并记录安全日志 |
| 兼容性测试 | 在IE11/Chrome/Firefox中粘贴Word内容 | 各浏览器表现一致 |
六、后续优化方向
整个开发周期历时8周,通过深度定制wangEditor和精心设计后端处理流程,成功实现了客户要求的复杂文档处理功能。系统在内网环境稳定运行2个月后,已开始规划向公有云环境的迁移方案,并准备申请软件著作权登记。
复制插件文件
安装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转换成图片上传到服务器中。 
上传网络图片
一键自动上传网络图片,自动下载远程服务器图片,自动上传远程服务器图片 
下载示例
点击下载完整示例




