Vue3 项目集成 OnlyOffice 在线编辑 + 自定义插件开发(二):插入功能全实现

承接上一篇《Vue3 项目集成 OnlyOffice 在线编辑 + 自定义插件开发(一)》,本文将聚焦 OnlyOffice 自定义插件的核心功能实现------包括表格插入、HTML 格式内容插入,以及通过正则匹配实现特殊字符自动识别与一键填充(如公司信息、日期等),全程附完整代码及实操说明,新手可直接复制复用。

核心目标:通过 Vue3 前端触发操作,结合 OnlyOffice 插件桥接层,实现对在线文档的灵活插入操作,解决实际开发中"批量填充""格式保留"的核心需求,提升文档编辑效率。

一、插入表格:一键插入行数据到文档表格

场景说明:在 Vue 页面中点击按钮,将表格行数据提取并插入到 OnlyOffice 文档的指定表格中,支持自动匹配单元格顺序,无需手动输入。

1.1 Vue 页面:按钮触发与数据提取

通过自定义按钮触发插入操作,提取当前行的有效字段(过滤隐藏列和操作列),拼接后传递给父组件,由父组件与桥接层通信。

javascript 复制代码
// 按钮配置(表格操作列按钮)
{
  render: 'tipButton',
  name: 'info',
  title: '插入行数据',
  text: '',
  type: 'success',
  icon: 'fa fa-file-text-o',
  class: 'table-row-edit',
  disabledTip: false,
  click: (row: TableRow) => {
      handleInsertRow(row) // 点击触发行数据插入
  },
}

// 提取当前行有效内容(过滤隐藏列和操作列)
const handleInsertRow = (row: any) => {
  // 筛选显示的列,排除操作列(operation‌)
  const columns = baTable.table.column.filter((item) => item.show !== false && item.prop !== 'operation‌')
  // 拼接行内容,用$作为分隔符(后续拆分插入单元格)
  const rowContent = columns.map((item) => row[item.prop]).join('$')
  // 调用插入表格方法,传递拼接后的内容
  handleInsertTable(rowContent)
}

// 向父组件发送插入指令(携带内容和类型)
const handleInsertTable = (content: any) => {
  const textToInsert = content || ''
  emit('insert', textToInsert, 'table') // 触发父组件insert事件,类型标记为table
}

1.2 父组件:接收指令并与桥接层通信

父组件通过监听子组件的 insert 事件,根据插入类型(table)组装请求参数,通过 window.parent.postMessage 向 OnlyOffice 桥接层发送插入指令。

javascript 复制代码
<!-- 父组件中引入子组件,监听insert事件 -->
<KeepAlive>
  <component :is="currentComponent" @insert="insertIntoDoc" />
</KeepAlive>

<script setup lang="ts">
// 接收子组件插入指令,向桥接层发送消息
const insertIntoDoc = (text: string, type?: string) => {
  let action = ''
  // 根据插入类型匹配对应的OnlyOffice操作
  switch (type) {
      case 'table':
          action = 'insertTable' // 插入表格标识
          break
      case 'html':
          action = 'insertHtml' // 插入HTML标识
          break
      default:
          action = 'insertText' // 默认插入纯文本
          break
  }

  // 组装请求参数,转为JSON字符串(OnlyOffice要求格式)
  const payload = JSON.stringify({
      action,
      text: String(text),
  })
  // 向桥接层发送消息(*表示允许所有源,实际开发可限制具体域名)
  window.parent.postMessage(payload, '*')
  // ElMessage.success('已发送插入指令') // 可选:添加操作提示
}
</script>

1.3 桥接层核心代码(关键实现)

在上一篇提到的 public/tender-plugin/index.html(OnlyOffice 插件桥接层)中,添加表格插入的核心逻辑,解析 Vue 发送的指令,调用 OnlyOffice 原生 API 实现单元格数据填充。

先补充桥接层完整页面结构(含加载遮罩,提升用户体验),再重点实现表格插入方法:

javascript 复制代码
<!doctype html>
<html lang="zh-CN">
    <head>
        <meta charset="UTF-8" />
        <title>ONLYOFFICE 桥接层</title>
        <!-- 引入OnlyOffice插件SDK(本地引入,避免跨域问题) -->
        <script src="./plugins.js"></script>
        <style>
            body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
            iframe { border: none; width: 100%; height: 100%; }
            /* 加载遮罩:插入操作时显示,提升体验 */
            .loading-mask {
                position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
                background: rgba(0, 0, 0, 0.6); display: none; justify-content: center; align-items: center; z-index: 9999;
            }
            .loading-mask.show { display: flex; }
            .loading-box {
                display: flex; align-items: center; gap: 10px;
                background: #fff; padding: 14px 24px; border-radius: 8px; font-size: 16px; color: #333;
            }
            .loading-spin {
                width: 18px; height: 18px; border: 2px solid #999; border-top-color: #1677ff;
                border-radius: 50%; animation: spin 0.8s linear infinite;
            }
            @keyframes spin { to { transform: rotate(360deg); } }
        </style>
    </head>
    <body>
        <iframe id="office-plugin-resource"></iframe>
        <div class="loading-mask" id="loadingMask">
            <div class="loading-box">
                <div class="loading-spin"></div>
                <span>正在处理中...</span>
            </div>
        </div>
        <script>
            // 1. 与 ONLYOFFICE 底层握手初始化
            window.Asc.plugin.init = function () {
                console.log('🌉 [桥接层] ONLYOFFICE 底层握手成功,权限已就绪!')
            }

            // 配置iframe路径(适配项目部署路径,避免404)
            const urlPrefix = location.pathname.includes('/web-client') ? '/web-client' : ''
            document.querySelector('#office-plugin-resource').src = `${location.origin}${urlPrefix}/#/plugin-panel`

            // 2. 监听 Vue 发来的消息,执行对应插入操作
            window.addEventListener('message', function (event) {
                // 确保收到的是纯字符串(防止OnlyOffice崩溃,过滤无效消息)
                if (typeof event.data === 'string') {
                    try {
                        var data = JSON.parse(event.data)
                        // 插入表格逻辑
                        if (data && data.action === 'insertTable') {
                            console.log('🌉 [桥接层] 行数据', data.text)
                            insertDataToCells(data.text) // 调用表格插入方法
                        }
                        // 其他插入逻辑(后续章节补充)
                    } catch (e) {
                        // 忽略系统自带的杂乱消息,避免插件报错
                    }
                }
            })

            // 3. 表格插入核心方法:将拼接的行数据拆分,插入到对应单元格
            function insertDataToCells(rowText) {
                // 存储行数据到OnlyOffice作用域(跨方法访问)
                Asc.scope.rowText = rowText

                // 调用OnlyOffice原生命令,操作文档(异步执行)
                window.Asc.plugin.callCommand(
                    function () {
                        const rowText = Asc.scope.rowText
                        const oDocument = Api.GetDocument() // 获取当前文档对象
                        const oParagraph = oDocument.GetCurrentParagraph() // 获取当前段落

                        // 异常处理:如果当前位置没有段落,直接返回
                        if (!oParagraph) return

                        // 判定当前是否在表格单元格内(不在则直接插入文本)
                        if (!oParagraph.GetParentTableCell || !oParagraph.GetParentTableCell()) {
                            oParagraph.AddText(rowText.replace(/\$/g, ' '))
                            return
                        }

                        // 拆分行数据(按$分隔,对应表格列顺序)
                        const rowItems = rowText.split('$')
                        let currentCell = oParagraph.GetParentTableCell() // 获取当前单元格

                        // 依次将数据插入到当前单元格及后续单元格
                        for (let i = 0; i < rowItems.length; i++) {
                            if (!currentCell) break // 没有更多单元格,终止循环
                            
                            // 清空单元格原有内容,插入新数据并居中显示
                            const oCellContent = currentCell.GetContent()
                            const oCellParagraph = oCellContent.GetElement(0)
                            oCellParagraph.RemoveAllElements() // 清空原有内容
                            oCellParagraph.SetJc('center') // 文本居中
                            oCellParagraph.AddText(rowItems[i]) // 插入当前列数据
                            
                            currentCell = currentCell.GetNext() // 切换到下一个单元格
                        }
                    },
                    false,
                    false,
                    function (result) {} // 回调函数(可添加操作结果提示)
                )
            }

            // 插件关闭时清理资源
            window.Asc.plugin.button = function (id) {
                this.executeCommand('close', '')
            }
        </script>
   </body>
</html>

1.4 效果说明

点击 Vue 页面表格中的"插入行数据"按钮,即可将当前行的所有有效字段,按顺序插入到 OnlyOffice 文档中当前选中的表格单元格内,自动清空原有内容并居中显示,无需手动调整格式。

二、插入HTML文本:保留格式插入富文本内容

场景说明:插入带有自定义格式(如字体、颜色、换行)的 HTML 内容到 OnlyOffice 文档,利用 OnlyOffice 的PasteHtml API 实现格式完美保留,适用于插入模板化内容、带样式的文本等场景。

2.1 Vue 页面:按钮触发与HTML内容提取

与表格插入逻辑类似,通过按钮触发,提取需要插入的 HTML 内容(如模板内容),传递给父组件并指定插入类型为 html

javascript 复制代码
// 按钮配置(与表格插入按钮类似,可复用样式)
{
  render: 'tipButton',
  name: 'info',
  title: '插入模板内容',
  text: '',
  type: 'success',
  icon: 'fa fa-file-text-o',
  class: 'table-row-edit',
  disabledTip: false,
  click: (row: TableRow) => {
      handleInsertRow(row)
  },
}

// 提取当前行的HTML内容(假设content字段存储HTML模板)
const handleInsertRow = (row: any) => {
  handleInsertHtml(row['content']) // 传递HTML内容
}

// 向父组件发送插入指令,类型标记为html
const handleInsertHtml = (content: any) => {
  const textToInsert = content || ''
  emit('insert', textToInsert, 'html')
}

2.2 桥接层核心代码(补充HTML插入逻辑)

在桥接层的 message 监听事件中,添加 insertHtml 分支,调用 OnlyOffice 的 PasteHtml API,直接解析 HTML 内容并插入到文档中(格式自动保留)。

javascript 复制代码
// 继续在window.addEventListener('message', ...)中添加如下逻辑
else if (data && data.action === 'insertHtml') {
    console.log('🌉 [桥接层] 插入 HTML ')
    // OnlyOffice 核心API:PasteHtml,直接解析并插入富文本内容
    window.Asc.plugin.executeMethod('PasteHtml', [data.text])
}

2.3 效果说明

插入的 HTML 内容(如带有字体颜色、换行、加粗的文本)会完美保留原有格式,直接渲染到 OnlyOffice 文档中,无需手动调整样式,适用于插入固定模板、带格式的说明文本等场景。

三、一键填充:正则匹配特殊字符,自动插入固定内容

核心场景:通过正则表达式匹配文档中的特殊字符(如"日期:""公司名称:"),一键插入预设内容(如当前日期、公司信息),支持多字段同时填充,大幅提升文档编辑效率(本文以日期填充为例,其他字段可直接复用逻辑)。

3.1 Vue 页面:一键填充交互界面与逻辑

设计交互界面(复选框选择填充字段、日期选择器自定义日期),点击"一键填充"按钮,组装填充参数,发送给桥接层。

javascript 复制代码
<template>
    <div class="ba-table-box">
        <!-- 填充字段选择 -->
        <el-form :model="form" label-width="auto" style="max-width: 600px">
            <el-form-item label="">
                <el-checkbox-group v-model="form.type" class="checkbox-wrapper">
                    
                    <el-checkbox value="date" name="type"> 签署日期 </el-checkbox>
                </el-checkbox-group>
            </el-form-item>

            <!-- 自定义签署日期(选中日期字段时显示) -->
            <el-form-item label="自定义签署日期" v-show="form.type.includes('date')">
                <el-date-picker 
                    v-model="selectedDate" 
                    type="date" 
                    placeholder="请选择签署日期" 
                    :clearable="false" 
                    style="width: 200px" 
                />
            </el-form-item>
        </el-form>

        <!-- 一键填充按钮 -->
        <el-row justify="start" style="margin-top: 16px">
            <el-button type="primary" size="small" plain @click="handleAutoFill">一键填充</el-button>
        </el-row>
    </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getCompanyInfo } from '/@/api/backend/business' // 接口:获取公司信息(自定义)

defineOptions({
    name: 'autoFill',
})

// 响应式日期变量,默认初始化为今天
const selectedDate = ref<Date>(new Date())

/*
 * 匹配模式说明:
 * mode: 1 → 匹配项后直接插入(有下划线则保留下划线格式)
 * mode: 2 → 居中雷达探测模式(适配"年 月 日"分散填空场景)
 */
const distObj = reactive({
    date: {
        match: [
            // 匹配"编制日期:____年__月__日"这类格式(含可选编制二字、空格、下划线)
            {
                keyword: '(?:编[^\\S\\n\\r]*制[^\\S\\n\\r]*)?日[^\\S\\n\\r]*期[^\\S\\n\\r]*[::](?:[^\\S\\n\\r]|[__\\----])*(年(?:[^\\S\\n\\r]|[__\\----])*月(?:[^\\S\\n\\r]|[__\\----])*日)',
                isRegex: true,
                mode: '2',
            },
            // 匹配"____年__月__日"这类纯日期填空格式
            {
                keyword: '(?:[^\\S\\n\\r]|[__\\----])*年(?:[^\\S\\n\\r]|[__\\----])*月(?:[^\\S\\n\\r]|[__\\----])*日',
                isRegex: true,
                mode: '2',
            },
            // 匹配"日期:"后无内容的场景(直接在后面插入)
            {
                keyword: '(?:编[^\\S\\n\\r]*制[^\\S\\n\\r]*)?日[^\\S\\n\\r]*期[^\\S\\n\\r]*[::](?![^\\S\\n\\r]*[0-9一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾])',
                isRegex: true,
                mode: '1',
            },
        ],
        value: '', // 填充值(动态计算)
        valueObj: { year: '', month: '', day: '' }, // 日期拆分(适配mode:2)
    },
})

// 选中的填充字段
const form = reactive({
    type: ['companyName', 'date', 'address', 'phone', 'postcode'],
})

// 一键填充核心逻辑:组装参数,发送给桥接层
const handleAutoFill = () => {
    // 动态解析用户选择的日期,赋值给distObj
    if (form.type.includes('date') && selectedDate.value) {
        const d = selectedDate.value
        const year = d.getFullYear().toString()
        const month = (d.getMonth() + 1).toString()
        const day = d.getDate().toString()

        // 覆盖日期填充值(年/月/日拆分,适配mode:2的分散填空)
        distObj.date.value = `${year}年${month}月${day}日`
        distObj.date.valueObj = { year, month, day }
    }

    // 组装需要填充的字段(筛选选中的字段)
    const fields: any[] = []
    Object.keys(distObj).forEach((key) => {
        if (form.type.includes(key)) {
            fields.push(distObj[key as keyof typeof distObj])
        }
    })

    // 向桥接层发送一键填充指令
    const payload = JSON.stringify({
        action: 'autoFill',
        fields: fields,
    })
    window.parent.postMessage(payload, '*')
}

// 初始化:获取公司信息(填充公司名称、地址等字段,可根据实际接口调整)
const fetchData = async () => {
    try {
        const res = await getCompanyInfo()
        const resData = res?.data ?? {}

        // 将接口返回的公司信息赋值给distObj
        Object.keys(distObj).forEach((key) => {
            if (key in resData) {
                distObj[key].value = resData[key]
            } else if (distObj[key].alias) {
                // 适配自定义字段(如接口返回的customFields数组)
                const aliasArr = distObj[key].alias.split('.')
                if (aliasArr[0] == 'customFields') {
                    distObj[key].value = resData['customFields'].find((item) => item.fieldName == aliasArr[1])?.fieldValue || ''
                }
            }
        })
    } catch (error) {
        console.error('获取公司信息失败:', error)
    }
}

// 页面挂载时获取公司信息
onMounted(() => {
    fetchData()
})
</script>

<style scoped lang="scss">
.checkbox-wrapper {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 10px;
    margin-bottom: 16px;
}
</style>

3.2 桥接层核心代码:正则匹配与自动填充

在桥接层中添加 autoFill 指令处理逻辑,实现正则匹配文档中的特殊字符,根据匹配模式插入对应内容,支持下划线格式保留、分散填空(如日期"年/月/日")等场景。

javascript 复制代码
// 1. 在message监听事件中添加autoFill分支
else if (data && data.action === 'autoFill') {
    console.log('🌉 [桥接层] 自动填充', data)
    handleAutoFill(data.fields) // 调用一键填充核心方法
}

// 2. 一键填充核心方法(含正则匹配、格式处理、内容插入)
function handleAutoFill(fields) {
    // 显示加载遮罩,提升用户体验
    const loadingMask = document.getElementById('loadingMask')
    if (loadingMask) {
        loadingMask.classList.add('show')
    }

    // 存储填充字段到OnlyOffice作用域
    Asc.scope.autoFillFields = fields

    // 调用OnlyOffice原生命令,操作文档(异步执行)
    window.Asc.plugin.callCommand(
        () => {
            try {
                const autoFillFields = Asc.scope.autoFillFields
                const oDocument = Api.GetDocument() // 获取当前文档对象
                const filledDateTags = new Set() // 去重:避免同一匹配项重复填充

                // 遍历所有需要填充的字段
                autoFillFields.forEach((item) => {
                    const insertString = item.value // 要插入的内容
                    const matchFields = generateMatchKeywords(oDocument, item.match) // 生成匹配规则

                    // 遍历每个匹配规则,执行填充
                    matchFields.forEach((match) => {
                        const { keyword, mode } = match
                        if (filledDateTags.has(keyword)) return // 跳过已填充的匹配项
                        filledDateTags.add(keyword)

                        // 正则匹配文档中的目标内容
                        const aSearchResults = oDocument.Search(keyword)
                        if (aSearchResults.length <= 0) return // 无匹配项,跳过

                        // 从后往前处理(避免修改文档后,前面的匹配位置失效)
                        for (let i = aSearchResults.length - 1; i >= 0; i--) {
                            const oRange = aSearchResults[i]
                            const keywordStartPos = oRange.GetStartPos()
                            const keywordEndPos = oRange.GetEndPos()

                            // 根据匹配模式执行不同的插入逻辑
                            switch (mode) {
                                // 模式1:匹配项后直接插入(处理下划线、单元格等场景)
                                case '1':
                                    // 检测是否属于模式2的场景(避免误判)
                                    let isMode2Territory = false
                                    try {
                                        // 向后侦测20个字符,判断是否有"年"字(模式2的特征)
                                        const checkRange = oDocument.GetRange(keywordEndPos, keywordEndPos + 20)
                                        if (checkRange) {
                                            const checkText = checkRange.GetText() || ''
                                            // 20字符内有"年"字且无换行,判定为模式2场景,跳过
                                            if (checkText.indexOf('年') !== -1 && checkText.indexOf('\r') === -1 && checkText.indexOf('\n') === -1) {
                                                isMode2Territory = true
                                            }
                                        }
                                    } catch (e) {}

                                    if (isMode2Territory) break // 属于模式2,跳过当前匹配项

                                    // 检测匹配项后是否有下划线
                                    const isNextCharUnderline = checkUnderline(oDocument, keywordEndPos)

                                    // 场景1:在单元格中填充(匹配项在单元格内)
                                    if (match.mbCell) {
                                        const currentCell = checkTableCell(oRange)
                                        if (currentCell) {
                                            const nextCell = currentCell.GetNext()
                                            const curCellText = currentCell.GetContent().GetText().trim()
                                            if (nextCell && curCellText == keyword) {
                                                const oCellContent = nextCell.GetContent()
                                                const oCellParagraph = oCellContent.GetElement(0)
                                                oCellParagraph.RemoveAllElements()
                                                oCellParagraph.AddText(insertString)
                                            }
                                        }
                                    }
                                    // 场景2:向前匹配(如"(日期)"格式)
                                    else if (match.isForward) {
                                        const isPrevCharUnderline = checkUnderlineForward(oDocument, keywordStartPos - 1)
                                        if (isPrevCharUnderline) {
                                            const underlineRangeForward = findUnderlineRangeForward(oDocument, keywordStartPos - 1)
                                            if (underlineRangeForward) {
                                                insertIntoUnderline(oDocument, underlineRangeForward, insertString, true)
                                            }
                                        }
                                    }
                                    // 场景3:匹配项后有下划线,在下划线中插入
                                    else if (isNextCharUnderline) {
                                        const underlineRange = findUnderlineRange(oDocument, keywordEndPos)
                                        if (underlineRange) {
                                            insertIntoUnderline(oDocument, underlineRange, insertString, true)
                                        } else {
                                            insertPosUnderline(oDocument, keywordEndPos, insertString)
                                        }
                                    }
                                    // 场景4:无下划线,直接在匹配项后插入
                                    else {
                                        oRange.AddText(insertString)
                                    }
                                    break

                                // 模式2:分散填空(如"____年__月__日",分别插入年、月、日)
                                case '2':
                                    if (!item.valueObj) break // 无日期拆分数据,跳过

                                    const rStart = oRange.GetStartPos()
                                    const rEnd = oRange.GetEndPos()

                                    // 匹配当前范围内的"年""月""日"(确定插入位置)
                                    const allYears = oDocument.Search('年')
                                    const allMonths = oDocument.Search('月')
                                    const allDays = oDocument.Search('日')

                                    let targetYear = null, targetMonth = null, targetDay = null
                                    // 筛选当前匹配范围内的"年"
                                    if (allYears) {
                                        for (let j = 0; j < allYears.length; j++) {
                                            if (allYears[j].GetStartPos() >= rStart && allYears[j].GetEndPos() <= rEnd)
                                                targetYear = allYears[j]
                                        }
                                    }
                                    // 筛选当前匹配范围内的"月"
                                    if (allMonths) {
                                        for (let j = 0; j < allMonths.length; j++) {
                                            if (allMonths[j].GetStartPos() >= rStart && allMonths[j].GetEndPos() <= rEnd)
                                                targetMonth = allMonths[j]
                                        }
                                    }
                                    // 筛选当前匹配范围内的"日"
                                    if (allDays) {
                                        for (let j = 0; j < allDays.length; j++) {
                                            if (allDays[j].GetStartPos() >= rStart && allDays[j].GetEndPos() <= rEnd)
                                                targetDay = allDays[j]
                                        }
                                    }

                                    // 关键:从右往左插入(日→月→年),避免修改后坐标失效
                                    const dayGap = insertDatePart(oDocument, targetDay, item.valueObj.day)
                                    const monthGap = insertDatePart(oDocument, targetMonth, item.valueObj.month, dayGap > 0 ? dayGap + 2 : null)
                                    const safeYearLimit = monthGap > 0 ? monthGap + 2 : 4
                                    insertDatePart(oDocument, targetYear, item.valueObj.year, safeYearLimit)
                                    break

                                default:
                                    console.log('缺少匹配模式,无法填充')
                            }
                        }
                    })
                })
            } catch (e) {
                console.error('插件执行出错:', e) // 捕获异常,避免插件崩溃
            }

            // 辅助函数:构建完整文档文本(用于正则匹配)
            function buildFullText(container) {
                let text = ''
                if (!container || !container.GetElementsCount) return text

                for (let i = 0; i < container.GetElementsCount(); i++) {
                    const elem = container.GetElement(i)
                    // 段落:直接获取文本
                    if (elem.GetText && !elem.GetRowsCount) {
                        text += elem.GetText() + '\n'
                    }
                    // 表格:遍历单元格,递归获取内容
                    else if (elem.GetRowsCount) {
                        for (let r = 0; r < elem.GetRowsCount(); r++) {
                            const row = elem.GetRow(r)
                            for (let c = 0; c < row.GetCellsCount(); c++) {
                                const cell = row.GetCell(c)
                                const cellContent = cell.GetContent()
                                text += buildFullText(cellContent)
                            }
                        }
                    }
                    // 其他容器:递归获取内容
                    else if (elem.GetElementsCount) {
                        text += buildFullText(elem)
                    }
                }
                return text
            }

            // 辅助函数:正则匹配文档中的目标内容
            function findRegexKeyword(doc, complexRegex) {
                const matchTexts = []
                if (!gFullDocText) {
                    gFullDocText = buildFullText(doc) // 初始化完整文档文本
                }
                if (!complexRegex) return []

                // 执行正则匹配,获取所有匹配项
                let matches = gFullDocText.matchAll(complexRegex)
                for (let match of matches) {
                    matchTexts.push(match[0])
                }
                return matchTexts
            }

            // 辅助函数:处理正则匹配项,去重并生成匹配规则
            function handleMatchKeyword(result, match) {
                const arr = findRegexKeyword(doc, new RegExp(match.keyword, 'g'))
                // 去重:避免重复匹配
                const newArr = arr.filter((item, index) => arr.indexOf(item) === index)
                // 生成最终匹配规则
                newArr.forEach((item) => {
                    result.push({ ...match, keyword: item, isRegex: false })
                })
            }

            // 辅助函数:生成所有匹配规则(处理正则和非正则匹配)
            function generateMatchKeywords(doc, matchArr) {
                if (!matchArr || !Array.isArray(matchArr)) return []
                let resultArr = []

                matchArr.forEach((match) => {
                    if (match.isRegex) {
                        handleMatchKeyword(resultArr, { ...match })
                        const exclusion = match.exclusion || {}

                        // 处理单元格内匹配场景
                        if (exclusion.mbCell) {
                            const mKeyword = match.keyword.replace(/\\s\*\[::\]$/, '')
                            handleMatchKeyword(resultArr, { ...match, keyword: mKeyword, mbCell: true })
                        }

                        // 处理向前匹配场景(如"(日期)")
                        if (exclusion.isForward) {
                            let fKeyword = match.keyword.replace(/\\s\*\[::\]$/, '')
                            fKeyword = `[((]${fKeyword}[))]`
                            handleMatchKeyword(resultArr, { ...match, keyword: fKeyword, isForward: true })
                        }
                    } else {
                        resultArr.push({ ...match })
                    }
                })
                return resultArr
            }

            // 辅助函数:判断当前匹配范围是否在表格单元格内
            function checkTableCell(oRange) {
                const oParagraph = oRange.GetParagraph(0)
                if (!oParagraph || !oParagraph.GetParentTableCell || !oParagraph.GetParentTableCell()) {
                    return false
                }
                return oParagraph.GetParentTableCell()
            }

            // 辅助函数:检测指定位置是否有下划线(向后检测)
            function checkUnderline(oDocument, pos) {
                const range = oDocument.GetRange(pos, pos + 1)
                try {
                    const textPr = range.GetTextPr()
                    const char = range.GetText()
                    // 有下划线属性且为空白字符,判定为下划线
                    if (textPr && textPr.GetUnderline && /^\s*$/.test(char)) {
                        return textPr.GetUnderline() === true
                    }
                    return false
                } catch (e) {
                    return false
                }
            }

            // 辅助函数:找到连续下划线范围(向后查找)
            function findUnderlineRange(doc, startPos) {
                let currentPos = startPos
                const maxSearchLength = 100 // 限制搜索长度,避免性能问题
                let searchCount = 0
                // 查找下划线结束位置
                while (searchCount < maxSearchLength) {
                    try {
                        if (!checkUnderline(doc, currentPos)) break
                        currentPos++
                        searchCount++
                    } catch (e) {
                        break
                    }
                }
                // 存在连续下划线,返回下划线范围
                if (currentPos > startPos + 1) {
                    return doc.GetRange(startPos, currentPos)
                }
                return null
            }

            // 辅助函数:检测指定位置是否有下划线(向前检测)
            function checkUnderlineForward(oDocument, pos) {
                const range = oDocument.GetRange(pos - 1, pos)
                try {
                    const textPr = range.GetTextPr()
                    const char = range.GetText()
                    if (textPr && textPr.GetUnderline && /^\s*$/.test(char)) {
                        return textPr.GetUnderline() === true
                    }
                    return false
                } catch (e) {
                    return false
                }
            }

            // 辅助函数:找到连续下划线范围(向前查找)
            function findUnderlineRangeForward(doc, startPos) {
                let currentPos = startPos
                const maxSearchLength = 100
                let searchCount = 0
                while (searchCount < maxSearchLength) {
                    try {
                        if (!checkUnderlineForward(doc, currentPos)) break
                        currentPos--
                        searchCount++
                    } catch (e) {
                        break
                    }
                }
                if (currentPos < startPos) {
                    return doc.GetRange(currentPos, startPos)
                }
                return null
            }

            // 辅助函数:在下划线中间插入内容(保留下划线格式)
            function insertIntoUnderline(doc, underlineRange, content, isExceedDelete) {
                let underlineStart = underlineRange.GetStartPos()
                let underlineEnd = underlineRange.GetEndPos()
                let oRangeToDelete = null
                const contentLength = content.length
                let contentWidth = null

                // 根据内容长度,删除多余的下划线(避免内容溢出)
                if (isExceedDelete) {
                    if (underlineEnd - underlineStart > contentLength * 2) {
                        contentWidth = contentLength * 2
                    } else if (underlineEnd - underlineStart > contentLength * 1.5) {
                        contentWidth = Math.floor(contentLength * 1.5)
                    } else if (underlineEnd - underlineStart > contentLength) {
                        contentWidth = contentLength
                    } else if (underlineEnd - underlineStart <= contentLength) {
                        contentWidth = underlineEnd - underlineStart - 2
                    }
                } else if (underlineEnd - underlineStart > content.length) {
                    contentWidth = content.length
                }

                // 删除多余下划线
                if (contentWidth) {
                    oRangeToDelete = doc.GetRange(underlineStart, underlineStart + contentWidth)
                    oRangeToDelete.Delete()
                    underlineStart = underlineRange.GetStartPos()
                    underlineEnd = underlineRange.GetEndPos()
                }

                // 在下划线中间插入内容,保持下划线格式
                const insertPos = Math.floor((underlineStart + underlineEnd) / 2)
                const textPr = Api.CreateTextPr()
                textPr.SetUnderline(true) // 保留下划线格式
                const insertRange = doc.GetRange(insertPos, insertPos)
                if (insertRange) {
                    insertRange.AddText(content, textPr)
                }
            }

            // 辅助函数:在指定位置插入带下划线的内容
            function insertPosUnderline(doc, start, content) {
                const textPr = Api.CreateTextPr()
                textPr.SetUnderline(true)
                const insertRange = doc.GetRange(start, start + 1)
                if (insertRange) {
                    insertRange.AddText(content, textPr)
                }
            }

            // 辅助函数:插入日期拆分内容(适配mode:2,保留原有格式)
            function insertDatePart(oDocument, targetNode, textContent, maxSpaceLimit = null) {
                if (!targetNode) return 0
                const pos = targetNode.GetStartPos() // 获取"年/月/日"的位置
                let gapStart = pos
                let needsUnderline = false
                let parentTextPr = null
                let expectedGapType = null
                let finalGapLength = 0

                // 向前查找下划线/空格(确定插入起点)
                for (let i = 1; i <= 30; i++) {
                    if (pos - i < 0) break
                    const prevRange = oDocument.GetRange(pos - i, pos - i + 1)
                    if (!prevRange) break

                    const char = prevRange.GetText() || ''
                    if (char === '\r' || char === '\n') break // 遇到换行,停止查找
                    const textPr = prevRange.GetTextPr()
                    if (i === 1) parentTextPr = textPr // 记录原有样式

                    let isGap = false
                    let currentGapType = null
                    // 判定下划线(包括下划线属性和下划线字符)
                    if (/^[__\------]+$/.test(char)) {
                        currentGapType = 'underline'
                    } else if (textPr && textPr.GetUnderline && /^\s*$/.test(char)) {
                        currentGapType = 'underline'
                    } else if (/^[\s\t\xA0\u3000]*$/.test(char)) {
                        currentGapType = 'space' // 空白字符
                    } else {
                        break
                    }

                    // 匹配间隙类型,限制查找范围
                    if (currentGapType) {
                        if (i === 1) expectedGapType = currentGapType
                        if (currentGapType === expectedGapType) {
                            if (maxSpaceLimit !== null && i > maxSpaceLimit) break
                            isGap = true
                            if (currentGapType === 'underline') needsUnderline = true
                        } else {
                            isGap = false
                        }
                    }

                    if (isGap) {
                        gapStart = pos - i
                        finalGapLength = i // 记录间隙长度
                    } else {
                        break
                    }
                }

                // 插入内容(保留原有样式)
                if (gapStart < pos) {
                    const midPos = Math.floor((gapStart + pos) / 2)
                    const insertRange = oDocument.GetRange(midPos, midPos)
                    const newPr = Api.CreateTextPr()

                    // 继承原有字体、字号(避免样式突变)
                    if (parentTextPr) {
                        if (parentTextPr.GetFontSize) newPr.SetFontSize(parentTextPr.GetFontSize())
                        if (parentTextPr.GetFontName) newPr.SetFontName(parentTextPr.GetFontName())
                    }

                    // 有下划线,保留下划线格式
                    if (needsUnderline) {
                        newPr.SetUnderline('single') // OnlyOffice规范:single表示单下划线
                        insertRange.AddText(textContent, newPr)
                    } else {
                        insertRange.AddText(textContent)
                    }
                } else {
                    // 无间隙,紧贴插入
                    oDocument.GetRange(pos, pos).AddText(textContent)
                }
                return finalGapLength
            }
        },
        false,
        false,
        // 填充完成后,隐藏加载遮罩
        (returnValue) => {
            const loadingMask = document.getElementById('loadingMask')
            if (loadingMask) {
                loadingMask.classList.remove('show')
            }
        }
    )
}

3.3 效果说明

在 Vue 页面选择需要填充的字段(如日期),选择自定义日期后点击"一键填充",桥接层会通过正则匹配文档中所有符合规则的"日期"相关字段,自动插入选中的日期,支持两种场景:

  • 场景1:"日期:"后无内容 → 直接在后面插入日期(保留原有格式);

  • 场景2:"____年__月__日" → 分别在"年""月""日"前面的下划线中插入对应数字,完美适配分散填空格式。

其他字段(公司名称、地址等)可直接复用此逻辑,只需在 distObj 中添加对应匹配规则和接口请求即可。

四、总结与注意事项

4.1 核心总结

本文实现了 OnlyOffice 自定义插件的三大核心插入功能,核心逻辑是"Vue 前端触发 → 父组件通信 → 桥接层解析 → OnlyOffice API 执行",关键要点:

  • 表格插入:通过 callCommand 操作文档表格,拆分拼接内容插入对应单元格;

  • HTML 插入:利用 PasteHtml API 直接解析富文本,保留原有格式;

  • 一键填充:通过正则匹配文档特殊字符,根据匹配模式插入内容,支持下划线保留、分散填空等复杂场景。

4.2 注意事项

  • 桥接层的 plugins.js 建议本地引入(避免跨域问题),可从 OnlyOffice 官方 SDK 下载;

  • 正则匹配规则可根据实际文档格式调整,避免匹配过宽或过窄;

  • 操作文档时尽量使用 callCommand 异步执行,避免阻塞插件;

  • 添加加载遮罩和异常捕获,提升用户体验,避免插件崩溃。

相关推荐
吕源林1 天前
C#怎么实现EF Core迁移 C#如何用Entity Framework Core进行数据库迁移和更新表结构【数据库】
jvm·数据库·python
数厘1 天前
2.21 sql聚合函数的特性与避坑指南(NULL值处理、DISTINCT在聚合函数中的应用)
数据库·sql·oracle
qq_206901391 天前
JavaScript中箭头函数在对象字面量方法中的潜在错误
jvm·数据库·python
一 乐1 天前
旅游|基于springboot + vue旅游信息推荐系统(源码+数据库+文档)
java·vue.js·spring boot·论文·旅游·毕设·旅游信息推荐系统
Trouvaille ~1 天前
【MySQL】视图:虚拟表的妙用
数据库·mysql·adb·面试·数据处理·后端开发·视图
Cosolar1 天前
2026年向量数据库选型指南:Qdrant、Pinecone、Milvus、Weaviate 与 Chroma 深度解析
数据库·面试·llm
最逗前端小白鼠1 天前
vue3 数据响应式遇到的问题
前端·vue.js
m0_747854521 天前
如何为禁用按钮点击添加提示文案
jvm·数据库·python
谁怕平生太急1 天前
面试题记录:在线数据迁移
java·数据库·spring
aXin_ya1 天前
Redis 原理篇 (数据结构)
数据库·redis·缓存