教育政府网站信创环境富文本编辑器重构记:从UEditor困境到自主适配方案的突破

一、项目启动:双重挑战下的紧急需求

2024年6月,某省级教育厅下属的继续教育平台发起紧急需求:需在1个月内完成富文本编辑器升级,核心要求包括:

  1. 功能需求

    • 完美支持Word文档粘贴(需保留文字样式、表格、图片、公式等)
    • 兼容WPS教育版生成的特殊格式
    • 支持在线批改功能(高亮、批注等)
  2. 信创环境约束

    • 操作系统:银河麒麟V10 SP1教育专版
    • 浏览器:360安全浏览器教育版(Chromium 91内核)
    • 数据库:人大金仓V8(需兼容PHP的PDO扩展)
    • 安全要求:通过等保2.0三级认证

原系统痛点

  • UEditor在信创环境下出现"幽灵字符"问题(粘贴后随机出现乱码)
  • 图片上传功能在国产防火墙拦截下频繁失败
  • 表格样式在WPS与Office间切换时完全错乱
二、技术选型:在信创生态中寻找平衡点

1. 候选方案深度测试

方案 Word粘贴准确率 信创兼容性 教育功能扩展 开发周期
TinyMCE 6 78%(基础样式) ★★☆☆☆ 需二次开发 4周
WangEditor 5 65% ★★★☆☆ 有限 2周
改写UEditor 72% ★★☆☆☆ 困难 3周
自主开发核心模块 92% ★★★★☆ 高度灵活 5周

关键发现

  • 教育行业特殊需求

    • 需支持LaTeX公式粘贴(来自MathType)
    • 批改功能需与现有阅卷系统API对接
    • 需实现"纯净模式"(过滤Word中的宏病毒风险)
  • 信创环境技术壁垒

    • 银河麒麟系统缺少libpng12库(影响图片处理)
    • 360教育版浏览器禁用了部分Clipboard API
    • 人大金仓数据库对BLOB类型支持有限
三、开发实施:分阶段攻克核心难题

阶段一:前端架构重构(Vue3实现)

// EduEditor.vue - 核心组件
import { ref, onMounted } from 'vue'
import { parseWordContent } from './word-parser' // 自定义解析器
import { uploadToJinKing } from './db-adapter' // 人大金仓适配

export default {
  setup() {
    const editorRef = ref(null)
    const isProcessing = ref(false)
    const alertMessage = ref('')

    // 信创环境专用粘贴处理
    const handlePaste = async (e) => {
      if (!e.clipboardData?.types.includes('Files') && 
          !e.clipboardData?.types.includes('text/html')) return

      isProcessing.value = true
      alertMessage.value = '正在处理文档内容...'

      try {
        // 优先处理文件粘贴(支持.docx拖拽)
        const files = Array.from(e.clipboardData.files)
        if (files.length > 0) {
          await handleFilePaste(files[0])
          return
        }

        // 处理HTML内容(Word粘贴)
        const html = e.clipboardData.getData('text/html')
        const { content, images } = await parseWordContent(html)

        // 分批上传图片(信创网络限制)
        const imageUrls = await Promise.all(
          images.map(img => uploadToJinKing(img))
        )

        // 替换图片占位符
        let finalContent = content
        imageUrls.forEach((url, idx) => {
          finalContent = finalContent.replace(
            `__IMG_PLACEHOLDER_${idx}__`, 
            ``
          )
        })

        editorRef.value.setContent(finalContent)
      } catch (error) {
        console.error('解析失败:', error)
        alertMessage.value = `处理失败: ${error.message}`
      } finally {
        isProcessing.value = false
        setTimeout(() => alertMessage.value = '', 3000)
      }
    }

    onMounted(() => {
      document.addEventListener('paste', handlePaste)
    })

    return { editorRef, isProcessing, alertMessage }
  }
}

阶段二:Word内容深度解析(关键突破)

// word-parser.js - 信创环境专用解析器
export const parseWordContent = async (html) => {
  // 创建隔离解析环境
  const parser = document.createElement('div')
  parser.innerHTML = html
  
  // 教育行业特殊处理:MathType公式
  const mathTypes = parser.querySelectorAll('[class*="MathType"]')
  mathTypes.forEach(el => {
    el.outerHTML = `${el.textContent}`
  })

  // 信创环境样式修复
  const styleFixes = [
    { 
      regex: /font-family:[^;"]*(Calibri|Arial)[^;"]*/gi,
      replace: 'font-family: "方正仿宋_GBK"' 
    },
    {
      regex: /mso-border-shadow:/gi,
      replace: 'border:1px solid #000;'
    }
  ]

  styleFixes.forEach(({ regex, replace }) => {
    parser.innerHTML = parser.innerHTML.replace(regex, replace)
  })

  // 图片提取与Base64转换(适配内网)
  const images = []
  const imgElements = parser.querySelectorAll('img')
  
  for (const img of imgElements) {
    if (img.src.startsWith('file://')) {
      // 处理本地文件(需用户授权)
      images.push({
        type: 'local',
        path: img.src.replace('file://', '')
      })
      img.src = '__IMG_PLACEHOLDER_' + (images.length - 1) + '__'
    } else if (!img.src.startsWith('data:')) {
      // 外网图片下载转Base64
      try {
        const response = await fetch(img.src)
        const blob = await response.blob()
        const reader = new FileReader()
        reader.readAsDataURL(blob)
        reader.onloadend = () => {
          images.push({
            type: 'base64',
            data: reader.result
          })
        }
      } catch (e) {
        images.push({
          type: 'error',
          alt: '[图片无法加载]'
        })
        img.src = '/static/broken-image.png'
      }
    }
  }

  return {
    content: parser.innerHTML,
    images: images.filter(i => i.type !== 'error')
  }
}

阶段三:信创数据库适配(PHP实现)

// db-adapter.php - 人大金仓专用适配器
class JinKingDB {
  private $pdo;
  
  public function __construct() {
    try {
      $this->pdo = new PDO(
        'kingbase:host=localhost;port=54321;dbname=EDU_SYSTEM',
        'edu_admin',
        'SecurePass@123'
      );
      $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    } catch (PDOException $e) {
      throw new Exception("数据库连接失败: " . $e->getMessage());
    }
  }

  // 信创环境优化的大文件存储
  public function storeAttachment($data, $filename) {
    try {
      // 分块存储(人大金仓单BLOB限制10MB)
      $chunkSize = 8 * 1024 * 1024; // 8MB
      $totalChunks = ceil(strlen($data) / $chunkSize);
      
      // 创建存储记录
      $stmt = $this->pdo->prepare("
        INSERT INTO ATTACHMENTS 
        (file_name, total_chunks, created_at) 
        VALUES (?, ?, NOW()) 
        RETURNING id
      ");
      $stmt->execute([$filename, $totalChunks]);
      $attachmentId = $this->pdo->lastInsertId();

      // 存储各分块
      for ($i = 0; $i < $totalChunks; $i++) {
        $chunk = substr($data, $i * $chunkSize, $chunkSize);
        $stmt = $this->pdo->prepare("
          INSERT INTO ATTACHMENT_CHUNKS 
          (attachment_id, chunk_index, chunk_data) 
          VALUES (?, ?, ?)
        ");
        $stmt->execute([$attachmentId, $i, $chunk]);
      }

      return "/attachments/merge/$attachmentId";
    } catch (Exception $e) {
      error_log("存储失败: " . $e->getMessage());
      return false;
    }
  }
}
四、信创环境专项优化

1. 字体兼容方案

/* 强制使用教育系统预装字体 */
.edu-content {
  font-family: "方正书宋_GBK", "汉仪楷体_GBK", "思源黑体 CN", sans-serif;
}

/* 公式特殊处理 */
.math-formula {
  font-family: "Latin Modern Math", "Cambria Math";
  background: #f5f5f5;
  padding: 2px 4px;
  border-radius: 3px;
}

2. 浏览器兼容补丁

// 修复360教育版浏览器的Clipboard API
if (navigator.userAgent.includes('360SE-Edu')) {
  const nativePaste = HTMLDocument.prototype.paste;
  HTMLDocument.prototype.paste = function(e) {
    // 延迟处理以绕过浏览器安全限制
    setTimeout(() => {
      const customEvent = new CustomEvent('eduPaste', {
        detail: {
          html: window.clipboardData.getData('text/html'),
          text: window.clipboardData.getData('text')
        }
      });
      this.dispatchEvent(customEvent);
    }, 100);
    
    if (nativePaste) nativePaste.apply(this, arguments);
  };
}
五、测试与部署

1. 信创环境测试矩阵

测试场景 银河麒麟+龙芯 统信UOS+飞腾 中标麒麟+兆芯
Word复杂样式保留 94% 91% 88%
20MB大文件粘贴 成功(12s) 成功(15s) 成功(18s)
与WPS交互兼容性 100% 98% 95%
等保2.0安全扫描 0高危漏洞 0高危漏洞 0高危漏洞

2. 性能优化措施

  • 图片处理

    // 信创环境专用图片压缩
    async function compressImage(file) {
      return new Promise((resolve) => {
        const img = new Image()
        img.onload = () => {
          const canvas = document.createElement('canvas')
          const ctx = canvas.getContext('2d')
          
          // 信创设备性能适配
          const quality = navigator.hardwareConcurrency > 4 ? 0.8 : 0.6
          
          canvas.width = img.width
          canvas.height = img.height
          ctx.drawImage(img, 0, 0)
          
          resolve(canvas.toDataURL('image/jpeg', quality))
        }
        img.src = URL.createObjectURL(file)
      })
    }
    
  • PHP内存管理

    ; php-fpm.conf 信创专项配置
    pm.max_children = 10
    pm.start_servers = 4
    pm.min_spare_servers = 2
    pm.max_spare_servers = 6
    request_terminate_timeout = 300
    
六、项目总结与行业启示
  1. 信创开发三大原则

    • 早测试原则:在真实信创环境(非模拟器)验证每个功能
    • 渐进增强原则:先实现核心功能,再逐步优化兼容性
    • 离线优先原则:所有依赖必须支持本地化部署
  2. 教育行业特殊经验

    • 公式编辑需同时支持MathType和LaTeX
    • 批改功能需与现有阅卷系统API深度集成
    • 必须通过教育装备行业协会的认证测试
  3. 独立开发者生存指南

    • 建立自己的信创测试环境(成本约¥18,000)
    • 与本地信创厂商建立技术支持通道
    • 对政府教育项目预留40%的不可预见成本

最终成果:新系统在客户信创环境中稳定运行3个月,处理Word文档2,300余份,样式保留准确率达到93%,获得客户"信创教育应用优秀案例"表彰。这次经历证明**:在信创领域,没有完美的现成方案,只有通过深度定制实现的可用方案**。目前正在将该解决方案封装为Vue组件库,计划在教育行业内部技术社区开源共享。

复制插件文件

WordPaster插件文件夹
安装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格式参考

为编辑器添加按钮

添加按钮

整合效果

wangEditor4整合效果

导入Word文档,支持doc,docx

粘贴Word和图片

导入Excel文档,支持xls,xlsx

粘贴Word和图片

粘贴Word

一键粘贴Word内容,自动上传Word中的图片,保留文字样式。
粘贴Word和图片

Word转图片

一键导入Word文件,并将Word文件转换成图片上传到服务器中。
导入Word转图片

导入PDF

一键导入PDF文件,并将PDF转换成图片上传到服务器中。
导入PDF转图片

导入PPT

一键导入PPT文件,并将PPT转换成图片上传到服务器中。
导入PPT转图片

上传网络图片

一键自动上传网络图片,自动下载远程服务器图片,自动上传远程服务器图片
自动上传网络图片

下载示例

点击下载完整示例

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐