成为全栈·Vue 管理后台篇·编辑器图片上传:从文件选择到文章附件关联

Markdown 编辑器里的图片按钮只是上传流程的入口。用户选择文件后,还需要认证请求、FormData 编码、服务端存储、URL 归一化、Markdown 插入、上传状态互锁和失败处理。本文沿 Vue 后台编辑器的真实代码逐步追踪。

本文代码快照:Vue 后台提交 d3ee41a。主要参考 src/pages/articles/ArticleFormPage.vue、src/api/attachments.ts、src/lib/request/index.ts、src/lib/fileUrl.ts 与契约 POST /upload。

前言:选中文件不等于文章里已有图片

在编辑器中选一张图片后,流程至少包含:文件进入浏览器、校验大小和类型、调用上传端点、服务器完成存储并创建 Attachment 记录、响应返回文件 URL、前端将 URL 插入 Markdown、文章保存时提交正文。任一环节失败,编辑内容都可能与用户预期不一致。

另一个重要区分是"文件上传成功"和"文章保存成功"。上传接口成功意味着附件实体已创建,文章内容还可能只是浏览器本地草稿;只有随后保存文章正文,引用该 URL 的 Markdown 才成为文章内容。反过来,保存文章成功也不必然证明所有引用 URL 都能公开访问。

API 模块构造 FormData

附件模块把文件和可选文章 ID 传给 POST /upload:

ts 复制代码
export const uploadFile = (file: File, articleId?: number): Promise<Attachment> => {
  const form = new FormData()
  form.append('file', file)
  if (articleId != null) form.append('articleId', String(articleId))
  return http.post<Attachment>('/upload', form)
}

API 函数不负责显示 toast、更新编辑器内容或决定文章何时保存。它只将契约端点包装成类型明确的业务函数。请求内核识别 FormData 后不设置 JSON Content-Type,也不 stringify body,让浏览器自动生成 multipart boundary。

契约定义了文件类型与大小范围(当前契约说明支持图片/PDF,最大 10MB),服务端仍需校验 MIME、实际内容、大小、权限和存储结果。前端可以提前做相同检查改善体验,但文件名和浏览器提供的 MIME 不可信,不能替代服务端安全验证。

编辑器上传回调怎样连到 API

MdEditor 的 on-upload-img 回调收到一组 File 和一个回调函数。页面把每个文件交给 uploadFile,获得 Attachment 后使用其 URL 调用编辑器 callback:

ts 复制代码
async function uploadImages(files: Array<File>, callback: (urls: string[]) => void) {
  uploading.value += files.length
  try {
    const attachments = await Promise.all(
      files.map((file) => uploadFile(file, saved.value?.id)),
    )
    callback(attachments.map((attachment) => fileUrl(attachment.url)))
  } catch (error) {
    message.error(error instanceof Error ? error.message : '图片上传失败')
  } finally {
    uploading.value = Math.max(0, uploading.value - files.length)
  }
}

只有上传请求 resolve 后才将 URL 插入 Markdown。失败时不调用成功 callback,向用户显示错误并恢复 uploading 计数。若一次选多张,当前 Promise.all 采取"全组成功后统一回调"的策略:一张失败会使整个 Promise reject,即使其他文件已经上传成功,它们仍可能形成附件记录但没有全部插入正文。这个行为是可见的取舍,若要支持部分成功,需要逐文件收集 settled 结果并把成功 URL 插入、失败文件明确提示。

URL 为什么经过 fileUrl()

后端返回的附件 URL 可能是 /files/{key},也可能是完整 http(s) URL。fileUrl() 保留绝对地址,对根相对地址不重复加斜杠,对普通相对路径补成根路径。这样页面、预览和编辑器引用能使用一致的 URL 规则。

文件路径和 API 路径不是一回事。API 请求走 API_BASE(如 /api/v1),静态文件通过 /files;Vite 开发代理分别将它们代理到 API target。不要把 /files/... 手工拼成 /api/v1/files/...,也不要在组件里写死线上文件域名。

最终文件是否可访问仍取决于存储后端、CDN/代理、跨域策略和部署配置。前端得到一个字符串 URL 不等于文件已经通过公开网络验收。

封面图走相同上传端点,但插入位置不同

封面上传通过隐藏的 input type=file 选择单个图片,调用同一个 uploadFile,然后将返回 URL 赋给 coverImage 并显示预览。正文上传则把 URL 插入 Markdown。两种路径共同使用附件 API,但各自维护不同字段和交互状态。

封面字段变化会将文章标记为 dirty;正文图片插入修改 content,同样触发未保存提示。编辑已有文章时,saved.id 会被作为 articleId 传入上传 API,可让附件记录关联到该文章。新建文章还没有服务端 ID,因此上传时没有 articleId;除非后续接口/业务流程显式补关联,不能声称这些附件会自动在创建文章后回填关联。

这也是新建草稿上传图片时需要在产品上明确的细节:要么接受附件先成为用户上传素材、内容引用其 URL;要么设计后续关联/清理机制。前端当前保存文章后不会自动再发一遍附件更新请求。

上传状态必须阻止提前保存

页面的 uploading 记录正在上传的文件数。保存草稿按钮和发布按钮在 uploading 大于 0 时禁用,避免 Markdown 还没收到 URL 就把文章提交;编辑器自身在文章保存忙碌时禁用,但上传阶段通过状态提示和保存按钮互锁处理。

在异步流程里用计数器而非一个 boolean,可以表示多文件同时上传。每次任务开始加上文件数量,finally 减回去;即使失败也会恢复。实现时需要保证每条分支恰好加减一次,避免计数负数或上传结束后界面仍显示忙碌。

注意当前 Markdown 多图上传和封面上传使用方式不同:正文维护 uploading 计数并参与保存按钮禁用;封面上传没有同步增加这个计数,按钮禁用逻辑主要与正文上传状态关联。若产品要求严格保证封面上传完成前不能发布,需要把封面请求也纳入统一上传计数或状态机。写作时应区分现状和建议,不把这一约束描述成所有上传都已覆盖。

安全、失败恢复与附件生命周期

上传接口要求已认证会员及以上。请求内核自动附 Bearer access token,并按浏览器会话策略携带 Cookie。服务端根据用户身份和可选 articleId 决定附件归属;编辑端不能用客户端传来的 articleId 证明用户有权修改对应文章。

上传失败时,原 Markdown 内容仍在,用户可以重试;上传成功但文章保存失败时,附件已创建,正文仍停留在本地。若用户随后离开,可能留下未被文章引用的附件。删除附件接口又有自己的权限规则,所以自动清理需要设计服务端能力和确认策略,不能在页面卸载时随意删掉用户已经上传的素材。

多图上传使用 Promise.all 时可能发生部分服务端成功、整体 callback 失败;封面修改也可能上传成功但表单未保存。产品可选择在"我的附件"中保留全部上传素材供后续使用,或者引入临时上传状态与服务端清理任务。当前 API 已有 GET /me/attachments 可列出本人附件,但文章编辑页本身没有实现完整素材库流程。

验收时逐段核对

  1. 选择合法图片,Network 面板确认请求为 POST /api/v1/upload 且 body 是 multipart/form-data。
  2. 检查请求头 boundary 是浏览器生成,前端没有手动覆盖 Content-Type。
  3. 响应返回 Attachment 实体和 URL,编辑器中出现 Markdown 图片引用。
  4. 尝试无效大小或类型文件,确认服务端拒绝后页面有错误提示、上传状态恢复。
  5. 正文上传期间观察保存/发布按钮是否禁用;上传完成后可保存文章。
  6. 编辑已有文章时检查请求是否携带 articleId;新建文章上传时确认未关联 ID 的处理方式符合当前契约。
  7. 保存后重新打开文章并确认图片引用和文件访问路径有效。

本地 mock 测试只能验证请求层 FormData 处理与组件 callback 行为;线上存储、域名代理、对象权限和浏览器安全策略要在实际部署环境另行验证。

小结:把附件看作独立实体

图片上传包含独立的附件创建、文章引用和文章保存三个阶段。API 层负责 FormData 端点,请求内核保留浏览器 boundary,编辑器回调只在上传成功后插入 URL,页面计数器阻止正文上传期间保存。新文章缺少 articleId、批量部分成功和孤立附件清理都是需要清楚记录的边界。

下一篇转向评论审核工作台:如何筛选审核状态、呈现评论与文章上下文、处理回复关系,并在执行审核/删除后反馈用户和失效正确缓存。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
Agent世界13 分钟前
用 CDP 做发布自动化:四道机器闸门与产物验收
前端·javascript
某哈压力大32 分钟前
SSH 突然 Connection reset?不是密钥坏了,是 OpenSSL 把 sshd 干趴了
前端·node.js·命令行
程序员大阳35 分钟前
副队长ES6+教程(3)--解构赋值
前端·javascript·es6·赋值·解构
神膘护体小月半1 小时前
css - 元素背景图由 hover 触发,初次加载时闪动的解决办法
前端·javascript·css
程序员大阳1 小时前
副队长ES6+教程(2)--let 与 const
前端·javascript·es6·const·let·var
deli0071 小时前
写满 200GB 后 SSD 为什么掉到 95MB/s?SLC 缓存模型实测
前端
曦樂~2 小时前
【Web Development】03JavaScript 基础和事件
开发语言·前端·javascript
deli0072 小时前
HTTPS 的锁是怎么挂上的?TLS 1.3 握手 6 步实测
前端
程序员大阳3 小时前
副队长ES6+教程(1)--ES6+初识
前端·ecmascript·es6