>
post-detail

Picgo 插件开发记录

「技术」

8 分钟

27 阅读

0

2025-12-31

1 前言

我很喜欢用 Obsidian,我的第一个笔记软件就是用的 Obsidian,在我还没开始写代码的时候,我就开始用 Obsidian 做笔记。

我现在也搭建了一个自己的博客网站,初版,也就是 v1 版的博客端的布局,特别是文章部分的布局,是参考 Obsidian 的。

然而,v1 版的后台系统,使用 Vue3 + Element Plus 搭建的,很简陋,而且超级无敌丑。

在 v1 版,上传笔记的主要方式是,先在 Obsidian 写好,然后复制粘贴到后台管理系统的 markdown 编辑器,最后把图片一一上传。很繁琐,对吧?

说实话,如果只是文字的话,复制粘贴过去就完事了,只是有些 Obsidian 特殊的语法,在 markdown 编辑器没法展示出来而已(博客端是可以的,因为我用了 markdown-it 插件)。

最麻烦的还是图片。如果我写了一篇很长的博客,有大量图片的话,一个个上传简直要我老命。

最开始,我解决该问题的想法是,在后台搭一个和 Obsidian 差不多体验的编辑器,这样写文章和上传图片两不误。这个想法后面放弃了,想想也知道,自己怎么可能搭建出媲美 Obsidian 的编辑体验的编辑器?

就算可以,那也得花超级多的时间,而且可能还会有众多 bug,对一个小博客网站来说,实在是杀鸡用牛刀。

后面,我了解到了图床这个东西。了解到 Obsidian 也可以利用插件连接图床,实现图片上传到图床,然后在笔记中使用。

可以说,这种方式完全解决了我的问题。

我的服务器使用 RustFS 作为对象存储服务,如果把其当作图床,利用插件,在 Obsidian 用到的图片就能直接上传到 RustFS,我的笔记图片就全是上传好的链接,我只需要将文档内容直接复制到后台的 md 编辑器,就移植好了,非常方便。

于是,我开始琢磨怎么在 Obsidian 集成图床。网上大部分都是使用 Picgo,我查了一下,Picgo 还支持 MinIO 。

下载完 Picgo 后,我查了一下插件市场,不出所料,没有 RustFS 的。

其实没有也无所谓,因为有 S3 的,RustFS 是完全遵循 S3 协议的,所以使用 S3 的插件也可以。

但是,我忘了一件事。我的博客系统上传文件是使用预签名上传的。更巧的是,我的博客是前后端分开服务器部署的,并且设置了反向代理,后端服务器完全是藏起来的,任何相关的端口都只开给前端服务器。预签名也是通过前端服务器转发。

不巧的是,里面所有的插件,都是直传对象存储服务的,适配我这个场景的完全没有。

自己写一个?我立刻浮起这种想法。抱着试一试的心态,我找到了 picgo-plugin-s3 插件的仓库,拉来下看了下。

好家伙,居然是用 ts 写的,代码完全看得懂。

于是为了让 Picgo 支持我的服务器,照着官网文档,开始开发一个 Picgo 插件。

参考:

2 开发前准备

开发之前,需要安装 picgo 官方的项目模板创建工具,不需要直接从 0 搭建。

随便找个目录打开 CMD,输入命令:

pnpm add -g picgo

由于我使用了 nvm,没有全局目录,我需要先运行 pnpm setup 创建一个全局目录

安装完成后,输入 picgo -h 看看是不是安装成功了

然后,在你的项目目录上,打开 CMD,输入命令

picgo init plugin <你的插件名称>

我经常创建不了项目,老是提示下载超时,我挂魔法也要试好多次才行,真的令人血压升高。

image.png

说几个关键的点

Choose modules you want to develop:指你要开发的模块,这里有五个模块,如果你选择某一个,创建出的项目的 index.ts 就会注册这个模块。当然,不选择的话,后面也可以自己在代码添加,添加也很简单。 Your plugin is just used in CLI?:指你开发的插件要用在命令行吗,我这里选 NO,因为我主要开发的是 GUI。 Your plugin has some shortcut for GUI?:指你的插件需要为 GUI 界面提供快捷键吗?我选的 NO。 项目名称一定要是 picgo-plugin-xxx 这种格式,不然后面安装的时候,PicGo 识别不了。

创建完成后,先不着急执行 npm install,我们先看看目录结构。 image.png

相当简单的目录结构。

点开 package.json, 默认配置

{  
  "name": "picgo-plugin-picgo-plugin-test",  
  "version": "1.0.0",  
  "description": "测试插件",  
  "main": "dist/index.js",  
  "publishConfig": {  
    "access": "public"  
  },  
  "homepage": "",  
  "scripts": {  
    "test": "echo \"Error: no test specified\" && exit 1",  
    "build": "tsc -p .",  
    "dev": "tsc -w -p .",  
    "patch": "npm version patch && git push origin master && git push origin --tags",  
    "minor": "npm version minor && git push origin master && git push origin --tags",  
    "major": "npm version major && git push origin master && git push origin --tags"  
  },  
  "keywords": [  
    "picgo",  
    "picgo-gui-plugin",  
    "picgo-plugin"  
  ],  
  "author": "Xigrut",  
  "license": "MIT",  
  "devDependencies": {  
    "@types/node": "16.9.1",  
    "@typescript-eslint/eslint-plugin": "^5.40.0",  
    "@typescript-eslint/parser": "^5.40.0",  
    "eslint-config-standard-with-typescript": "^23.0.0",  
    "eslint-plugin-node": "^11.1.0",  
    "eslint-plugin-standard": "^3.1.0",  
    "picgo": "^1.5.0-alpha.13",  
    "typescript": "^4.8.4",  
    "eslint": "^8.25.0",  
    "eslint-config-standard": "^17.0.0",  
    "eslint-plugin-import": "^2.26.0",  
    "eslint-plugin-n": "^15.3.0",  
    "eslint-plugin-promise": "^6.1.0"  
  }  
}

可以看到,配置的各种依赖,版本都很老,甚至连自己的 picgo 库都不是最新版的。我们来简单升级一下。

把依赖改成这样:

"devDependencies": {  
  "@eslint/js": "^9.39.2",  
  "@types/node": "22.19.3",  
  "eslint": "^9.39.2",  
  "eslint-config-prettier": "^10.1.8",  
  "eslint-plugin-prettier": "^5.5.4",  
  "picgo": "1.6.0",  
  "prettier": "^3.7.4",  
  "typescript": "^5.9.3",  
  "typescript-eslint": "^8.50.0"  
},  
"dependencies": {  
  "file-type": "^21.1.1", 
  "mime": "^4.1.0"  
}

@types/node 的版本可以根据自己的 node 版本调整,我的是 22 版。 file-type 和 mime 主要是用来判断文件类型的。

然后直接执行 pnpm install 安装。执行完成后,删除项目目录中的 .eslintrc 文件。创建一个 eslint.config.mjs 文件。然后把下面的内容粘进去。

// @ts-check  

import eslint from '@eslint/js'  
import { defineConfig } from 'eslint/config'  
import tseslint from 'typescript-eslint'  
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'  

export default defineConfig(  
    {  
        ignores: ['**/build/**', '**/dist/**'],  
    },  
    eslint.configs.recommended,  
    tseslint.configs.recommended,  
    eslintPluginPrettier,  
    {  
        rules: {  
            'prettier/prettier': [  
                'error',  
                {  
                    semi: false,          // 不使用分号结尾  
                    singleQuote: true,    // 使用单引号而非双引号  
                    printWidth: 80,       // 代码行最大宽度为80个字符  
                    tabWidth: 2,          // 缩进使用2个空格  
                    trailingComma: 'all', // 对象/数组末尾保留逗号  
                    arrowParens: 'always',// 箭头函数参数始终使用括号  
                    endOfLine: 'auto',    // 自动选择换行符类型  
                }  
            ],  
        },  
    },  
)

'prettier/prettier': 部分,可以根据自己的习惯更改,上面是我自己的习惯。

粘完以后,你的编译器可能会报一堆错,那是因为 eslint 配置生效了,但是没有自动帮你纠正,我们得设置一下保存后自动纠正的功能。

我使用的是 WebStorm 的免费版,先点开 eslint 的设置 image.png

点击 All actions on save image.png

勾选 Run eslint --fix,Run Prettier 好像勾选不了,如果可以勾选的话就勾选一下。 image.png

点击 OK,然后随便打个回车,按下快捷键 ctrl + s 保存, eslint 就会自动纠正了。 image.png

其他地方的代码也是,如果有很多报错的话,可以打回车,然后保存一下就行。觉得麻烦的话,就可以像 vue 项目一样,在 package.json 添加一个 format 命令,然后直接执行 pnpm format 就行。

然后点开 tsconfig.json,需要的话,也可以简单更新一下,这个文件主要关于 ts 的编译行为的配置,我直接贴代码了,改的不多。

{  
  "compilerOptions": {  
    "target": "es2020",  
    "module": "commonjs",  
    "moduleResolution": "node",  
    "resolveJsonModule": true,  
    "esModuleInterop": true,  
    "allowSyntheticDefaultImports": true,  
    "sourceMap": true,  
    "declaration": true,  
    "outDir": "dist",  
    "strict": true,  
    "skipLibCheck": true,  
    "forceConsistentCasingInFileNames": true,  
    "baseUrl": "src",  
    "lib": [  
      "es2020"  
    ]  
  },  
  "include": [  
    "./src/**/*"  
  ]  
}

做到这,项目就更新好了。

3 开发

3.1 各个模块的功能

首先点开 index.ts ,可以看到如下内容:

import { PicGo } from 'picgo'  

export = (ctx: PicGo) => {  
  const register = (): void => {  
    ctx.helper.uploader.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
    ctx.helper.transformer.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
    ctx.helper.beforeTransformPlugins.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
    ctx.helper.beforeUploadPlugins.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
    ctx.helper.afterUploadPlugins.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
  }  
  return {  
    uploader: 'picgo-plugin-test',  
    transformer: 'picgo-plugin-test',  
    register,  
  }  
}

命令行创建项目时,选择的模块会在这里被注册。

每个模块的执行流程,可以参照官网的流程图: image.png

每个模块的作用,可以去官网上看,我自己只开发 uploader,其他不太熟悉,就不多说了。 插件开发 | PicGo-Core

我们主要编写的,就是其中的 handle 方法,可以看到,handle 方法是一个接收 PicGo 类型的 ctx 参数,返回的是 void,那我们定义方法的时候,就可以这么定义。

3.2 输入配置编写

了解完每个模块的作用后,就可以开始开发了,由于我只需要开发 uploader,所以其他模块我就直接删了。

import { PicGo } from 'picgo'  

export = (ctx: PicGo) => {  
  const register = (): void => {  
    ctx.helper.uploader.register('picgo-plugin-test', {  
      handle(ctx) {  
        console.log(ctx)  
      },  
    })  
  }  
  return {  
    uploader: 'picgo-plugin-test',  
    transformer: 'picgo-plugin-test',  
    register,  
  }  
}

然后,我们来捋一下开发的最终目标。我的博客的上传图片的流程是:请求预签名 -> 拿到预签名 -> 使用预签名上传文件。

非常简单吧?不需要任何库,仅仅使用原生的 fetch API 就行。

但由于我博客网站有鉴权,请求预签名需要登录 token,但是我没有开放登录功能,所以我这就不用博客的来演示了,我使用我之前的 RustFS + Springboot 的 Demo 项目来作为需要上传的目标。 rustfs_demo: RustFS Learning Demo

以上只是上传的流程,其实还有前置的流程没有说,我一边说一边给代码。

首先, PicGo 上传一般需要用户填写相应的信息,例如目标地址、Access Key、Secret Key 等等。所以我们得写一个配置信息,主要是配置用户需要填写什么信息,程序怎么获取。

在 src 目录下,创建 core 目录,在 core 目录下创建 types 目录,然后在 types 目录创建一个 config.d.ts 文件,定义一下我们需要填写的信息类型。

说明一下,这些目录没有规定一定要这么创建,这么创建纯粹只是我的编程习惯。

interface ICustomConfig {  
  // 后端服务地址  
  backendEndpoint: string  
  // 预签名接口地址  
  presignedAPIPath: string  
}  

export type { ICustomConfig }

然后在 core 目录下, 创建 config.ts 文件,添加一个 buildPluginConfig 方法。

import { ICustomConfig } from './types/config'  
import { IPluginConfig } from 'picgo'  

function buildPluginConfig(customConfig: ICustomConfig): IPluginConfig[] {  
  return [  
    {  
      name: 'backendEndpoint',  
      type: 'input',  
      default: customConfig.backendEndpoint,  
      required: true,  
      message: '请输入后端服务地址',  
      alias: '后端服务地址',  
    },  
    {  
      name: 'presignedAPIPath',  
      type: 'input',  
      default: customConfig.presignedAPIPath,  
      required: true,  
      message: '请输入预签名获取接口地址',  
      alias: '预签名获取接口地址',  
    },  
  ]  
}

type 有 input、password、confirm 三种类型,对应不同的输入组件。

然后,我们再添加和导出两个方法,也就是 get 和 set 方法。get 就是获取当前的配置信息,set 就是设置用户输入的值,可以在这里添加默认值。

忘记了一件事,由于有很多地方需要用到插件名称,如果每次都要手打,后面要改的话,一个个改很麻烦,所以我们在 core 目录下创建一个 constants.ts 文件,导出一个 PLUGIN_NAME 常量,在需要用到 Plugin其他地方直接调用就行。

export const PLUGIN_NAME = 'picgo-plugin-test'

添加 set 和 get 方法,我直接给完整的 config.ts 代码。

import { ICustomConfig } from './types/config'  
import { IPicGo, IPluginConfig } from 'picgo'  
import { PLUGIN_NAME } from './constants'  

function buildPluginConfig(customConfig: ICustomConfig): IPluginConfig[] {  
  return [  
    {  
      name: 'backendEndpoint',  
      type: 'input',  
      default: customConfig.backendEndpoint,  
      required: true,  
      message: '请输入后端服务地址',  
      alias: '后端服务地址',  
    },  
    {  
      name: 'presignedAPIPath',  
      type: 'input',  
      default: customConfig.presignedAPIPath,  
      required: true,  
      message: '请输入预签名获取接口地址',  
      alias: '预签名获取接口地址',  
    },  
  ]  
}  

export function setConfig(ctx: IPicGo): IPluginConfig[] {  
  const defaultConfig: ICustomConfig = {  
    backendEndpoint: 'http://127.0.0.1:8080',  
    presignedAPIPath: '/api/presigned',  
  }  

  let userConfig = ctx.getConfig<ICustomConfig>(`picBed.${PLUGIN_NAME}`)  
  userConfig = { ...defaultConfig, ...(userConfig || {}) }  

  return buildPluginConfig(userConfig)  
}  

export function getConfig(ctx: IPicGo): ICustomConfig {  
  const userConfig = ctx.getConfig<ICustomConfig>(`picBed.${PLUGIN_NAME}`)  
  if (!userConfig) {  
    ctx.log.error('[uploader]无法找到用户配置')  
    throw new Error('[uploader]无法找到用户配置')  
  }  

  return userConfig  
}

ctx.log 是 PicGo 的日志方法,使用该方法输出的日志,能够在 PicGo 的日志文件里看到,方便调试。 当然,日志的内容格式没有规定,这么写是我自己的习惯。

3.3 文件前置处理

官网介绍对 PicGo 能获取的文件信息有如下介绍:

[
  {
    buffer: 'xxx', // 图片的buffer值,buffer和base64值二选一即可
    base64Image: 'xxxx', // 图片的base64值,buffer和base64值二选一即可
    fileName: 'xxxx', // 图片的文件名
    width: 'xxxx', // 图片宽度
    height: 'xxxx', // 图片高度
    extname: '.xxx' // 图片格式的扩展名 比如.jpg | .png
  },
  {
    ...
  },
  ...
]

其中buffer和base64值二选一即可,如果传入的是path数组,PicGo 默认输出是buffer值 。

但是预签名上传的话,是用 put 方法,然后在 body 设置 File 类型的文件来上传的,所以我们得写一个转化方法,将 buffer 或者 base64,转化为 File。

除此之外,File 文件需要知道图片的类型,而且预签名也对文件类型敏感,也需要传递文件的 Mime 类型,所以我们还得获取文件的 Mime 类型,以此设置 contentType。目标就是这两个。

在 core/types 目录下,创建 convert.d.ts 文件,添加如下类型

interface IImageFile {  
  imageFile: File  
  contentType: string  
}  

export type { IImageFile }

在 core 目录下,创建 convert.ts 文件

import { IImgInfo } from 'picgo'  
import { IImageFile } from './types/convert'  
import mime from 'mime'  
import { fileTypeFromBuffer } from 'file-type'  

export async function convertImageFile(  
  imageItem: IImgInfo,  
): Promise<IImageFile> {  
  if (!imageItem) {  
    throw new Error('[uploader]未获取到文件项')  
  }  

  let contentType = ''  
  let imageBuffer: Buffer  

  // 统一使用 buffer 来创建 File,base64 也转为 buffer  if (imageItem.buffer) {  
    imageBuffer = imageItem.buffer  
  } else if (imageItem.base64Image) {  
    imageBuffer = Buffer.from(imageItem.base64Image, 'base64')  
  } else {  
    throw new Error('[uploader]未获取到文件的 buffer 或 base64')  
  }  

  // 从 base64Image 中获取 contentType  if (imageItem.base64Image) {  
    const match = imageItem.base64Image.match(/[^:]\w+\/[\w-+\d.]+(?=;|,)/)  
    if (match?.[0]) {  
      contentType = match[0]  
    }  
  }  
  // 从 extname 扩展名中获取 contentType  if (!contentType && imageItem.extname) {  
    contentType = mime.getType(imageItem.extname) || ''  
  }  
  // 如果都获取不到,使用 file-type 库提取 mime 类型  
  if (!contentType) {  
    const fileType = await fileTypeFromBuffer(imageBuffer)  
    contentType = fileType?.mime || ''  
  }  
  // 如果都找不到,抛出错误  
  if (!contentType) {  
    throw new Error('[uploader]无法确定文件类型')  
  }  
  // 获取文件扩展名(为了设置文件名)  
  let fileExtension = ''  
  if (imageItem.extname) {  
    // 从 extname 获取扩展名  
    fileExtension = imageItem.extname.startsWith('.')  
      ? imageItem.extname.slice(1)  
      : imageItem.extname  
  } else if (contentType) {  
    // 使用 mime 库获取扩展名  
    fileExtension = mime.getExtension(contentType) || ''  
  }  
  const fileName = fileExtension ? `file.${fileExtension}` : 'file'  

  // 返回创建结果  
  return {  
    imageFile: new File([imageBuffer], fileName, {  
      type: contentType,  
    }),  
    contentType,  
  }  
}

3.4 API 与 工具

为了方便 API 的调用,我们需要手动定义一下我们需要使用的API。

在 core 包下,创建 apis.ts 文件。

import { ICustomConfig } from './types/config'  

export const Apis = (userConfig: ICustomConfig) => {  
  const backendBaseUrl: string = userConfig.backendEndpoint  

  async function fetchPresignedUrl(mimeType: string, ext: string) {  
    return fetch(backendBaseUrl + userConfig.presignedAPIPath, {  
      method: 'POST',  
      headers: {  
        'Content-Type': 'application/json',  
      },  
      body: JSON.stringify({  
        mimeType: mimeType,  
        ext: ext,  
      }),  
    })  
  }  

  async function uploadFileByPresignedUrl(  
    presignedUrl: string,  
    imageFile: File,  
  ) {  
    return fetch(presignedUrl, {  
      method: 'PUT',  
      body: imageFile,  
      headers: {  
        'Content-Type': imageFile.type,  
      },  
    })  
  }  

  return {  
    fetchPresignedUrl,  
    uploadFileByPresignedUrl,  
  }  
}

另外,预签名是直传 OSS 服务,但是 OSS 服务是不返回 URL 的,我们可以从预签名中直接获取,所以得写个工具方法,从预签名中获取文件访问的 URL。

在 core 目录下,创建 utils.ts 文件,添加如下方法:

export function extractObjectUrlFromPresignedUrl(presignedUrl: string) {  
  const urlObject = new URL(presignedUrl)  
  urlObject.search = ''  

  return urlObject.href  
}

到这,前置步骤就完了,接下来就是核心上传方法。

3.5 上传文件

从官网的介绍:

[
  {
    buffer: 'xxx', // 图片的buffer值,buffer和base64值二选一即可
    base64Image: 'xxxx', // 图片的base64值,buffer和base64值二选一即可
    fileName: 'xxxx', // 图片的文件名
    width: 'xxxx', // 图片宽度
    height: 'xxxx', // 图片高度
    extname: '.xxx' // 图片格式的扩展名 比如.jpg | .png
  },
  {
    ...
  },
  ...
]

可以看到,输入的是一个列表,也就是说,可能会传入多个文件,所以我们得写一个任务方法,将每个上传任务包装起来。

从上面的前置操作可以看到,我们的完整流程大概是:获取配置 -> 初始化 API -> 将文件转化为 File,提取 mime 类型和扩展名 -> 获取预签名 -> 解析访问路径 URL -> 使用预签名上传文件。

我们的每个任务的执行都是这样的,所以只需要将其包装成异步方法,返回 Promise 就行。这样, uploader 就只需要根据文件列表,创建任务,然后 Promise.all() 等待所有任务完成,再解析结果就行。

现在思考一下,任务方法怎么创建?

传入参数需要什么?当然是 PicGo 的 IImgInfo 了。

返回结果需要什么?当然是上传的 url 了。

所以在 core/types 下,创建 task.d.ts 文件。

import { IImgInfo } from 'picgo'  

interface ITaskOption {  
  index: number  
  imageItem: IImgInfo  
}  

interface ITaskResult {  
  index: number  
  url?: string  
  error?: Error  
}  

export type { ITaskOption, ITaskResult }

添加索引的目的是方便 task 列表和 input 的文件列表做对应。

在 core 目录下,创建 task.ts 文件

import { ITaskOption, ITaskResult } from './types/task'  
import { ICustomConfig } from './types/config'  
import { IPicGo } from 'picgo'  
import { Apis } from './apis'  
import { IImageFile } from './types/convert'  
import { convertImageFile } from './convert'  
import { extractObjectUrlFromPresignedUrl } from './utils'  

export async function createTask(  
  option: ITaskOption,  
  userConfig: ICustomConfig,  
  ctx: IPicGo,  
): Promise<ITaskResult> {  
  // 初始化结果  
  const result: ITaskResult = {  
    index: option.index,  
  }  
  // 初始化 api 接口  
  const apis = Apis(userConfig)  
  // 初始化图片文件  
  let imageFile: IImageFile = {} as IImageFile  

  try {  
    // 转化为 File    imageFile = await convertImageFile(option.imageItem)  
    ctx.log.debug('[uploader]转换图片文件成功')  
  } catch (err) {  
    result.error = new Error(  
      `[uploader]转换图片文件失败,原因: ${err instanceof Error ? err.message : String(err)}`,  
    )  
    return result  
  }  

  // 获取预签名  
  await apis  
    .fetchPresignedUrl(imageFile.contentType)  
    .then((value) => {  
      if (!value.ok) {  
        result.error = buildErrorLog(  
          `[uploader]获取预签名地址失败, 原因:${value.status} --- ${value.statusText}`,  
          ctx,  
        )  
      }  

      return value.json() as Promise<string>  
    })  
    .then(async (value: string) => {  
      const presignedUrl = value  
      // 提取预签名  
      const realUrl = extractObjectUrlFromPresignedUrl(presignedUrl)  
      // 使用预签名上传文件  
      await apis  
        .uploadFileByPresignedUrl(presignedUrl, imageFile.imageFile)  
        .then((value) => {  
          if (!value.ok) {  
            result.error = buildErrorLog(  
              `[uploader]上传文件失败, 状态码:${value.status} --- ${value.statusText}`,  
              ctx,  
            )  
          }  
          // 返回文件访问地址  
          result.url = realUrl  
        })  
        .catch((err) => {  
          result.error = buildErrorLog(  
            `[uploader]上传文件失败, 原因:${err instanceof Error ? err.message : String(err)}`,  
            ctx,  
          )  
        })  
    })  
    .catch((err) => {  
      result.error = buildErrorLog(  
        `[uploader]获取预签名地址失败, 原因:${err instanceof Error ? err.message : String(err)}`,  
        ctx,  
      )  
    })  
  // 返回结果  
  return result  
}  

// 构建错误日志  
function buildErrorLog(msg: string, ctx: IPicGo): Error {  
  ctx.log.error(msg)  
  return new Error(msg)  
}

接下来,就可以实现上传器了。在 src 目录,创建 uploader.ts 文件

import { IImgInfo, IPicGo } from 'picgo'  
import { getConfig } from './core/config'  
import { createTask } from './core/task'  
import { ITaskResult } from './core/types/task'  

export async function uploaderHandler(ctx: IPicGo): Promise<IPicGo> {  
  // 获取配置  
  const userConfig = getConfig(ctx)  
  const output = ctx.output  
  ctx.log.info('[uploader]开始上传')  
  // 创建下载任务  
  const tasks = output.map((item: IImgInfo, index: number) => {  
    ctx.log.debug('[uploader]开始处理图片: ' + item.fileName)  

    return createTask(  
      {  
        index,  
        imageItem: item,  
      },  
      userConfig,  
      ctx,  
    )  
  })  
  // 上传任务  
  let results: ITaskResult[] = []  
  try {  
    results = await Promise.all(tasks)  
  } catch (err) {  
    throw buildErrorLog(  
      `[uploader]上传失败,原因: ${err instanceof Error ? err.message : String(err)}`,  
      ctx,  
    )  
  }  
  // 处理结果  
  for (const result of results) {  
    // 释放文件内存  
    delete output[result.index].buffer  
    delete output[result.index].base64Image  
    // 处理错误  
    if (result.error) {  
      output[result.index].error = result.error  
    } else {  
      // 处理成功的 url      ctx.log.debug(`[uploader]文件上传成功,url: ${result.url}`)  
      output[result.index].url = result.url  
      output[result.index].imgUrl = result.url  
    }  
  }  
  // 更新输出  
  ctx.output = output  
  return ctx  
}  

function buildErrorLog(msg: string, ctx: IPicGo): Error {  
  ctx.log.error(msg)  
  return new Error(msg)  
}

更新一下 index.ts 的信息

import { IPicGo, IPluginConfig, PicGo } from 'picgo'  
import { PLUGIN_NAME } from './core/constants'  
import { uploaderHandler } from './uploader'  
import { setConfig } from './core/config'  

function getConfig(ctx: IPicGo): IPluginConfig[] {  
  return setConfig(ctx)  
}  

export = (ctx: PicGo) => {  
  const register = (): void => {  
    ctx.helper.uploader.register(PLUGIN_NAME, {  
      handle: uploaderHandler,  
      config: getConfig,  
    })  
  }  
  return {  
    uploader: PLUGIN_NAME,  
    transformer: PLUGIN_NAME,  
    register,  
  }  
}

大功告成了。

4 安装与测试

安装插件的流程,可以参考官网: 插件测试与发布 | PicGo-Core

首先, 在目录下,使用 pnpm build 命令,打包文件。

如果你像我一样,使用 PicGo 的 GUI 版本(Windows),可以直接使用 GUI 的导入功能。 image.png

在弹出的文件选择器中,选择 picgo-plugin-test 这个项目文件,点击确定,就能导入了。

image.png

注意

如果导入失败的话,就去 PicGo 设置、设置日志文件、点击【设置文件】按钮、弹出窗口、点击【点击打开】按钮,就能打开 PicGo 的日志文件,可以在那里看是什么原因出错了。

现在测试一下插件能不能上传文件。

在图床设置里,找到刚刚导入的插件,新建一个 test 配置。 image.png

然后在上传区,上传一个图片。

我这边上传失败了,打开日志,看到下面的信息

2025-12-31 14:10:42 [PicGo INFO] Uploading... Current uploader is [picgo-plugin-test] 
2025-12-31 14:10:42 [PicGo INFO] [uploader]开始上传 
2025-12-31 14:10:43 [PicGo ERROR] [uploader]获取预签名地址失败, 原因:405 ---  
2025-12-31 14:10:43 [PicGo ERROR] [uploader]获取预签名地址失败, 原因:Invalid URL 
2025-12-31 14:10:43 [PicGo INFO] afterUploadPlugins: picgo-plugin-xgrblog running 
2025-12-31 14:10:43 [PicGo INFO] xgrblog: after upload... 
2025-12-31 14:10:43 [PicGo SUCCESS] 

我看了一眼,是我后端接口参数有问题,多了一个参数,另外,task.ts 也有问题

// 获取预签名  
await apis  
  .fetchPresignedUrl(imageFile.contentType)  
  .then((value) => {  
    if (!value.ok) {  
      result.error = buildErrorLog(  
        `[uploader]获取预签名地址失败, 原因:${value.status} --- ${value.statusText}`,  
        ctx,  
      )  
    }  
    // 我的接口返回的直接就是 url,不需要转化为 Json。
    return value.text() as Promise<string>  
  })

提醒

如果需要联调测试的话,就在项目的命令行,运行 pnpm dev,运行后会自动监听你项目的变化,然后自动打包。 你在项目里改好代码后,保存一下,然后重启一下 PicGo 就行了,不需要重新安装插件。

修改完以后,就上传成功了。 image.png

访问一下 image.png

完成了。

测试代码 Demo 的仓库:picgo-plugin-test: PicGo 插件开发 Demo

5 总结

  1. 学习了 PicGo 插件的开发流程。
  2. 学会了如何开发 PicGo 插件的 Uploader 模块。
  3. 学会了如何在 PicGo GUI 客户端安装插件。
  4. 学会了如何联调测试插件。

0 人赞过

评论

评论加载中…