重庆PHP程序员的CMS企业官网开发日记

客户需求分析

各位群友大家好,我是重庆一个苦逼的PHP程序员,最近接了个CMS企业官网的外包项目。客户爸爸提出了一个"丧心病狂"的需求:要在TinyMCE编辑器里增加Word/Excel/PPT/PDF导入功能,还要支持Word一键粘贴,更要保留各种复杂格式和公式!

需求清单:

  1. 编辑器插件包,开箱即用
  2. 支持Word/Excel/PPT/PDF导入
  3. 保留图片、字体、表格等所有样式
  4. 特别要支持各种公式:
    • Latex公式转MathML
    • MathType公式
    • 公式图片(emz/wmz格式)
  5. 微信公众号内容导入
  6. 图片自动上传到阿里云OSS
  7. 预算控制在680元以内(老板你真会砍价)

技术选型分析

经过三天三夜的调研(其实就是熬夜刷GitHub和Stack Overflow),我评估了以下几个方案:

  1. Mammoth.js - 适合Word转HTML,但对公式支持较差
  2. Pandoc - 功能强大但部署复杂,不适合开箱即用
  3. Office JS - 微软官方方案,但只能在Office在线使用
  4. 商业编辑器插件 - 价格普遍在2000+(超出预算)

最终我决定:自己造轮子!不,是组合现有开源组件打造一个定制解决方案。

解决方案架构

前端方案 (Vue2 + TinyMCE)

// src/components/TinyMCEEditor.vue



import tinymce from 'tinymce/tinymce'
import Editor from '@tinymce/tinymce-vue'
import 'tinymce/themes/silver'
import 'tinymce/plugins/image'
import 'tinymce/plugins/link'
import 'tinymce/plugins/table'

// 自定义插件
import './plugins/docimport/plugin.js'

export default {
  components: { Editor },
  data() {
    return {
      content: '',
      disabled: false,
      editorInit: {
        height: 500,
        menubar: false,
        plugins: [
          'advlist autolink lists link image charmap print preview anchor',
          'searchreplace visualblocks code fullscreen',
          'insertdatetime media table paste code help wordcount',
          'docimport' // 我们的自定义插件
        ],
        toolbar: 'undo redo | formatselect | bold italic backcolor | \
                  alignleft aligncenter alignright alignjustify | \
                  bullist numlist outdent indent | removeformat | \
                  docimport', // 添加我们的按钮
        images_upload_handler: (blobInfo, success, failure) => {
          this.uploadImage(blobInfo.blob()).then(url => {
            success(url)
          }).catch(err => {
            failure(err.message)
          })
        }
      }
    }
  },
  methods: {
    async uploadImage(file) {
      const formData = new FormData()
      formData.append('file', file)
      
      try {
        const response = await this.$http.post('/api/upload', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        })
        return response.data.url
      } catch (error) {
        throw new Error('上传失败: ' + error.message)
      }
    }
  },
  mounted() {
    tinymce.init({})
  }
}

自定义TinyMCE插件

// src/components/plugins/docimport/plugin.js
(function() {
  tinymce.PluginManager.add('docimport', function(editor) {
    editor.ui.registry.addButton('docimport', {
      text: '导入文档',
      icon: 'upload',
      tooltip: '从Word/Excel/PDF导入内容',
      onAction: function() {
        // 创建文件选择对话框
        const input = document.createElement('input')
        input.type = 'file'
        input.accept = '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf'
        input.style.display = 'none'
        
        input.onchange = async function(e) {
          const file = e.target.files[0]
          if (!file) return
          
          editor.setProgressState(true)
          
          try {
            // 处理文件上传和转换
            const formData = new FormData()
            formData.append('docfile', file)
            
            const response = await fetch('/api/docimport', {
              method: 'POST',
              body: formData
            })
            
            const result = await response.json()
            if (result.error) {
              throw new Error(result.error)
            }
            
            // 插入转换后的HTML
            editor.insertContent(result.html)
            
            // 处理图片上传
            if (result.images) {
              for (const img of result.images) {
                const imgResponse = await fetch('/api/upload', {
                  method: 'POST',
                  body: img.data,
                  headers: { 'X-File-Name': img.name }
                })
                const imgUrl = await imgResponse.json()
                editor.insertContent(``)
              }
            }
            
          } catch (error) {
            editor.notificationManager.open({
              text: '导入失败: ' + error.message,
              type: 'error'
            })
          } finally {
            editor.setProgressState(false)
          }
        }
        
        document.body.appendChild(input)
        input.click()
        document.body.removeChild(input)
      }
    })
  })
})()

后端PHP处理 (Zend Studio开发)

// application/controllers/DocImportController.php
 'application/msword',
        'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'xls' => 'application/vnd.ms-excel',
        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'ppt' => 'application/vnd.ms-powerpoint',
        'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        'pdf' => 'application/pdf'
    ];
    
    // 临时目录
    protected $_tempDir = '/tmp/docimport';
    
    public function init()
    {
        if (!file_exists($this->_tempDir)) {
            mkdir($this->_tempDir, 0777, true);
        }
    }
    
    public function indexAction()
    {
        $this->_helper->layout->disableLayout();
        $this->_helper->viewRenderer->setNoRender(true);
        
        try {
            // 检查上传
            if (!isset($_FILES['docfile'])) {
                throw new Exception('没有上传文件');
            }
            
            $upload = $_FILES['docfile'];
            if ($upload['error'] !== UPLOAD_ERR_OK) {
                throw new Exception('上传错误: ' . $upload['error']);
            }
            
            // 验证文件类型
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mime = finfo_file($finfo, $upload['tmp_name']);
            finfo_close($finfo);
            
            $extension = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));
            if (!array_search($mime, $this->_allowedTypes) || 
                !isset($this->_allowedTypes[$extension])) {
                throw new Exception('不支持的文件类型');
            }
            
            // 保存临时文件
            $tempFile = tempnam($this->_tempDir, 'doc_');
            move_uploaded_file($upload['tmp_name'], $tempFile);
            
            // 根据文件类型处理
            $result = [];
            switch ($extension) {
                case 'doc':
                case 'docx':
                    $result = $this->_processWord($tempFile);
                    break;
                case 'xls':
                case 'xlsx':
                    $result = $this->_processExcel($tempFile);
                    break;
                case 'ppt':
                case 'pptx':
                    $result = $this->_processPowerpoint($tempFile);
                    break;
                case 'pdf':
                    $result = $this->_processPdf($tempFile);
                    break;
            }
            
            // 返回结果
            echo json_encode([
                'success' => true,
                'html' => $result['html'],
                'images' => $result['images'] ?? []
            ]);
            
        } catch (Exception $e) {
            echo json_encode([
                'success' => false,
                'error' => $e->getMessage()
            ]);
        }
    }
    
    protected function _processWord($file)
    {
        // 使用unoconv转换(需要服务器安装unoconv)
        $outputFile = tempnam($this->_tempDir, 'html_');
        exec("unoconv -f html -o $outputFile $file");
        
        $html = file_get_contents($outputFile);
        
        // 处理图片(在HTML中提取base64图片并准备上传)
        $images = [];
        $dom = new DOMDocument();
        @$dom->loadHTML($html);
        
        $imgTags = $dom->getElementsByTagName('img');
        foreach ($imgTags as $img) {
            $src = $img->getAttribute('src');
            if (strpos($src, 'data:') === 0) {
                // 提取base64数据
                list($type, $data) = explode(';', $src);
                list(, $data) = explode(',', $data);
                $data = base64_decode($data);
                
                $images[] = [
                    'name' => uniqid() . '.png',
                    'data' => $data
                ];
                
                // 临时替换为占位符,前端会重新上传
                $img->setAttribute('src', 'about:blank');
            }
        }
        
        $html = $dom->saveHTML();
        
        // 处理Latex公式(简单示例,实际需要更复杂的解析)
        $html = preg_replace_callback('/\\\$\\\(.*?)\\\$/', function($matches) {
            $latex = htmlspecialchars($matches[1]);
            return "{$matches[0]}";
        }, $html);
        
        return [
            'html' => $html,
            'images' => $images
        ];
    }
    
    // 其他文件类型处理方法...
    protected function _processExcel($file) { /* ... */ }
    protected function _processPowerpoint($file) { /* ... */ }
    protected function _processPdf($file) { /* ... */ }
    
    public function uploadAction()
    {
        $this->_helper->layout->disableLayout();
        $this->_helper->viewRenderer->setNoRender(true);
        
        try {
            // 这里处理前端直接上传的图片
            $input = fopen("php://input", "r");
            $temp = tmpfile();
            stream_copy_to_stream($input, $temp);
            fclose($input);
            
            // 获取文件名(如果有)
            $filename = $_SERVER['HTTP_X_FILE_NAME'] ?? 'upload_' . time();
            
            // 上传到OSS
            $ossClient = new OSS\OssClient(
                OSS_ACCESS_KEY,
                OSS_SECRET_KEY,
                OSS_ENDPOINT
            );
            
            $object = 'uploads/' . date('Ymd') . '/' . uniqid() . '_' . $filename;
            $result = $ossClient->uploadFile(OSS_BUCKET, $object, stream_get_meta_data($temp)['uri']);
            
            echo json_encode([
                'url' => $result['info']['url'],
                'success' => true
            ]);
            
        } catch (Exception $e) {
            echo json_encode([
                'success' => false,
                'error' => $e->getMessage()
            ]);
        }
    }
}

部署方案

  1. 前端部署

    • 将自定义插件放在public/js/tinymce/plugins/docimport目录
    • 在TinyMCE初始化配置中添加我们的插件
  2. 后端部署

    • 需要安装unoconv(用于文档转换):
      sudo apt-get install unoconv
      
    • 安装PHP OSS SDK:
      composer require alibaba/oss-sdk-php
      
  3. 服务器配置

    • 确保ECS服务器有足够的内存处理文档转换
    • 配置OSS的访问权限

预算控制

如何在680元预算内完成?

  1. 开源组件

    • TinyMCE社区版(免费)
    • unoconv(免费)
    • 开源公式解析库(如MathJax)
  2. 云服务

    • 使用OSS按量付费(预计每月<10元)
    • ECS使用共享型实例(最便宜的配置)
  3. 人工成本

    • 自己加班搞定(0成本)
    • 或者在群里招募小伙伴合作(分摊成本)

各位技术大佬、创业先锋、副业达人看过来!

我这里有个"钱"景无限的项目需求:

🔥 CMS企业官网编辑器增强插件 🔥

✅ 支持Word/Excel/PPT/PDF导入
✅ 保留所有复杂格式和公式
✅ 一键粘贴Word内容
✅ 图片自动上传OSS
✅ 开箱即用的编辑器插件

技术栈:Vue2 + TinyMCE + PHP + MySQL + 阿里云

合作方式

  1. 技术入股(你出技术,我出需求,分成比例你说了算)
  2. 项目外包(按功能点计价,绝不拖欠)
  3. 代理分销(推荐客户拿20%提成,躺着赚钱)

加入我们

  • 交流技术,共同进步
  • 分享项目,一起赚钱
  • 新人加群送1~99元红包
  • 推荐客户提成20%,月入过万不是梦

🚀 QQ群号:223813913
💼 适合人群

  • 想找副业的程序员
  • 想接外包的公司
  • 想拓展人脉的技术爱好者

群里不定时发放福利,已经有小伙伴通过推荐拿到第一桶金了!你还在等什么?

后续开发计划

  1. 第一阶段(已实现):

    • 基本文档导入功能
    • 图片自动上传
    • 简单格式保留
  2. 第二阶段(进行中):

    • 完善公式支持(Latex/MathType)
    • 微信公众号内容抓取
    • 批量图片处理优化
  3. 第三阶段(计划中):

    • 离线处理能力
    • 多格式导出
    • 团队协作功能

总结

这个项目虽然挑战不小,但也是一次很好的学习机会。通过组合现有开源组件,我们可以在预算内实现大部分功能。关键是要处理好各种文档格式的转换和公式的渲染。

欢迎各位群友提供建议和解决方案,特别是如果有处理emz/wmz格式公式图片的经验,请不吝赐教!让我们一起来完成这个"不可能的任务",顺便赚点外快!

记住,QQ群号:223813913,加群就有红包拿,推荐客户还有高额提成,这么好的机会哪里找?赶紧加入我们吧!

复制插件

WordPaster插件文件夹

安装jquery

npm install jquery

在组件中引入

  // 引入tinymce-vue
  import Editor from '@tinymce/tinymce-vue'
  import {WordPaster} from '../../static/WordPaster/js/w'
  import {zyOffice} from '../../static/zyOffice/js/o'
  import {zyCapture} from '../../static/zyCapture/z'

添加工具栏

//添加导入excel工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor).importExcel()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('excelimport', {
        text: '',
        tooltip: '导入Excel文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('excelimport', {
        text: '',
        tooltip: '导入Excel文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('excelimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加word转图片工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().importWordToImg()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('importwordtoimg', {
        text: '',
        tooltip: 'Word转图片',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('importwordtoimg', {
        text: '',
        tooltip: 'Word转图片',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('importwordtoimg', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加粘贴网络图片工具栏按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().UploadNetImg()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('netpaster', {
        text: '',
        tooltip: '网络图片一键上传',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('netpaster', {
        text: '',
        tooltip: '网络图片一键上传',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('netpaster', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入PDF按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().ImportPDF()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('pdfimport', {
        text: '',
        tooltip: '导入pdf文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('pdfimport', {
        text: '',
        tooltip: '导入pdf文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('pdfimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入PPT按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor);
      WordPaster.getInstance().importPPT()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('pptimport', {
        text: '',
        tooltip: '导入PowerPoint文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('pptimport', {
        text: '',
        tooltip: '导入PowerPoint文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('pptimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加导入WORD按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    function selectLocalImages(editor) {        
      WordPaster.getInstance().SetEditor(editor).importWord()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('wordimport', {
        text: '',
        tooltip: '导入Word文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('wordimport', {
        text: '',
        tooltip: '导入Word文档',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('wordimport', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

//添加WORD粘贴按钮
(function () {
    'use strict';
    var global = tinymce.util.Tools.resolve('tinymce.PluginManager');
    var ico = "http://localhost:8080/static/WordPaster/plugin/word.png"
    function selectLocalImages(editor) {
      WordPaster.getInstance().SetEditor(editor).PasteManual()
    }

    var register$1 = function (editor) {
      editor.ui.registry.addButton('wordpaster', {
        text: '',
        tooltip: 'Word一键粘贴',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
      editor.ui.registry.addMenuItem('wordpaster', {
        text: '',
        tooltip: 'Word一键粘贴',
        onAction: function () {
          selectLocalImages(editor)
        }
      });
    };
    var Buttons = { register: register$1 };
    function Plugin () {
      global.add('wordpaster', function (editor) {        
        Buttons.register(editor);
      });
    }
    Plugin();
}());

在线代码:

添加插件

// 插件
      plugins: {
          type: [String, Array],
          // default: 'advlist anchor autolink autosave code codesample colorpicker colorpicker contextmenu directionality emoticons fullscreen hr image imagetools importcss insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textcolor textpattern visualblocks visualchars'
          default: 'autoresize code autolink autosave image imagetools paste preview table powertables'
      },

点击查看在线代码

初始化组件

// 初始化
WordPaster.getInstance({
    // 上传接口:http://www.ncmem.com/doc/view.aspx?id=d88b60a2b0204af1ba62fa66288203ed
    PostUrl: 'http://localhost:8891/upload.aspx',
    // 为图片地址增加域名: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: ''
})

在页面中引入组件


功能演示

编辑器

在编辑器中增加功能按钮
WordPaster-TinyMCE5

导入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

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐