diff --git a/package.json b/package.json index cf7b140..e52297c 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,11 @@ "@octokit/rest": "^19.0.7", "@picgo/i18n": "^1.0.0", "@picgo/store": "^2.0.4", + "@smithy/node-http-handler": "^2.0.2", "@types/marked": "^4.0.8", "@types/mime-types": "^2.1.1", "@videojs-player/vue": "^1.0.0", "ali-oss": "^6.18.0", - "aws-sdk": "^2.1373.0", "axios": "^1.4.0", "compare-versions": "^4.1.3", "core-js": "^3.27.1", diff --git a/scripts/upload-beta.js b/scripts/upload-beta.js index b173e05..86402d1 100644 --- a/scripts/upload-beta.js +++ b/scripts/upload-beta.js @@ -29,8 +29,8 @@ const uploadFile = async () => { secretAccessKey: SECRET_KEY }, endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`, - sslEnabled: true, - region: 'us-east-1' + tls: true, + region: 'auto' } const client = new S3Client.S3Client(options) const parallelUploads3 = new Upload.Upload({ diff --git a/scripts/upload-dist-to-r2.js b/scripts/upload-dist-to-r2.js index ef41a19..c05b4b0 100644 --- a/scripts/upload-dist-to-r2.js +++ b/scripts/upload-dist-to-r2.js @@ -1,6 +1,6 @@ // upload dist bundled-app to r2 require('dotenv').config() -const S3 = require('aws-sdk/clients/s3') + const S3Client = require('@aws-sdk/client-s3') const Upload = require('@aws-sdk/lib-storage') const pkg = require('../package.json') @@ -8,6 +8,7 @@ const configList = require('./config') const fs = require('fs') const path = require('path') const yaml = require('js-yaml') +const mime = require('mime-types') const BUCKET = 'piclist-dl' const VERSION = pkg.version @@ -16,12 +17,15 @@ const ACCOUNT_ID = process.env.R2_ACCOUNT_ID const SECRET_ID = process.env.R2_SECRET_ID const SECRET_KEY = process.env.R2_SECRET_KEY -const s3 = new S3({ +const options = { + credentials: { + accessKeyId: SECRET_ID, + secretAccessKey: SECRET_KEY + }, endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`, - accessKeyId: SECRET_ID, - secretAccessKey: SECRET_KEY, - signatureVersion: 'v4' -}) + tls: true, + region: 'auto' +} const removeDupField = path => { const file = fs.readFileSync(path, 'utf8') @@ -39,74 +43,87 @@ const removeDupField = path => { const uploadFile = async () => { try { const platform = process.platform - if (configList[platform]) { - let versionFileHasUploaded = false - for (const [index, config] of configList[platform].entries()) { - const fileName = `${config.appNameWithPrefix}${VERSION}${config.arch}${config.ext}` - const distPath = path.join(__dirname, '../dist_electron') - const versionFileName = config['version-file'] - console.log('[PicList Dist] Uploading...', fileName, `${index + 1}/${configList[platform].length}`) - const fileStream = fs.createReadStream(path.join(distPath, fileName)) - const options = { - credentials: { - accessKeyId: SECRET_ID, - secretAccessKey: SECRET_KEY - }, - endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`, - sslEnabled: true, - region: 'us-east-1' + if (!configList[platform]) { + console.warn('platform not supported!', platform) + return + } + let versionFileHasUploaded = false + for (const [index, config] of configList[platform].entries()) { + const fileName = `${config.appNameWithPrefix}${VERSION}${config.arch}${config.ext}` + const distPath = path.join(__dirname, '../dist_electron') + const versionFileName = config['version-file'] + + console.log('[PicList Dist] Uploading...', fileName, `${index + 1}/${configList[platform].length}`) + const fileStream = fs.createReadStream(path.join(distPath, fileName)) + const client = new S3Client.S3Client(options) + + const parallelUploads3 = new Upload.Upload({ + client, + params: { + Bucket: BUCKET, + Key: `${FILE_PATH}${fileName}`, + Body: fileStream, + ContentType: 'application/octet-stream', + Metadata: { + description: 'uploaded by PicList' + } } - const client = new S3Client.S3Client(options) - const parallelUploads3 = new Upload.Upload({ + }) + parallelUploads3.on('httpUploadProgress', progress => { + const progressBar = Math.round((progress.loaded / progress.total) * 100) + process.stdout.write(`\r${progressBar}% ${fileName}`) + }) + console.log('\n') + await parallelUploads3.done() + console.log(`${fileName} uploaded!`) + + if (!versionFileHasUploaded) { + console.log('[PicList Version File] Uploading...', versionFileName) + let versionFilePath + if (platform === 'win32') { + versionFilePath = path.join(distPath, 'latest.yml') + } else if (platform === 'darwin') { + versionFilePath = path.join(distPath, 'latest-mac.yml') + } else { + versionFilePath = path.join(distPath, 'latest-linux.yml') + } + removeDupField(versionFilePath) + const versionFileStream = fs.createReadStream(versionFilePath) + const uploadVersionFileToRoot = new Upload.Upload({ client, params: { Bucket: BUCKET, - Key: `${FILE_PATH}${fileName}`, - Body: fileStream, - ContentType: 'application/octet-stream', + Key: `${versionFileName}`, + Body: versionFileStream, + ContentType: mime.lookup(versionFileName), Metadata: { description: 'uploaded by PicList' } } }) - parallelUploads3.on('httpUploadProgress', progress => { - const progressBar = Math.round((progress.loaded / progress.total) * 100) - process.stdout.write(`\r${progressBar}% ${fileName}`) - }) - console.log('\n') - await parallelUploads3.done() - console.log(`${fileName} uploaded!`) - if (!versionFileHasUploaded) { - console.log('[PicList Version File] Uploading...', versionFileName) - let versionFilePath - if (platform === 'win32') { - versionFilePath = path.join(distPath, 'latest.yml') - } else if (platform === 'darwin') { - versionFilePath = path.join(distPath, 'latest-mac.yml') - } else { - versionFilePath = path.join(distPath, 'latest-linux.yml') + console.log('\nUploading version file to root...') + await uploadVersionFileToRoot.done() + console.log(`${versionFileName} uploaded!`) + versionFileStream.close() + const versionFileStream2 = fs.createReadStream(versionFilePath) + const uploadVersionFileToLatest = new Upload.Upload({ + client, + params: { + Bucket: BUCKET, + Key: `${FILE_PATH}${versionFileName}`, + Body: versionFileStream2, + ContentType: mime.lookup(versionFileName), + Metadata: { + description: 'uploaded by PicList' + } } - removeDupField(versionFilePath) - const versionFileBuffer = fs.readFileSync(versionFilePath) - await s3 - .upload({ - Bucket: BUCKET, - Key: `${versionFileName}`, - Body: versionFileBuffer - }) - .promise() - await s3 - .upload({ - Bucket: BUCKET, - Key: `${FILE_PATH}${versionFileName}`, - Body: versionFileBuffer - }) - .promise() - versionFileHasUploaded = true - } + }) + console.log('\nUploading version file to latest...') + await uploadVersionFileToLatest.done() + console.log(`${versionFileName} uploaded!`) + versionFileStream2.close() + versionFileHasUploaded = true } - } else { - console.warn('platform not supported!', platform) } } catch (err) { console.error(err) diff --git a/src/main/events/ipcList.ts b/src/main/events/ipcList.ts index b8bbec9..e5d5c87 100644 --- a/src/main/events/ipcList.ts +++ b/src/main/events/ipcList.ts @@ -1,3 +1,4 @@ +// Electron 相关 import { app, ipcMain, @@ -8,16 +9,38 @@ import { screen, IpcMainInvokeEvent } from 'electron' + +// 窗口管理器 import windowManager from 'apis/app/window/windowManager' + +// 枚举类型声明 import { IWindowList } from '#/types/enum' + +// 上传器 import uploader from 'apis/app/uploader' + +// 粘贴模板函数 import pasteTemplate from '~/main/utils/pasteTemplate' + +// 数据存储库和类型声明 import db, { GalleryDB } from '~/main/apis/core/datastore' + +// 服务器模块 import server from '~/main/server' + +// 获取图片床模块 import getPicBeds from '~/main/utils/getPicBeds' + +// 快捷键处理器 import shortKeyHandler from 'apis/app/shortKey/shortKeyHandler' + +// 全局事件总线 import bus from '@core/bus' + +// 文件系统库 import fs from 'fs-extra' + +// 事件常量 import { TOGGLE_SHORTKEY_MODIFIED_MODE, OPEN_DEVTOOLS, @@ -34,19 +57,46 @@ import { GET_PICBEDS, HIDE_DOCK } from '#/events/constants' + +// 上传剪贴板文件和已选文件的函数 import { uploadClipboardFiles, uploadChoosedFiles } from '~/main/apis/app/uploader/apis' + +// 核心 IPC 模块 import picgoCoreIPC from './picgoCoreIPC' + +// 处理复制的 URL 和生成短链接的函数 import { handleCopyUrl, generateShortUrl } from '~/main/utils/common' + +// 构建主页面、迷你页面、插件页面、图片床列表的菜单函数 import { buildMainPageMenu, buildMiniPageMenu, buildPluginPageMenu, buildPicBedListMenu } from './remotes/menu' + +// 路径处理库 import path from 'path' + +// i18n 模块 import { T } from '~/main/i18n' + +// 同步设置的上传和下载文件函数 import { uploadFile, downloadFile } from '../utils/syncSettings' + +// SSH 客户端模块 import SSHClient from '../utils/sshClient' + +// Sftp 配置类型声明 import { ISftpPlistConfig } from 'piclist' +// AWS S3 相关模块 +import { S3Client, DeleteObjectCommand, S3ClientConfig } from '@aws-sdk/client-s3' +import { NodeHttpHandler } from '@smithy/node-http-handler' +import http, { AgentOptions } from 'http' +import https from 'https' + +// 通用获取 Agent 函数 +import { getAgent } from '../manage/utils/common' + const STORE_PATH = app.getPath('userData') export default { @@ -137,6 +187,66 @@ export default { } }) + ipcMain.handle('delete-aws-s3-file', async (_evt: IpcMainInvokeEvent, configMap: IStringKeyMap) => { + try { + const { imgUrl, config: { accessKeyID, secretAccessKey, bucketName, region, endpoint, pathStyleAccess, rejectUnauthorized, proxy } } = configMap + console.log(JSON.stringify(configMap, null, 2)) + const url = new URL(!/^https?:\/\//.test(imgUrl) ? `http://${imgUrl}` : imgUrl) + const fileKey = url.pathname.replace(/^\/+/, '') + const endpointUrl: string | undefined = endpoint + ? /^https?:\/\//.test(endpoint) + ? endpoint + : `http://${endpoint}` + : undefined + const sslEnabled = endpointUrl ? endpointUrl.startsWith('https') : true + const agent = getAgent(proxy, sslEnabled) + const commonOptions: AgentOptions = { + keepAlive: true, + keepAliveMsecs: 1000, + scheduling: 'lifo' as 'lifo' | 'fifo' | undefined + } + const extraOptions = sslEnabled ? { rejectUnauthorized: !!rejectUnauthorized } : {} + const handler = sslEnabled + ? new NodeHttpHandler({ + httpsAgent: agent.https + ? agent.https + : new https.Agent({ + ...commonOptions, + ...extraOptions + }) + }) + : new NodeHttpHandler({ + httpAgent: agent.http + ? agent.http + : new http.Agent({ + ...commonOptions, + ...extraOptions + }) + }) + const s3Options: S3ClientConfig = { + credentials: { + accessKeyId: accessKeyID, + secretAccessKey + }, + endpoint: endpointUrl, + tls: sslEnabled, + forcePathStyle: pathStyleAccess, + region, + requestHandler: handler + } + const client = new S3Client(s3Options) + const command = new DeleteObjectCommand({ + Bucket: bucketName, + Key: fileKey + }) + const result = await client.send(command) + return result.$metadata.httpStatusCode === 204 + } catch (err: any) { + console.error(err) + return false + } + }) + ipcMain.handle('migrateFromPicGo', async () => { const picGoConfigPath = STORE_PATH.replace('piclist', 'picgo') const fileToMigration = [ diff --git a/src/main/manage/apis/s3plist.ts b/src/main/manage/apis/s3plist.ts index f1f2ed1..8c2e6da 100644 --- a/src/main/manage/apis/s3plist.ts +++ b/src/main/manage/apis/s3plist.ts @@ -4,7 +4,6 @@ import { ListBucketsCommand, ListObjectsV2Command, GetBucketLocationCommand, - S3ClientConfig, _Object, CommonPrefix, ListObjectsV2CommandOutput, @@ -12,7 +11,8 @@ import { GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, - PutObjectCommand + PutObjectCommand, + S3ClientConfig } from '@aws-sdk/client-s3' // AWS S3 上传和进度 @@ -23,7 +23,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner' // HTTP 和 HTTPS 模块 import https from 'https' -import http from 'http' +import http, { AgentOptions } from 'http' +import { NodeHttpHandler } from '@smithy/node-http-handler' // 日志记录器 import { ManageLogger } from '../utils/logger' @@ -34,9 +35,6 @@ import { formatEndpoint, formatError, getAgent, getFileMimeType, NewDownloader, // 是否为图片的判断函数、HTTP 代理格式化函数 import { isImage, formatHttpProxy } from '@/manage/utils/common' -// HTTP 和 HTTPS 代理库 -import { HttpsProxyAgent, HttpProxyAgent } from 'hpagent' - // 窗口管理器 import windowManager from 'apis/app/window/windowManager' @@ -58,21 +56,8 @@ import path from 'path' // 取消下载任务的加载文件列表、刷新下载文件传输列表 import { cancelDownloadLoadingFileList, refreshDownloadFileTransferList } from '@/manage/utils/static' -interface S3plistApiOptions { - credentials: { - accessKeyId: string - secretAccessKey: string - } - endpoint?: string - sslEnabled: boolean - s3ForcePathStyle: boolean - httpOptions?: { - agent: https.Agent - } -} - class S3plistApi { - baseOptions: S3plistApiOptions + baseOptions: S3ClientConfig logger: ManageLogger agent: any proxy: string | undefined @@ -92,26 +77,39 @@ class S3plistApi { secretAccessKey }, endpoint: endpoint ? formatEndpoint(endpoint, sslEnabled) : undefined, - sslEnabled, - s3ForcePathStyle, - httpOptions: { - agent: this.setAgent(proxy, sslEnabled) - } - } as S3plistApiOptions + tls: sslEnabled, + forcePathStyle: s3ForcePathStyle, + requestHandler: this.setAgent(proxy, sslEnabled) + } this.logger = logger - this.agent = this.setAgent(proxy, sslEnabled) this.proxy = formatHttpProxy(proxy, 'string') as string | undefined } - setAgent (proxy: string | undefined, sslEnabled: boolean) : HttpProxyAgent | HttpsProxyAgent | undefined { - const protocol = sslEnabled ? 'https' : 'http' - const agent = getAgent(proxy, sslEnabled)[protocol] - const commonOptions = { keepAlive: true } + setAgent (proxy: string | undefined, sslEnabled: boolean) : NodeHttpHandler { + const agent = getAgent(proxy, sslEnabled) + const commonOptions: AgentOptions = { + keepAlive: true, + keepAliveMsecs: 1000, + scheduling: 'lifo' as 'lifo' | 'fifo' | undefined + } const extraOptions = sslEnabled ? { rejectUnauthorized: false } : {} - return agent ?? new (sslEnabled ? https.Agent : http.Agent)({ - ...commonOptions, - ...extraOptions - }) + return sslEnabled + ? new NodeHttpHandler({ + httpsAgent: agent.https + ? agent.https + : new https.Agent({ + ...commonOptions, + ...extraOptions + }) + }) + : new NodeHttpHandler({ + httpAgent: agent.http + ? agent.http + : new http.Agent({ + ...commonOptions, + ...extraOptions + }) + }) } logParam = (error:any, method: string) => diff --git a/src/main/manage/utils/common.ts b/src/main/manage/utils/common.ts index a29cd6d..03ee781 100644 --- a/src/main/manage/utils/common.ts +++ b/src/main/manage/utils/common.ts @@ -4,7 +4,7 @@ import mime from 'mime-types' import axios from 'axios' import { app } from 'electron' import crypto from 'crypto' -import got, { RequestError } from 'got' +import got, { OptionsOfTextResponseBody, RequestError } from 'got' import { Stream } from 'stream' import { promisify } from 'util' import UpDownTaskQueue, @@ -40,20 +40,13 @@ export const getFSFile = async ( } } -export const isInputConfigValid = (config: any): boolean => { - if ( - typeof config === 'object' && +export function isInputConfigValid (config: any): boolean { + return typeof config === 'object' && !Array.isArray(config) && Object.keys(config).length > 0 - ) { - return true - } - return false } -export const getFileMimeType = (filePath: string): string => { - return mime.lookup(filePath) || 'application/octet-stream' -} +export const getFileMimeType = (filePath: string): string => mime.lookup(filePath) || 'application/octet-stream' const checkTempFolderExist = async () => { const tempPath = path.join(app.getPath('downloads'), 'piclistTemp') @@ -131,7 +124,7 @@ export const NewDownloader = async ( }) return true } catch (e: any) { - logger && logger.error(formatError(e, { method: 'NewDownloader' })) + logger?.error(formatError(e, { method: 'NewDownloader' })) fs.remove(savedFilePath) instance.updateDownloadTask({ id, @@ -179,13 +172,13 @@ export const gotUpload = async ( .then((res: any) => { instance.updateUploadTask({ id, - progress: res && (res.statusCode === 200 || res.statusCode === 201) ? 100 : 0, - status: res && (res.statusCode === 200 || res.statusCode === 201) ? uploadTaskSpecialStatus.uploaded : commonTaskStatus.failed, + progress: res?.statusCode === 200 || res?.statusCode === 201 ? 100 : 0, + status: res?.statusCode === 200 || res?.statusCode === 201 ? uploadTaskSpecialStatus.uploaded : commonTaskStatus.failed, finishTime: new Date().toLocaleString() }) }) .catch((err: any) => { - logger && logger.error(formatError(err, { method: 'gotUpload' })) + logger?.error(formatError(err, { method: 'gotUpload' })) instance.updateUploadTask({ id, progress: 0, @@ -213,42 +206,46 @@ export const formatError = (err: any, params:IStringKeyMap) => { message: err.message ?? '', stack: err.stack ?? '' } - } else { - if (typeof err === 'object') { - return JSON.stringify(err) + JSON.stringify(params) - } else { - return String(err) + JSON.stringify(params) - } } + if (typeof err === 'object') { + return `${JSON.stringify(err)}${JSON.stringify(params)}` + } + return `${String(err)}${JSON.stringify(params)}` } export const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/') -export const getAgent = (proxy:any, https: boolean = true) => { +const commonOptions = { + keepAlive: true, + keepAliveMsecs: 1000, + scheduling: 'lifo' as 'lifo' | 'fifo' | undefined +} as any + +export const getAgent = (proxy:any, https: boolean = true): { + https?: HttpsProxyAgent + http?: HttpProxyAgent +} => { const formatProxy = formatHttpProxy(proxy, 'string') as any + const commonResult = { + https: undefined, + http: undefined + } + if (!formatProxy) return commonResult + commonOptions.proxy = formatProxy.replace('127.0.0.1', 'localhost') if (https) { - return formatProxy - ? { - https: new HttpsProxyAgent({ - keepAlive: true, - keepAliveMsecs: 1000, - rejectUnauthorized: false, - scheduling: 'lifo' as 'lifo' | 'fifo' | undefined, - proxy: formatProxy.replace('127.0.0.1', 'localhost') - }) - } - : {} - } else { - return formatProxy - ? { - http: new HttpProxyAgent({ - keepAlive: true, - keepAliveMsecs: 1000, - scheduling: 'lifo' as 'lifo' | 'fifo' | undefined, - proxy: formatProxy.replace('127.0.0.1', 'localhost') - }) - } - : {} + return { + https: new HttpsProxyAgent({ + ...commonOptions, + rejectUnauthorized: false + }), + http: undefined + } + } + return { + http: new HttpProxyAgent({ + ...commonOptions + }), + https: undefined } } @@ -258,10 +255,8 @@ export const getInnerAgent = (proxy: any, sslEnabled: boolean = true) => { return formatProxy ? { agent: new https.Agent({ - keepAlive: true, - keepAliveMsecs: 1000, + ...commonOptions, rejectUnauthorized: false, - scheduling: 'lifo' as 'lifo' | 'fifo' | undefined, host: formatProxy.host, port: formatProxy.port }) @@ -272,25 +267,20 @@ export const getInnerAgent = (proxy: any, sslEnabled: boolean = true) => { keepAlive: true }) } - } else { - return formatProxy - ? { - agent: new http.Agent({ - keepAlive: true, - keepAliveMsecs: 1000, - scheduling: 'lifo' as 'lifo' | 'fifo' | undefined, - host: formatProxy.host, - port: formatProxy.port - }) - } - : { - agent: new http.Agent({ - keepAlive: true, - keepAliveMsecs: 1000, - scheduling: 'lifo' as 'lifo' | 'fifo' | undefined - }) - } } + return formatProxy + ? { + agent: new http.Agent({ + ...commonOptions, + host: formatProxy.host, + port: formatProxy.port + }) + } + : { + agent: new http.Agent({ + ...commonOptions + }) + } } export function getOptions ( @@ -301,23 +291,17 @@ export function getOptions ( body?: any, timeout?: number, proxy?: any -) { - const options = { - method: method?.toUpperCase(), - headers, - searchParams, - agent: getAgent(proxy), - timeout: { - request: timeout || 30000 - }, - body, - throwHttpErrors: false, - responseType - } as IStringKeyMap - Object.keys(options).forEach(key => { - options[key] === undefined && delete options[key] - }) - return options +): OptionsOfTextResponseBody { + return { + ...(method && { method: method.toUpperCase() }), + ...(headers && { headers }), + ...(searchParams && { searchParams }), + ...(body && { body }), + ...(responseType && { responseType }), + ...(timeout !== undefined ? { timeout: { request: timeout } } : { timeout: { request: 30000 } }), + ...(proxy && { agent: Object.fromEntries(Object.entries(getAgent(proxy)).filter(([, v]) => v !== undefined)) }), + throwHttpErrors: false + } } export const formatEndpoint = (endpoint: string, sslEnabled: boolean): string => diff --git a/src/renderer/apis/awss3.ts b/src/renderer/apis/awss3.ts index 9546dcc..a0fa156 100644 --- a/src/renderer/apis/awss3.ts +++ b/src/renderer/apis/awss3.ts @@ -1,44 +1,13 @@ -import { S3 } from 'aws-sdk' +import { ipcRenderer } from 'electron' +import { getRawData } from '~/renderer/utils/common' export default class AwsS3Api { static async delete (configMap: IStringKeyMap): Promise { - const { imgUrl, config: { accessKeyID, secretAccessKey, bucketName, region, endpoint, pathStyleAccess, bucketEndpoint, rejectUnauthorized } } = configMap try { - const url = new URL(!/^https?:\/\//.test(imgUrl) ? `http://${imgUrl}` : imgUrl) - const fileKey = url.pathname - let endpointUrl - if (endpoint) { - if (!/^https?:\/\//.test(endpoint)) { - endpointUrl = `http://${endpoint}` - } else { - endpointUrl = endpoint - } - } - let sslEnabled = true - if (endpointUrl) { - sslEnabled = endpointUrl.startsWith('https') - } - const http = sslEnabled ? require('https') : require('http') - const client = new S3({ - accessKeyId: accessKeyID, - secretAccessKey, - endpoint: endpointUrl, - s3ForcePathStyle: pathStyleAccess, - sslEnabled, - region, - s3BucketEndpoint: bucketEndpoint, - httpOptions: { - agent: new http.Agent({ - rejectUnauthorized, - timeout: 30000 - }) - } - }) - const result = await client.deleteObject({ - Bucket: bucketName, - Key: fileKey.replace(/^\/+/, '') - }).promise() - return result.$response.httpResponse.statusCode === 204 + const deleteResult = await ipcRenderer.invoke('delete-aws-s3-file', + getRawData(configMap) + ) + return deleteResult } catch (error) { console.log(error) return false diff --git a/src/renderer/manage/pages/bucketPage.vue b/src/renderer/manage/pages/bucketPage.vue index 6023954..43715b1 100644 --- a/src/renderer/manage/pages/bucketPage.vue +++ b/src/renderer/manage/pages/bucketPage.vue @@ -4,7 +4,7 @@ ea/* */