WPS 加载项中实现当前文档直接上传

背景

在 WPS 加载项里做文件上传时,最常见的做法是让用户通过 <input type="file"> 或上传组件重新选择本地文件。但在办公插件场景里,用户真正想上传的往往就是当前正在编辑的文档。

如果还要求用户手动选择一次文件,体验上会有几个问题:

  • 用户需要知道当前文档实际保存在哪里。
  • 未保存或路径复杂的文件容易找不到。
  • 插件已经运行在 WPS 内,却没有利用"当前文档"这个上下文。
  • Mac WPS 环境下,普通浏览器文件选择和 WPS 文件系统路径还可能出现兼容问题。

因此,这次实现的核心目标是:不让用户选择文件,而是直接读取 WPS 当前活动文档,将其转成可上传的 Blob,再通过 FormData 提交给后端。

核心流程

整体流程可以拆成几步:

text 复制代码
获取当前活动文档
记录原始文档路径和保存状态
生成临时文件路径
将当前文档另存到临时路径
读取临时文件的二进制内容
转换成 Blob
通过 FormData 上传
恢复当前文档到原始路径
清理临时文件

其中最重要的是两点:

  • 上传的是当前 WPS 文档,而不是用户重新选择的文件。
  • 临时保存和读取不能破坏用户继续编辑当前文档的体验。

获取当前活动文档

WPS 加载项中可以通过 wps.WpsApplication().ActiveDocument 获取当前活动文档。

为了兼容不同运行环境,通常会封装一个获取应用对象的方法:

js 复制代码
getWpsApplication(){
  if (window.Application) return window.Application
  if (typeof wps === 'undefined') return null
  if (wps.Application) return wps.Application
  if (typeof wps.WpsApplication === 'function') return wps.WpsApplication()
  return null
}

然后再取当前文档:

js 复制代码
getActiveDocument(){
  const app = this.getWpsApplication()
  const activeDocument = app && app.ActiveDocument ? app.ActiveDocument : null
  if (!activeDocument) {
    throw new Error('当前没有打开任何文档')
  }
  return activeDocument
}

这一步要明确处理"没有打开文档"的情况,否则后续保存、读取都会变成不清晰的运行时错误。

为什么不能直接用当前文件路径

如果文档已经保存过,理论上可以读取 activeDocument.FullName 对应的本地文件。但这并不总是可靠:

  • 用户当前编辑内容可能还没有保存到磁盘。
  • 直接读取原文件可能拿到旧内容。
  • 新建文档可能还没有 FullName
  • Mac WPS 的路径读写行为和普通浏览器环境不完全一致。

因此,更稳的方式是先把当前文档落到一个临时路径,再读取这个临时文件。

临时保存的取舍

理想情况下,应该调用"保存副本"类 API,这样不会改变当前文档路径。但实际运行环境里并不一定存在 SaveCopyAs 方法。

因此最终采用的策略是:

text 复制代码
记录原始 FullName
SaveAs2(tempPath)
读取 tempPath
SaveAs2(originalFullName)

也就是说,先用 SaveAs2 把当前文档保存到临时路径,读取完成后再立刻 SaveAs2 回原始路径。

这一步的关键是必须记录原始路径:

js 复制代码
getOriginalDocumentFullName(activeDocument){
  const fullName = activeDocument.FullName || ''
  if (!fullName || fullName === activeDocument.Name) {
    throw new Error('请先保存当前文档后再上传')
  }
  return fullName
}

对于未保存过的新文档,没有可恢复的原始路径。此时不应该继续上传,而应该提示用户先保存文档。

读取二进制内容

在 WPS 文件系统里,不能简单假设存在 ReadFile API。实际可用的是:

js 复制代码
wps.FileSystem.readAsBinaryString(filePath)

读取后得到的是二进制字符串,需要手动转成字节数组,再构造 Blob

js 复制代码
createBlobFromWpsFileContent(fileContent){
  if (typeof fileContent === 'string') {
    const bytes = new Uint8Array(fileContent.length)
    for (let index = 0; index < fileContent.length; index++) {
      bytes[index] = fileContent.charCodeAt(index) & 0xff
    }
    return new Blob([bytes], { type: 'application/octet-stream' })
  }
  return new Blob([fileContent], { type: 'application/octet-stream' })
}

这里不能把二进制字符串直接当普通文本处理,否则文件内容会损坏。

避免上传 5 字节假文件

实现过程中遇到过一个很典型的问题:上传出来的文件大小只有 5 字节,类型是 application/octet-stream

后来发现,这是因为文件读取失败时返回了 false,但代码仍然把它包装成了 Blob

js 复制代码
new Blob([false], { type: 'application/octet-stream' }).size
// 5

也就是说,上传的不是文档内容,而是字符串 "false"

因此读取文件后必须做失败值校验:

js 复制代码
readWpsFileAsBinaryString(filePath){
  const fileSystem = typeof wps !== 'undefined' ? wps.FileSystem : null
  if (!fileSystem || typeof fileSystem.readAsBinaryString !== 'function') {
    throw new Error('当前环境不支持读取 WPS 临时文件')
  }
  const fileContent = fileSystem.readAsBinaryString(filePath)
  if (
    fileContent === false ||
    fileContent === undefined ||
    fileContent === null ||
    fileContent === '' ||
    fileContent === 'false'
  ) {
    throw new Error('读取 WPS 临时文件失败')
  }
  return fileContent
}

这个判断看起来很朴素,但非常关键。它可以避免错误文件继续进入后端流程,也能让问题更早暴露在前端。

通过 FormData 上传

拿到 Blob 后,就可以用 FormData 上传:

js 复制代码
const formData = new FormData()
formData.append('file', fileBlob, fileName)
await this.$axios.post('/knowledge/uploadFile', formData)

这里建议一定传第三个参数 fileName

如果只写:

js 复制代码
formData.append('file', fileBlob)

后端拿到的文件名可能会变成默认的 blob,影响文件名展示、后缀识别或后续处理。

恢复用户编辑状态

因为使用了 SaveAs2(tempPath),当前活动文档会临时切到临时文件路径。如果不恢复,用户上传完成后继续编辑的可能就不是原文件了。

因此上传流程必须在 finally 中恢复:

js 复制代码
restoreOriginalDocumentPath(activeDocument, originalFullName, originalSaved){
  activeDocument.SaveAs2(originalFullName)
  if (typeof originalSaved === 'boolean') {
    activeDocument.Saved = originalSaved
  }
}

这里恢复了两件事:

  • 文档路径恢复到原始 FullName
  • 文档保存状态尽量恢复到上传前的状态。

这能减少上传动作对用户编辑体验的干扰。

临时文件清理

上传完成后,需要尝试删除临时文件:

js 复制代码
removeTempFile(tempPath){
  if (!tempPath || typeof wps === 'undefined' || !wps.FileSystem) return
  try {
    if (wps.FileSystem.Exists(tempPath)) {
      wps.FileSystem.Remove(tempPath)
    }
  } catch (e) {
    // 临时文件清理失败不影响上传结果
  }
}

清理失败不应该影响主流程。上传已经完成时,临时文件删除失败最多是环境层面的残留问题,不应该反向提示用户"上传失败"。

关键经验

第一,不要假设 WPS 加载项环境等同于浏览器环境。当前文档、文件系统、路径读取都要走 WPS 提供的能力。

第二,Mac WPS 的本地路径和文件读取行为更容易暴露兼容问题。读取结果必须校验,不能直接相信 API 返回值。

第三,SaveAs2 会改变当前活动文档路径。只要用它做临时保存,就必须设计恢复逻辑。

第四,未保存文档需要单独处理。没有原始路径时,无法可靠恢复用户继续编辑的文件。

第五,上传 Blob 时要带文件名。否则后端可能无法正确识别真实文件名和扩展名。

小结

"上传当前文档"看起来是一个很小的功能,但在 WPS 加载项里涉及多个边界:活动文档、临时保存、本地路径、二进制读取、Blob 转换、上传文件名、状态恢复和临时文件清理。

最终实现的重点不是上传接口本身,而是如何在不打断用户编辑体验的前提下,把当前 WPS 文档可靠地转换成一个可上传文件。

这类能力非常适合沉淀成插件基础能力:只要封装好"当前文档转 Blob/File"的过程,后续无论是上传到知识库、发起审批、同步到网盘,还是提交到业务系统,都可以复用同一套底层逻辑。

相关推荐
慢功夫1 小时前
💡【调试】按下 F5 后,mini-vscode 到底做了什么
前端·visual studio code
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(二十一):网络请求封装优化
前端·javascript·vue.js
用户11481867894841 小时前
普通Fetch、Fetch流式、Axios、SSE:谁才是流式输出的正确操作?
前端·javascript
奇奇怪怪的1 小时前
多模态 RAG:图片与表格检索
前端
犇驫聊AI1 小时前
Claude Code 用了大半年才悟出来的 6 个技巧,第 3 个直接省一半 Token
前端·claude
团团崽_七分甜2 小时前
Spring Data JPA 从入门到实战完整指南
前端
我是大卫2 小时前
React源码解析-第一部分:宏观架构
前端·react.js·源码
我是大卫2 小时前
React源码解析-第二部分:更新与调度
前端·react.js·源码
狸礼li2 小时前
web网站设计作品--第二大脑
前端