Feature(custom): add new script system

ISSUES CLOSED: #462
This commit is contained in:
Kuingsmile
2026-01-26 17:57:44 +08:00
parent a10e701cc9
commit 9c8698907e
34 changed files with 1359 additions and 143 deletions

View File

@@ -1,5 +1,7 @@
## 🎉 [v3.3.0] 更新日志
本次更新带来了显著的内存优化,全新的脚本扩展功能和主题系统,此外还对 UI 界面进行了全面的重构和优化。
### 🚀 性能优化
- 减少了 **60-70%** 的闲置内存占用和 **20%** 的打开窗口时内存占用
@@ -7,9 +9,18 @@
### ✨ 新增功能
#### 🛠 脚本扩展
使用自定义的`javascript`脚本来扩展 PicList 的功能无需本机安装node环境更加轻量和灵活。
- 全新的脚本系统,支持图床上传脚本和多种类型的通用脚本
- 支持在上传前后、相册添加/删除前后等多个阶段运行脚本,满足更多自定义需求
- 脚本管理页面,支持新建、编辑、删除和启用/禁用脚本
#### ⚙️ 核心功能
- 现在支持软件内编辑配置文件并保存
- 高级自定义图床现在支持使用脚本上传
- Windows 便携模式,无需安装运行,数据存储在程序目录下的 `data` 文件夹中,且支持自动更新。
- Linux 新增 `rpm` 安装包。
- 新增图床编辑卡片页面,解决多配置切换时的混乱问题。
@@ -28,6 +39,7 @@
- 文件浏览侧边栏名称超出宽度时支持滚动显示完整名称。
- 优化了视频播放页面的显示效果。
- 相册页面支持显示已选数量、匹配的 URL 列表和记忆过滤器状态。
- 优化了设置页面的布局分布
- 支持浏览完整插件列表、查看详情及一键安装。
- 新增新手引导页面,首次运行自动弹出。

View File

@@ -1,31 +1,45 @@
## 🎉 [v3.3.0] Release Notes
### 🚀 Performance Optimization
This update brings significant memory optimizations, a brand new script extension feature, and a theme system. Additionally, the UI has been comprehensively redesigned and optimized.
- Reduced **60-70%** idle memory usage and **20%** memory usage when opening windows.
- Optimized loading speed and browsing performance for multiple pages
### 🚀 Performance Improvements
- Reduced idle memory usage by **60-70%** and memory usage when opening windows by **20%**
- Optimized loading speed and browsing performance on multiple pages
### ✨ New Features
#### 🛠 Script Extensions
Use custom `javascript` scripts to extend PicList's functionality without the need to install a local Node environment, making it lighter and more flexible.
- A brand new script system that supports image hosting upload scripts and various types of general scripts
- Supports running scripts at multiple stages such as before and after uploading, before and after adding/removing albums, meeting more customization needs
- Script management page that supports creating, editing, deleting, and enabling/disabling scripts
#### ⚙️ Core Features
- Now supports manually disabling GPU acceleration to resolve black screen or flickering issues caused by some hardware compatibility.
- Added advanced animation settings for a better UI interaction experience.
- Now supports editing and saving configuration files within the software
- Advanced custom image hosting now supports script uploads
- Windows portable mode, no installation required, data is stored in the `data` folder in the program directory, and supports automatic updates.
- Added `rpm` installation package for Linux.
- Added image hosting editing card page to solve confusion when switching multiple configurations.
- Added list mode support for the file browsing page.
- Added image hosting editing card page to resolve confusion when switching multiple configurations.
- Added list mode support to the file browsing page.
- Now supports manually disabling GPU acceleration to resolve black screen or flickering issues caused by some hardware compatibility.
- Added advanced animation settings for a better UI interaction experience when enabled.
#### 🎨 UI Interface
- Refactored almost all business pages and optimized dozens of UI details.
- Redesigned almost all business pages and optimized dozens of UI details.
- Integrated theme repository [PicList ThemeHub](https://github.com/Kuingsmile/PicList-ThemeHub), supporting custom downloads.
- Provided 12 built-in themes (such as bilibili, 二次元, 极夜紫 styles).
- Redesigned all pages of the management feature
- Optimized album page card styles, clearer boundaries, and improved selection box visual effects.
- Optimized the display of multiple pages on narrow screens to avoid content overflow.
- Redesigned all pages of the management function
- Optimized album page card styles, clearer boundaries, improved selection box visual effects.
- Optimized the display of multiple pages under narrow screens to avoid content overflow.
- Supported scrolling to display the full name when the file browsing sidebar name exceeds the width.
- The album page supports displaying the number of selected items, matching URL lists, and remembering filter states.
- Optimized the display effect of the video playback page.
- The album page supports displaying the number of selected items, the list of matching URLs, and remembering the filter state.
- Optimized the layout distribution of the settings page.
- Supports browsing the complete plugin list, viewing details, and one-click installation.
- Added a new user guide page that automatically pops up on the first run.
@@ -36,9 +50,10 @@
### 🐛 Bug Fixes
- Fixed the issue of abnormal display of the sorting dropdown box on the management page.
- Fixed the issue where the sort dropdown box on the management page displayed abnormally.
- Fixed the issue where the image hosting list on the management page did not correctly highlight the currently selected item.
- Fixed the display issue of the task page in dark mode.
- Fixed the issue where the markdown preview content on the management page did not render correctly.
- Fixed the display anomaly on the task page in dark mode.
- Fixed the issue where the "Set as Default Image Hosting" button status on the image hosting settings page was not updated in a timely manner.
- Fixed the issue where the independent watermark setting button status on the image hosting was not synchronized in the preprocessing settings page.
- Fixed the issue where bottom elements of some pages were obscured.
- Fixed the issue where the button status of the independent watermark setting for image hosting in the preprocessing settings page was not synchronized.
- Fixed the issue where bottom elements on some pages were obscured.

View File

@@ -26,6 +26,7 @@ import { configPaths } from '~/utils/configPaths'
import { IPasteStyle, IWindowList } from '~/utils/enum'
import { isMacOSVersionGreaterThanOrEqualTo } from '~/utils/getMacOSVersion'
import pasteTemplate from '~/utils/pasteTemplate'
import { runScriptInStage } from '~/utils/runScript'
import { hideMiniWindow, openMainWindow, openMiniWindow } from '~/utils/windowHelper'
import menubarPng from '../../../../../resources/menubar.png?asset&asarUnpack'
@@ -308,8 +309,8 @@ export function createTray(tooltip: string) {
const rawInput = cloneDeep(files)
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
const res = await uploader.setWebContents(trayWindow?.webContents).uploadReturnCtx(files)
const imgs = res[0] ? res[0] : false
const backImgs = res[1] ? res[1] : false
const imgs = res.ctx?.output ? res.ctx.output : false
const backImgs = res.backupCtx?.output ? res.backupCtx.output : false
const deleteLocalFile = allConfig.settings?.deleteLocalFile || false
if (imgs !== false) {
const pasteText: string[] = []
@@ -334,7 +335,8 @@ export function createTray(tooltip: string) {
notification.show()
}, i * 100)
}
await GalleryDB.getInstance().insert(imgs[i])
const inserted = await GalleryDB.getInstance().insert(imgs[i])
runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted })
}
handleCopyUrl(pasteText.join('\n'))
trayWindow?.webContents.send('dragFiles', imgs)

View File

@@ -1,46 +1,56 @@
import path from 'node:path'
import { themesDir } from '@core/datastore/dirs'
import * as fsWalk from '@nodelib/fs.walk'
import AdmZip from 'adm-zip'
import axios from 'axios'
import fs from 'fs-extra'
import { randomStringGenerator } from '@/manage/utils/common'
import logger from '~/apis/core/picgo/logger'
import { IWindowList } from '~/utils/enum'
import windowManager from '../window/windowManager'
export async function resolveThemes(): Promise<{ key: string; label: string }[]> {
const files = fsWalk.walkSync(themesDir(), {
followSymbolicLinks: true,
fs,
stats: true,
throwErrorOnBrokenSymbolicLink: false,
})
const result: string[] = []
files.forEach(item => {
if (item.stats?.isFile()) {
result.push(path.basename(item.path))
}
})
const themes = await Promise.all(
result
.filter(file => file.endsWith('.css'))
.map(async file => {
const css = (await fs.readFile(path.join(themesDir(), file), 'utf-8')) || ''
let name = file
if (css.startsWith('/*')) {
name = css.split('\n')[0].replace('/*', '').replace('*/', '').trim() || file
const dir = themesDir()
const entries = await fs.readdir(dir, { withFileTypes: true })
const themePromises = entries
.filter(entry => entry.isFile() && entry.name.endsWith('.css'))
.map(async entry => {
const filePath = path.join(dir, entry.name)
let label = entry.name
let fd
try {
fd = await fs.open(filePath, 'r')
const buffer = Buffer.alloc(256)
const { bytesRead } = await fs.read(fd, buffer, 0, 256, 0)
const content = buffer.toString('utf8', 0, bytesRead)
const match = content.match(/^\/\*\s*(.*?)\s*\*\//)
if (match && match[1]) {
label = match[1].trim()
}
return { key: file, label: name }
}),
)
if (themes.find(theme => theme.key === 'default.css')) {
return themes
} catch (e: any) {
logger.error(e)
} finally {
if (fd !== undefined) await fs.close(fd)
}
return { key: entry.name, label }
})
const themes = await Promise.all(themePromises)
const hasDefault = themes.some(t => t.key === 'default.css')
if (!hasDefault) {
themes.unshift({ key: 'default.css', label: '默认' })
} else {
return [{ key: 'default.css', label: '默认' }, ...themes]
const idx = themes.findIndex(t => t.key === 'default.css')
const [defaultTheme] = themes.splice(idx, 1)
themes.unshift(defaultTheme)
}
return themes
}
export async function fetchThemes(): Promise<boolean> {

View File

@@ -11,8 +11,9 @@ import { handleCopyUrl, handleUrlEncodeWithSetting } from '~/utils/common'
import { configPaths } from '~/utils/configPaths'
import { IPasteStyle, IWindowList } from '~/utils/enum'
import pasteTemplate from '~/utils/pasteTemplate'
import { runScriptInStage } from '~/utils/runScript'
const handleClipboardUploadingReturnCtx = async (img?: IUploadOption): Promise<(ImgInfo[] | false)[]> => {
const handleClipboardUploadingReturnCtx = async (img?: IUploadOption): Promise<IuploadReturnCtxResult> => {
const useBuiltinClipboardConfig = picgo.getConfig<boolean | undefined>(configPaths.settings.useBuiltinClipboard)
const useBuiltinClipboard = useBuiltinClipboardConfig === undefined ? true : !!useBuiltinClipboardConfig
const win = windowManager.getAvailableWindow()
@@ -26,8 +27,8 @@ export const uploadClipboardFiles = async (): Promise<IStringKeyMap> => {
let img: ImgInfo[] | false = false
let backImg: ImgInfo[] | false = false
const res = await handleClipboardUploadingReturnCtx()
img = res[0] ? res[0] : false
backImg = res[1] ? res[1] : false
img = res.ctx?.output ? res.ctx.output : false
backImg = res.backupCtx?.output ? res.backupCtx.output : false
const allConfig = picgo.getConfig<any>() || {}
if (img !== false) {
if (img.length > 0) {
@@ -50,6 +51,7 @@ export const uploadClipboardFiles = async (): Promise<IStringKeyMap> => {
}, 100)
}
const inserted = await GalleryDB.getInstance().insert(img[0])
runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted })
// trayWindow just be created in mac/windows, not in linux
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
trayWindow?.webContents?.send('clipboardFiles', [])
@@ -93,8 +95,8 @@ export const uploadChoosedFiles = async (
let imgs: ImgInfo[] | false = false
let backImgs: ImgInfo[] | false = false
const res = await uploader.setWebContents(webContents).uploadReturnCtx(input)
imgs = res[0] ? res[0] : false
backImgs = res[1] ? res[1] : false
imgs = res.ctx?.output ? res.ctx.output : false
backImgs = res.backupCtx?.output ? res.backupCtx.output : false
const result = []
const allConfig = picgo.getConfig<any>() || {}
if (imgs !== false) {
@@ -140,6 +142,7 @@ export const uploadChoosedFiles = async (
}
}
const inserted = await GalleryDB.getInstance().insert(imgs[i])
runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted })
result.push({
url: handleUrlEncodeWithSetting(inserted.imgUrl!),
fullResult: inserted,

View File

@@ -118,15 +118,15 @@ class Uploader {
return filePath
}
async uploadWithBuildInClipboardReturnCtx(img?: IUploadOption): Promise<(ImgInfo[] | false)[]> {
async uploadWithBuildInClipboardReturnCtx(img?: IUploadOption): Promise<IuploadReturnCtxResult> {
let imgPath: string | false = false
try {
imgPath = await this.getClipboardImagePath()
if (!imgPath) return [false, false]
if (!imgPath) return { ctx: undefined, backupCtx: undefined }
return await this.uploadReturnCtx(img ?? [imgPath])
} catch (e: any) {
logger.error(e)
return [false, false]
return { ctx: undefined, backupCtx: undefined }
} finally {
if (imgPath && imgPath.startsWith(path.join(picgo.baseDir, CLIPBOARD_IMAGE_FOLDER))) {
fs.remove(imgPath)
@@ -134,24 +134,24 @@ class Uploader {
}
}
async uploadReturnCtx(img?: IUploadOption): Promise<(ImgInfo[] | false)[]> {
async uploadReturnCtx(img?: IUploadOption): Promise<IuploadReturnCtxResult> {
try {
const result = [false, false] as (ImgInfo[] | false)[]
const result = { ctx: undefined, backupCtx: undefined } as IuploadReturnCtxResult
const res = await picgo.uploadReturnCtx(img)
const allConfig = picgo.getConfig<any>() || {}
if (Array.isArray(res.output) && res.output.some((item: ImgInfo) => item.imgUrl)) {
res.output.forEach((item: ImgInfo) => {
if (Array.isArray(res.ctx?.output) && res.ctx?.output.some((item: ImgInfo) => item.imgUrl)) {
res.ctx.output.forEach((item: ImgInfo) => {
item.config = JSON.parse(JSON.stringify(allConfig.picBed?.[item.type!]))
})
result[0] = res.output
result.ctx = res.ctx
}
if (Array.isArray(res.backupOutput) && res.backupOutput.some((item: ImgInfo) => item.imgUrl)) {
res.backupOutput.forEach((item: ImgInfo) => {
if (Array.isArray(res.backupCtx?.output) && res.backupCtx?.output.some((item: ImgInfo) => item.imgUrl)) {
res.backupCtx.output.forEach((item: ImgInfo) => {
item.config = JSON.parse(JSON.stringify(allConfig.picBed?.[item.type!]))
})
result[1] = res.backupOutput
result.backupCtx = res.backupCtx
}
return result
} catch (e: any) {
@@ -163,7 +163,7 @@ class Uploader {
clickToCopy: true,
})
}, 500)
return [false, false]
return { ctx: undefined, backupCtx: undefined } as IuploadReturnCtxResult
} finally {
ipcMain.removeAllListeners(GET_RENAME_FILE_NAME)
}

View File

@@ -43,6 +43,10 @@ export function defaultDir() {
return userDataDir()
}
export function scriptsDir() {
return path.join(dataDir(), 'scripts')
}
export function defaultConfigPath() {
if (isPortable()) {
return path.join(exeDir(), 'data', 'data.json')

View File

@@ -13,6 +13,7 @@ import { T as $t } from '~/i18n'
import { handleCopyUrl } from '~/utils/common'
import { IPasteStyle } from '~/utils/enum'
import pasteTemplate from '~/utils/pasteTemplate'
import { runScriptInStage } from '~/utils/runScript'
// Cross-process support may be required in the future
class GuiApi implements IGuiApi {
@@ -74,8 +75,8 @@ class GuiApi implements IGuiApi {
const webContents = this.getWebcontentsByWindowId(this.windowId)
const rawInput = cloneDeep(input)
const res = await uploader.setWebContents(webContents!).uploadReturnCtx(input)
const imgs = res[0] ? res[0] : false
const backImgs = res[1] ? res[1] : false
const imgs = res.ctx?.output ? res.ctx.output : false
const backImgs = res.backupCtx?.output ? res.backupCtx.output : false
let result: ImgInfo[] = []
const allConfig = picgo.getConfig<any>() || {}
if (imgs !== false) {
@@ -103,7 +104,8 @@ class GuiApi implements IGuiApi {
notification.show()
}, i * 100)
}
await GalleryDB.getInstance().insert(imgs[i])
const inserted = await GalleryDB.getInstance().insert(imgs[i])
runScriptInStage('onUploadSuccess', res.ctx || picgo, { galleryItem: inserted })
}
handleCopyUrl(pasteText.join('\n'))
webContents?.send('uploadFiles')

View File

@@ -6,6 +6,7 @@ import { clipboard } from 'electron'
import { RPCRouter } from '~/events/rpc/router'
import { ICOREBuildInEvent, IPasteStyle, IRPCActionType, IRPCType } from '~/utils/enum'
import pasteTemplate from '~/utils/pasteTemplate'
import { runScriptInStage } from '~/utils/runScript'
interface IFilter {
orderBy?: 'asc' | 'desc'
limit?: number
@@ -38,6 +39,7 @@ const galleryRoutes = [
action: IRPCActionType.GALLERY_REMOVE_FILES,
handler: async (_: IIPCEvent, args: [files: ImgInfo[]]) => {
setTimeout(() => {
runScriptInStage('onGalleryRemove', picgo, { galleryItem: args[0] })
picgo.emit(ICOREBuildInEvent.REMOVE, args[0], GuiApi.getInstance())
}, 500)
},

View File

@@ -1,12 +1,15 @@
import path from 'node:path'
import { dataDir } from '@core/datastore/dirs'
import { dataDir, scriptsDir } from '@core/datastore/dirs'
import picgo from '@core/picgo'
import { IpcMainEvent, shell } from 'electron'
import fs from 'fs-extra'
import logger from '~/apis/core/picgo/logger'
import { isAutoStartEnabled, setAutoStart } from '~/utils/autoStart'
import { getDirectoryTree } from '~/utils/common'
import { IRPCActionType, IRPCType } from '~/utils/enum'
import { runScript } from '~/utils/runScript'
const STORE_PATH = dataDir()
@@ -57,9 +60,110 @@ export default [
action: IRPCActionType.WRITE_FILE_CONTENT,
handler: async (_: IIPCEvent, args: [fileName: string, content: string]) => {
const abFilePath = path.join(STORE_PATH, args[0])
fs.ensureDirSync(path.dirname(abFilePath))
fs.writeFileSync(abFilePath, args[1], 'utf-8')
},
},
{
action: IRPCActionType.RUN_SCRIPT_FILE,
handler: async (_: IIPCEvent, args: [fileName: string[]]) => {
const abFilePath = path.join(scriptsDir(), ...args[0])
if (!fs.existsSync(abFilePath)) {
throw new Error('Script file does not exist')
}
const scriptContent = fs.readFileSync(abFilePath, 'utf-8')
try {
await runScript(picgo, scriptContent, {})
logger.info(`Script ${args[0].join('/')} executed successfully`)
return 'Script executed successfully'
} catch (e) {
return Error(`Script execution failed: ${e}`)
}
},
type: IRPCType.INVOKE,
},
{
action: IRPCActionType.CREATE_SCRIPTS_FILE,
handler: async (_: IIPCEvent, args: [fileName: string[], content: string]) => {
const abFilePath = path.join(scriptsDir(), ...args[0])
fs.ensureDirSync(path.dirname(abFilePath))
fs.writeFileSync(abFilePath, args[1], 'utf-8')
},
},
{
action: IRPCActionType.READ_SCRIPTS_FILE,
handler: async (_: IIPCEvent, args: [fileName: string[]]) => {
const abFilePath = path.join(scriptsDir(), ...args[0])
if (!fs.existsSync(abFilePath)) {
return null
}
return fs.readFileSync(abFilePath, 'utf-8')
},
type: IRPCType.INVOKE,
},
{
action: IRPCActionType.LIST_SCRIPTS_FILES,
handler: async (_: IIPCEvent, args: [dirPath: string[]]) => {
const dir = scriptsDir()
const targetDir = path.join(dir, ...(args[0] || []))
if (!(await fs.pathExists(targetDir))) {
return {}
}
try {
return await getDirectoryTree(targetDir)
} catch (error) {
console.error('Failed to list scripts:', error)
return {}
}
},
type: IRPCType.INVOKE,
},
{
action: IRPCActionType.WRITE_SCRIPT_FILE,
handler: async (_: IIPCEvent, args: [fileName: string[], content: string]) => {
const abFilePath = path.join(scriptsDir(), ...args[0])
fs.ensureDirSync(path.dirname(abFilePath))
fs.writeFileSync(abFilePath, args[1], 'utf-8')
},
},
{
action: IRPCActionType.DELETE_SCRIPTS_FILE,
handler: async (_: IIPCEvent, args: [fileName: string[]]) => {
const abFilePath = path.join(scriptsDir(), ...args[0])
if (fs.existsSync(abFilePath)) {
fs.unlinkSync(abFilePath)
}
},
},
{
action: IRPCActionType.GET_FILES_STAT,
handler: async (_: IIPCEvent, [filePaths, type]: [string[][], 'scripts' | 'config']) => {
const basePath = type === 'scripts' ? scriptsDir() : STORE_PATH
const result: IObj[] = []
const statPromises = filePaths.map(async filePath => {
const absolutePath = path.join(basePath, ...filePath)
try {
return await fs.promises.stat(absolutePath)
} catch {
return undefined
}
})
const statsResults = await Promise.all(statPromises)
for (const [index, item] of filePaths.entries()) {
result.push({
fileName: item[item.length - 1],
stats: statsResults[index],
category: item.length > 1 ? item.slice(0, -1).join('.') : '',
filePath: item,
})
}
return result
},
type: IRPCType.INVOKE,
},
{
action: IRPCActionType.PICLIST_OPEN_DIRECTORY,
handler: async (_: IIPCEvent, args: [dirPath?: string, inStorePath?: boolean]) => {

View File

@@ -32,8 +32,8 @@ const trayRoutes = [
const trayWindow = windowManager.get(IWindowList.TRAY_WINDOW)
// macOS use builtin clipboard is OK
const res = await uploader.setWebContents(trayWindow?.webContents).uploadWithBuildInClipboardReturnCtx()
const img = res[0] ? res[0] : false
const backupImgs = res[1] ? res[1] : false
const img = res.ctx?.output ? res.ctx.output : false
const backupImgs = res.backupCtx?.output ? res.backupCtx.output : false
const allConfig = picgo.getConfig<any>() || {}
if (img !== false) {
const pasteStyle = allConfig.settings?.pasteStyle || IPasteStyle.MARKDOWN

View File

@@ -34,7 +34,7 @@ import { IRemoteNoticeTriggerHook, ISartMode, IWindowList } from '~/utils/enum'
import { getUploadFiles } from '~/utils/handleArgv'
import { initI18n } from '~/utils/handleI18n'
import { notificationList } from '~/utils/notification'
import { MemoryMonitor } from '~/utils/performanceOptimizer'
import { runScriptInStage } from '~/utils/runScript'
import { CLIPBOARD_IMAGE_FOLDER } from '~/utils/static'
import updateChecker from '~/utils/updateChecker'
const isDevelopment = process.env.NODE_ENV !== 'production'
@@ -146,9 +146,6 @@ class LifeCycle {
notice.show()
}
}
if (isDevelopment) {
MemoryMonitor.start()
}
await remoteNoticeHandler.init()
remoteNoticeHandler.triggerHook(IRemoteNoticeTriggerHook.APP_START)
if (startMode === ISartMode.MINI && process.platform !== 'darwin') {
@@ -196,6 +193,7 @@ class LifeCycle {
}
const clipboardDir = path.join(picgo.baseDir, CLIPBOARD_IMAGE_FOLDER)
fs.emptyDir(clipboardDir)
runScriptInStage('onSoftwareOpen', picgo, {})
}
app.whenReady().then(readyFunction)
}
@@ -251,7 +249,7 @@ class LifeCycle {
server.shutdown()
webServer.stop()
stopFileServer()
MemoryMonitor.stop()
runScriptInStage('onSoftwareClose', picgo, {})
})
// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {

View File

@@ -375,3 +375,23 @@ export function getUploaderType(ctx: IPicGo): {
const id = picBedConfig._id || ''
return { picBed, id }
}
export async function getDirectoryTree(currentPath: string): Promise<Record<string, any>> {
const result: Record<string, any> = {}
const entries = await fs.readdir(currentPath, { withFileTypes: true })
await Promise.all(
entries.map(async entry => {
const fullPath = path.join(currentPath, entry.name)
if (entry.isDirectory()) {
result[entry.name] = await getDirectoryTree(fullPath)
} else if (entry.isFile()) {
result[entry.name] = null
}
}),
)
return result
}

View File

@@ -87,6 +87,7 @@ export const IRPCActionType = {
SET_CURRENT_LANGUAGE: 'SET_CURRENT_LANGUAGE',
OPEN_WINDOW: 'OPEN_WINDOW',
OPEN_MINI_WINDOW: 'OPEN_MINI_WINDOW',
RELOAD_WINDOW: 'RELOAD_WINDOW',
CLOSE_WINDOW: 'CLOSE_WINDOW',
MINIMIZE_WINDOW: 'MINIMIZE_WINDOW',
SHOW_MINI_PAGE_MENU: 'SHOW_MINI_PAGE_MENU',
@@ -123,9 +124,17 @@ export const IRPCActionType = {
PICLIST_OPEN_DIRECTORY: 'PICLIST_OPEN_DIRECTORY',
PICLIST_AUTO_START: 'PICLIST_AUTO_START',
PICLIST_AUTO_START_STATUS: 'PICLIST_AUTO_START_STATUS',
// file operation rpc
READ_FILE_CONTENT: 'READ_FILE_CONTENT',
WRITE_FILE_CONTENT: 'WRITE_FILE_CONTENT',
RELOAD_WINDOW: 'RELOAD_WINDOW',
CREATE_SCRIPTS_FILE: 'CREATE_SCRIPTS_FILE',
READ_SCRIPTS_FILE: 'READ_SCRIPTS_FILE',
LIST_SCRIPTS_FILES: 'LIST_SCRIPTS_FILES',
WRITE_SCRIPT_FILE: 'WRITE_SCRIPT_FILE',
GET_FILES_STAT: 'GET_FILES_STAT',
DELETE_SCRIPTS_FILE: 'DELETE_SCRIPTS_FILE',
RUN_SCRIPT_FILE: 'RUN_SCRIPT_FILE',
// shortkey setting rpc
SHORTKEY_UPDATE: 'SHORTKEY_UPDATE',

123
src/main/utils/runScript.ts Normal file
View File

@@ -0,0 +1,123 @@
import crypto from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { scriptsDir } from '@core/datastore/dirs'
import picgo from '@core/picgo'
import logger from '@core/picgo/logger'
import axios from 'axios'
import fs from 'fs-extra'
import { IPicGo } from 'piclist'
export const scriptLifecycleStages = [
'onSoftwareOpen',
'onSoftwareClose',
'preProcess',
'beforeTransform',
'transform',
'beforeUpload',
'upload',
'afterUpload',
'onUploadSuccess',
'onGalleryRemove',
'manualTrigger',
'uploader.advancedplist',
] as const
function format(data: unknown): string {
if (data instanceof Error) {
return `${data.name}: ${data.message}\n${data.stack}`
}
try {
return JSON.stringify(data)
} catch {
return String(data)
}
}
export async function runScript(ctx: IPicGo, script: string, extra: Record<string, any>): Promise<any> {
try {
const base64Decode = (str: string): string => Buffer.from(str, 'base64').toString('utf-8')
const base64Encode = (data: Buffer | string): string =>
(Buffer.isBuffer(data) ? data : Buffer.from(String(data))).toString('base64')
const exposedAPI = {
ctx,
extra,
console: Object.freeze({
log: (...args: unknown[]) => ctx.log.info(args.map(format).join(' ')),
info: (...args: unknown[]) => ctx.log.info(args.map(format).join(' ')),
error: (...args: unknown[]) => ctx.log.error(args.map(format).join(' ')),
debug: (...args: unknown[]) => ctx.log.debug(args.map(format).join(' ')),
}),
axios,
crypto,
setTimeout,
setInterval,
clearTimeout,
clearInterval,
fs,
path,
base64Decode,
base64Encode,
os,
Buffer,
}
vm.createContext(exposedAPI)
ctx.log.info('start to run script')
vm.runInContext(script, exposedAPI)
const promise = vm.runInContext(
`(async () => {
const result = main(ctx, extra)
if (result instanceof Promise) return await result
return result
})()`,
exposedAPI,
)
const result = await promise
ctx.log.info('script executed successfully')
return result
} catch (e) {
ctx.log.error(`script execution failed: ${e}`)
throw e
}
}
export async function runScriptInStage(stage: string, ctx: IPicGo, extra: Record<string, any>): Promise<void> {
const baseDir = scriptsDir()
const enabledPaths: string[] = []
let scriptDir: string
const allConfig = picgo.getConfig<any>() || {}
const disabledList: string[] = allConfig.scripts?.disabledList || []
if (stage === 'uploader.advancedplist') {
scriptDir = path.join(baseDir, 'uploader', 'advancedplist')
stage = 'uploader/advancedplist'
} else {
scriptDir = path.join(baseDir, stage)
}
const files = await fs.readdir(scriptDir).catch(() => [])
if (files.length === 0) {
return
}
for (const file of files) {
if (file.endsWith('.js')) {
if (!disabledList.includes(`${stage}/${file}`)) {
enabledPaths.push(path.join(scriptDir, file))
}
}
}
if (enabledPaths.length === 0) {
logger.info(`no enabled scripts found in stage ${stage}`)
return
}
for (const scriptPath of enabledPaths) {
const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
try {
await runScript(ctx, scriptContent, extra)
logger.info(`script ${scriptPath} in stage ${stage} executed successfully`)
} catch (e) {
logger.error(`script ${scriptPath} in stage ${stage} execution failed: ${e}`)
}
}
}

View File

@@ -238,8 +238,8 @@ class UploadTaskQueueManager {
const rawInput = cloneDeep(input)
const res = await uploader.setWebContents(webContents).uploadReturnCtx(input)
const imgs = res[0] ? res[0] : false
const backupImgs = res[1] ? res[1] : false
const imgs = res.ctx?.output ? res.ctx.output : false
const backupImgs = res.backupCtx?.output ? res.backupCtx.output : false
const allConfig = picgo.getConfig<any>() || {}
if (imgs !== false && imgs.length > 0) {

View File

@@ -21,14 +21,14 @@ const editorRef = ref(null)
const view = shallowRef(null)
onMounted(() => {
const languageExtension = props.language === 'json' ? json() : javascript()
const startState = EditorState.create({
doc: props.modelValue,
extensions: [
lineNumbers(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
json(),
javascript(),
languageExtension,
oneDark,
search({ top: true }),
keymap.of([...searchKeymap]),

View File

@@ -95,6 +95,7 @@
</div>
</template>
</SettingCard>
<slot name="extra-config" />
<slot />
</SettingSection>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="tight ? 'mb-0' : 'mb-3'" class="flex items-center gap-2 text-sm font-medium text-main">
<div v-if="title" :class="tight ? 'mb-0' : 'mb-3'" class="flex items-center gap-2 text-sm font-medium text-main">
<slot name="icon">
<component :is="icon" v-if="icon" :size="iconSize" class="text-accent" />
</slot>
@@ -68,14 +68,14 @@ onClickOutside(dropdownRef, () => {
const {
tight = true,
title,
title = '',
icon = null,
iconSize = 18,
zeroPlaceholder,
allList,
} = defineProps<{
tight?: boolean
title: string
title?: string
icon?: any
iconSize?: number
zeroPlaceholder: string

View File

@@ -4,6 +4,7 @@
},
"common": {
"cancel": "Cancel",
"clear": "Clear",
"close": "Close",
"confirm": "Confirm",
"edit": "Edit",
@@ -69,6 +70,7 @@
"picbed": "PicBed",
"picBedQrCode": "PicBed QR Code",
"plugins": "Plugins",
"scripts": "Scripts",
"selected": "Selected",
"selectPicBeds": "Select PicBeds",
"settings": "Settings",
@@ -714,15 +716,65 @@
"rename": {
"placeholder": "Please enter new file name"
},
"scripts": {
"addNew": "Add New Script",
"chooseScriptType": "Please choose script type",
"confirmDelete": "Confirm delete script?",
"createScript": "Create Script",
"createScriptsToGo": "Please create scripts to get started",
"deleteFailed": "Delete script failed",
"deleteScript": "Delete Script",
"deleteScriptConfirm": "Confirm delete script {name}?",
"deleteScriptTitle": "Delete Script",
"deleteSuccess": "Delete script succeeded",
"description": "Extend PicList functionality with lightweight scripts",
"disabled": "Disabled",
"disableScript": "Disable Script",
"duplicateScriptNameError": "Script name already exists, please use a different name",
"editScript": "Edit Script",
"emptyScriptList": "Script list is empty",
"enabled": "Enabled",
"enableScript": "Enable Script",
"newScript": "New Script",
"NoScripts": "No Scripts",
"noScriptsFound": "No Scripts Found",
"openScriptFolder": "Open Script Folder",
"pleaseEnterScriptName": "Please enter script name",
"runScript": "Run Script",
"runScriptFailed": "Run script failed {errorMessage}",
"runScriptSuccess": "Script ran successfully",
"scriptContentPlaceholder": "Please enter script content",
"scriptNamePlaceholder": "Please enter script name",
"scriptSaved": "Script saved",
"scriptsTypes": {
"_name": "Script Types",
"afterUpload": "After Upload",
"beforeTransform": "Before Transform",
"beforeUpload": "Before Upload",
"manualTrigger": "Manual Trigger",
"onGalleryRemove": "On Gallery Remove",
"onSoftwareClose": "On Software Close",
"onSoftwareOpen": "On Software Open",
"onUploadSuccess": "On Upload Success",
"preProcess": "Pre Process",
"transform": "Transform",
"upload": "On Upload",
"uploader": {
"advancedplist": "Advanced Custom Uploader"
}
},
"selectScriptType": "Select Script Type",
"title": "Script Management"
},
"settings": {
"advanced": {
"chooseLogLevel": "Please select log level",
"enableServer": "Enable Upload API Service",
"enableWebServer": "Enable Web Server",
"guiLogFile": "GUI Log File",
"guiLogFile": "Open GUI Log File",
"invalidJson": "Invalid JSON format",
"logDialogDesc": "View log files and configure log settings",
"logFile": "General Log File",
"logFile": "Open General Log File",
"logFilePath": "Log File Path",
"logFilePathDesc": "View log file directory",
"logFileSize": "Log File Size",
@@ -737,7 +789,7 @@
"success": "Success",
"warn": "Warn"
},
"manageLogFile": "Manage Log File",
"manageLogFile": "Open Cloud Log File",
"networkAndProxy": "Network and Proxy",
"pluginInstallMirror": "Plugin Install Mirror",
"pluginInstallProxy": "Plugin Install Proxy",
@@ -782,7 +834,8 @@
"commonConfig": "Common Configuration",
"configureSync": "Platform Config",
"downloadSettings": "Download Settings",
"editConfigFile": "Edit Configuration File",
"editCloudConfigFile": "Edit Cloud Configuration File",
"editConfigFile": "Edit General Configuration File",
"fileManagement": "File Management",
"galleryDB": "Gallery Database Sync",
"gitea": {
@@ -804,7 +857,7 @@
"token": "GitHub Access Token",
"username": "GitHub Username"
},
"manageConfig": "Manage Configuration",
"manageConfig": "Cloud Configuration",
"migrateDesc": "Import configuration and gallery data from PicGo",
"migrateDescPicList": "Import configuration and gallery data from PicList Installation",
"migrateFromPicGo": "Migrate from PicGo",
@@ -815,8 +868,8 @@
"mirgrateSuccess": "Import Successful, Please Restart PicList to Take Effect",
"mirgrateTitle": "Notification",
"notConfigured": "Not configured",
"openConfigFile": "Open Configuration File",
"openConfigFileDir": "Open Configuration File Directory",
"openConfigFile": "Open General Configuration File",
"openConfigFileDir": "Open Application File Directory",
"selectType": "Please select sync platform",
"syncActions": "Sync Actions",
"syncConfigProxy": "Proxy",
@@ -919,8 +972,8 @@
"advancedRnameDialogDesc": "Configure advanced renaming rules for file uploads",
"advancedRnameFormat": "Advanced Rename Format",
"autoCopyUrlAfterUpload": "Auto Copy URL After Upload",
"autoImportInManage": "Auto Import Configuration in Management Page",
"autoImportInManageHint": "After enabling, the management page will automatically import the corresponding image bed configuration",
"autoImportInManage": "Auto Import Configuration in Cloud Page",
"autoImportInManageHint": "After enabling, the cloud page will automatically import the corresponding image bed configuration",
"autoImportPicBed": "Select the image bed to enable auto import",
"availablePlaceholders": "Available Placeholders",
"availablePlaceholdersTitle": "Use these placeholders to customize link format",
@@ -1154,6 +1207,15 @@
"title": "Configurations"
}
},
"scripts": {
"createScript": "Create Script",
"deleteScript": "Delete Script",
"duplicateScriptNameError": "Script name already exists",
"editScripts": "Edit Script",
"newScriptTitle": "New Script",
"noScriptsFound": "No Scripts Found",
"pleaseEnterScriptName": "Please enter script name"
},
"settings": {
"theme": {
"auto": "Auto",

View File

@@ -4,6 +4,7 @@
},
"common": {
"cancel": "取消",
"clear": "清空",
"close": "关闭",
"confirm": "确认",
"edit": "编辑",
@@ -69,6 +70,7 @@
"picbed": "图床",
"picBedQrCode": "图床配置二维码",
"plugins": "插件",
"scripts": "脚本",
"selected": "已选中",
"selectPicBeds": "请选择图床",
"settings": "设置",
@@ -547,7 +549,7 @@
"savedConfigs": "已配置云端",
"selectPlaceholder": "请选择",
"tips": "提示",
"title": "云端管理",
"title": "云端存储管理",
"viewDetails": "查看详情"
},
"main": {
@@ -714,15 +716,65 @@
"rename": {
"placeholder": "请输入新的文件名"
},
"scripts": {
"addNew": "添加新脚本",
"chooseScriptType": "请选择脚本类型",
"confirmDelete": "确认删除脚本吗?",
"createScript": "创建脚本",
"createScriptsToGo": "请先创建脚本以开始使用",
"deleteFailed": "删除脚本失败",
"deleteScript": "删除脚本",
"deleteScriptConfirm": "确认删除脚本 {name} 吗?",
"deleteScriptTitle": "删除脚本",
"deleteSuccess": "删除脚本成功",
"description": "使用轻量级脚本扩展 PicList 的功能",
"disabled": "已禁用",
"disableScript": "禁用脚本",
"duplicateScriptNameError": "脚本名称已存在,请使用不同的名称",
"editScript": "编辑脚本",
"emptyScriptList": "脚本列表为空",
"enabled": "已启用",
"enableScript": "启用脚本",
"newScript": "新脚本",
"NoScripts": "暂无脚本",
"noScriptsFound": "未找到脚本",
"openScriptFolder": "打开脚本文件夹",
"pleaseEnterScriptName": "请输入脚本名称",
"runScript": "运行脚本",
"runScriptFailed": "运行脚本失败 {errorMessage}",
"runScriptSuccess": "脚本运行成功",
"scriptContentPlaceholder": "请输入脚本内容",
"scriptNamePlaceholder": "请输入脚本名称",
"scriptSaved": "脚本已保存",
"scriptsTypes": {
"_name": "脚本类型",
"afterUpload": "上传后",
"beforeTransform": "图片变换前",
"beforeUpload": "上传前",
"manualTrigger": "手动触发",
"onGalleryRemove": "相册删除时",
"onSoftwareClose": "软件关闭时",
"onSoftwareOpen": "软件启动时",
"onUploadSuccess": "上传成功时",
"preProcess": "上传处理前",
"transform": "图片变换时",
"upload": "上传时",
"uploader": {
"advancedplist": "高级自定义图床"
}
},
"selectScriptType": "选择脚本类型",
"title": "脚本管理"
},
"settings": {
"advanced": {
"chooseLogLevel": "请选择日志记录等级",
"enableServer": "是否开启上传API服务",
"enableWebServer": "是否开启 Web 服务",
"guiLogFile": "GUI 日志文件",
"guiLogFile": "打开 GUI 日志文件",
"invalidJson": "无效的JSON格式",
"logDialogDesc": "查看日志文件和配置日志设置",
"logFile": "常规日志文件",
"logFile": "打开常规日志文件",
"logFilePath": "日志文件路径",
"logFilePathDesc": "查看日志文件所在目录",
"logFileSize": "日志文件大小",
@@ -737,7 +789,7 @@
"success": "成功",
"warn": "警告"
},
"manageLogFile": "管理日志文件",
"manageLogFile": "打开云端日志文件",
"networkAndProxy": "网络与代理",
"pluginInstallMirror": "插件安装镜像",
"pluginInstallProxy": "插件安装代理",
@@ -782,7 +834,8 @@
"commonConfig": "通用配置",
"configureSync": "平台设置",
"downloadSettings": "下载配置",
"editConfigFile": "编辑配置文件",
"editCloudConfigFile": "编辑云端配置文件",
"editConfigFile": "编辑通用配置文件",
"fileManagement": "文件管理",
"galleryDB": "相册数据库同步",
"gitea": {
@@ -804,7 +857,7 @@
"token": "GitHub 访问令牌",
"username": "GitHub 用户名"
},
"manageConfig": "管理配置",
"manageConfig": "云端管理配置",
"migrateDesc": "从 PicGo 导入配置和相册数据",
"migrateDescPicList": "从安装版PicList导入配置和相册数据",
"migrateFromPicGo": "从PicGo迁移",
@@ -815,8 +868,8 @@
"mirgrateSuccess": "导入成功, 请重启PicList生效",
"mirgrateTitle": "通知",
"notConfigured": "未配置",
"openConfigFile": "打开配置文件",
"openConfigFileDir": "打开配置文件目录",
"openConfigFile": "打开通用配置文件",
"openConfigFileDir": "打开应用文件目录",
"selectType": "请选择平台",
"syncActions": "同步操作",
"syncConfigProxy": "代理",
@@ -919,8 +972,8 @@
"advancedRnameDialogDesc": "配置文件上传时的高级重命名规则",
"advancedRnameFormat": "高级重命名格式",
"autoCopyUrlAfterUpload": "上传后自动复制URL",
"autoImportInManage": "管理页面自动导入配置",
"autoImportInManageHint": "启用后,管理页面将自动导入对应图床配置",
"autoImportInManage": "云端页面自动导入配置",
"autoImportInManageHint": "启用后,云端页面将自动导入对应图床配置",
"autoImportPicBed": "选择需要开启自动导入的图床",
"availablePlaceholders": "可用占位符",
"availablePlaceholdersTitle": "使用以下占位符自定义链接格式",
@@ -1146,6 +1199,7 @@
"duplicateSuccess": "拷贝成功",
"duplicateTitle": "拷贝配置",
"edit": "编辑",
"removeFromFavorites": "从快速切换中移除",
"selected": "已启用",
"setAsDefault": "设为默认图床",

View File

@@ -4,6 +4,7 @@
},
"common": {
"cancel": "取消",
"clear": "清空",
"close": "關閉",
"confirm": "確認",
"edit": "編輯",
@@ -69,6 +70,7 @@
"picbed": "圖床",
"picBedQrCode": "圖床配置 QRCODE",
"plugins": "插件",
"scripts": "腳本",
"selected": "已選中",
"selectPicBeds": "請選擇圖床",
"settings": "設定",
@@ -714,15 +716,65 @@
"rename": {
"placeholder": "請輸入新的檔案名稱"
},
"scripts": {
"addNew": "添加新腳本",
"chooseScriptType": "请选择腳本類型",
"confirmDelete": "确认删除腳本吗?",
"createScript": "创建腳本",
"createScriptsToGo": "请先创建腳本以开始使用",
"deleteFailed": "删除腳本失败",
"deleteScript": "删除腳本",
"deleteScriptConfirm": "确认删除腳本 {name} 吗?",
"deleteScriptTitle": "删除腳本",
"deleteSuccess": "删除腳本成功",
"description": "使用轻量级脚本扩展 PicList 的功能",
"disabled": "已禁用",
"disableScript": "禁用腳本",
"duplicateScriptNameError": "腳本名稱已存在,請使用不同的名稱",
"editScript": "編輯腳本",
"emptyScriptList": "腳本列表為空",
"enabled": "已啟用",
"enableScript": "啟用腳本",
"newScript": "新腳本",
"NoScripts": "暫無腳本",
"noScriptsFound": "未找到腳本",
"openScriptFolder": "打開腳本文件夾",
"pleaseEnterScriptName": "請輸入腳本名稱",
"runScript": "運行腳本",
"runScriptFailed": "運行腳本失敗 {errorMessage}",
"runScriptSuccess": "腳本運行成功",
"scriptContentPlaceholder": "請輸入腳本內容",
"scriptNamePlaceholder": "請輸入腳本名稱",
"scriptSaved": "腳本已保存",
"scriptsTypes": {
"_name": "腳本類型",
"afterUpload": "上傳後",
"beforeTransform": "圖片變換前",
"beforeUpload": "上傳前",
"manualTrigger": "手動觸發",
"onGalleryRemove": "相冊刪除時",
"onSoftwareClose": "軟件關閉時",
"onSoftwareOpen": "軟件啟動時",
"onUploadSuccess": "上傳成功時",
"preProcess": "上傳處理前",
"transform": "圖片變換時",
"upload": "上傳時",
"uploader": {
"advancedplist": "高級自定義圖床"
}
},
"selectScriptType": "選擇腳本類型",
"title": "腳本管理"
},
"settings": {
"advanced": {
"chooseLogLevel": "請選擇日誌記錄等級",
"enableServer": "是否開啟上傳API服務",
"enableWebServer": "是否開啟 Web 服務",
"guiLogFile": "GUI 日誌文件",
"guiLogFile": "打開 GUI 日誌文件",
"invalidJson": "無效的 JSON 格式",
"logDialogDesc": "查看日誌文件和配置日誌設置",
"logFile": "常規日誌文件",
"logFile": "打開常規日誌文件",
"logFilePath": "日誌文件路徑",
"logFilePathDesc": "查看日誌文件所在目錄",
"logFileSize": "日誌文件大小",
@@ -737,7 +789,7 @@
"success": "成功",
"warn": "警告"
},
"manageLogFile": "管理日誌文件",
"manageLogFile": "打開雲端日誌文件",
"networkAndProxy": "網絡與代理",
"pluginInstallMirror": "插件安裝鏡像",
"pluginInstallProxy": "插件安裝代理",
@@ -782,7 +834,8 @@
"commonConfig": "通用配置",
"configureSync": "平台配置",
"downloadSettings": "下載配置",
"editConfigFile": "編輯配置文件",
"editCloudConfigFile": "編輯雲端配置文件",
"editConfigFile": "編輯通用配置文件",
"fileManagement": "文件管理",
"galleryDB": "相冊數據庫同步",
"gitea": {
@@ -804,7 +857,7 @@
"token": "GitHub 訪問令牌",
"username": "GitHub 用戶名"
},
"manageConfig": "管理配置",
"manageConfig": "雲端配置",
"migrateDesc": "從 PicGo 導入配置和相冊數據",
"migrateDescPicList": "從安裝版 PicList 導入配置和相冊數據",
"migrateFromPicGo": "從PicGo遷移",
@@ -815,8 +868,8 @@
"mirgrateSuccess": "導入成功, 請重啟PicList生效",
"mirgrateTitle": "通知",
"notConfigured": "未配置",
"openConfigFile": "打開配置文件",
"openConfigFileDir": "打開配置文件目錄",
"openConfigFile": "打開通用配置文件",
"openConfigFileDir": "打開應用文件目錄",
"selectType": "請選擇同步平台類型",
"syncActions": "同步操作",
"syncConfigProxy": "代理",
@@ -919,8 +972,8 @@
"advancedRnameDialogDesc": "配置文件上傳時的高級重命名規則",
"advancedRnameFormat": "高級重命名格式",
"autoCopyUrlAfterUpload": "上傳後自動複製URL",
"autoImportInManage": "管理頁面自動導入配置",
"autoImportInManageHint": "啟用後,管理頁面將自動導入對應圖床配置",
"autoImportInManage": "雲端頁面自動導入配置",
"autoImportInManageHint": "啟用後,雲端頁面將自動導入對應圖床配置",
"autoImportPicBed": "選擇需要開啟自動導入的圖床",
"availablePlaceholders": "可用占位符",
"availablePlaceholdersTitle": "使用以下占位符自定義鏈接格式",
@@ -1154,6 +1207,15 @@
"title": "配置"
}
},
"scripts": {
"createScript": "創建腳本",
"deleteScript": "刪除腳本",
"duplicateScriptNameError": "腳本名稱已存在",
"editScripts": "編輯腳本",
"newScriptTitle": "新建腳本",
"noScriptsFound": "未找到腳本",
"pleaseEnterScriptName": "請輸入腳本名稱"
},
"settings": {
"theme": {
"auto": "自動",

View File

@@ -221,6 +221,7 @@ import {
Cloud,
CopyIcon,
DatabaseIcon,
FileCode,
ImagesIcon,
Info,
PlugIcon,
@@ -271,6 +272,11 @@ const navigationItems = computed(() => [
path: '/main-page/plugins',
icon: PlugIcon,
},
{
name: t('navigation.scripts'),
path: '/main-page/scripts',
icon: FileCode,
},
])
watch(

View File

@@ -37,7 +37,7 @@
<config-form :id="type" ref="$configForm" :config="config" type="uploader">
<!-- Action Buttons -->
<div class="mb-4 flex flex-wrap gap-3 rounded-xl border border-border bg-accent/10 p-4">
<CustomButton type="secondary" :icon="RotateCcw" :text="t('common.reset')" @click="handleReset" />
<CustomButton type="secondary" :icon="RotateCcw" :text="t('common.clear')" @click="handleReset" />
<CustomButton type="primary" :icon="Check" :text="t('common.confirm')" @click="handleConfirm" />
<div v-if="picBedConfigList.length > 0" class="relative">
@@ -179,6 +179,7 @@ const handleConfirm = async () => {
async function getPicBeds() {
try {
const result = await window.electron.triggerRPC<any>(IRPCActionType.PICBED_GET_PICBED_CONFIG, $route.params.type)
console.log('PicBed config result:', result)
config.value = result.config
picBedName.value = result.name
} catch (error) {
@@ -220,7 +221,30 @@ async function handleConfigImport(configItem: IUploaderConfigListItem) {
const handleReset = async () => {
try {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_RESET_CONFIG, type.value, $route.params.configId)
config.value.forEach(item => {
let defaultValue
switch (item.type) {
case 'text':
case 'password':
defaultValue = ''
break
case 'number':
defaultValue = 0
break
case 'checkbox':
defaultValue = []
break
case 'select':
defaultValue = item.choices && item.choices.length > 0 ? item.choices[0].value : null
break
case 'switch':
defaultValue = false
break
default:
defaultValue = null
}
$configForm.value?.updateRuleForm(item.name, defaultValue)
})
message.success(t('pages.picBedConfigs.resetSuccess'))
} catch (error) {
console.error('Failed to reset configuration:', error)

View File

@@ -279,6 +279,11 @@
:icon="Edit"
@click="editFile('data.json')"
/>
<CustomNavCard
:title="t('pages.settings.sync.editCloudConfigFile')"
:icon="Edit"
@click="editFile('manage.json')"
/>
<CustomNavCard
:title="t('pages.settings.sync.openConfigFileDir')"
:icon="FolderOpen"
@@ -562,10 +567,22 @@
>
<SettingSection :icon="FileText" :title="t('pages.settings.advanced.logging')">
<CustomNavCard
:title="t('pages.settings.advanced.logFilePath')"
:description="t('pages.settings.advanced.logFilePathDesc')"
:icon="FolderOpen"
@click="openDirectory"
:title="t('pages.settings.advanced.logFile')"
description="piclist.log"
:icon="FileText"
@click="openFile('piclist.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.guiLogFile')"
description="piclist-gui-local.log"
:icon="FileText"
@click="openFile('piclist-gui-local.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.manageLogFile')"
description="manage.log"
:icon="FileText"
@click="openFile('manage.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.setLog')"
@@ -902,24 +919,6 @@
>
<div class="flex h-full w-full flex-col p-4">
<SettingSection>
<CustomNavCard
:title="t('pages.settings.advanced.logFile')"
description="piclist.log"
:icon="FileText"
@click="openFile('piclist.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.guiLogFile')"
description="piclist-gui-local.log"
:icon="FileText"
@click="openFile('piclist-gui-local.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.manageLogFile')"
description="manage.log"
:icon="FileText"
@click="openFile('manage.log')"
/>
<CustomInput
v-model="formOfSetting.logFileSizeLimit"
:title="t('pages.settings.advanced.logFileSize')"

View File

@@ -5,7 +5,7 @@
class="flex w-full items-center justify-between gap-4 overflow-visible rounded-2xl border border-border-secondary px-6 py-2 shadow-md max-md:items-stretch max-md:p-5"
>
<div class="flex flex-1 flex-wrap items-center gap-4 p-1">
<DatabaseIcon :size="24" class="text-accent" />
<PlugIcon :size="24" class="text-accent" />
<div>
<h1 class="m-0 text-2xl font-semibold tracking-tight text-main">{{ t('pages.plugin.title') }}</h1>
<p class="m-0 text-sm text-secondary">{{ t('pages.plugin.description') }}</p>
@@ -407,10 +407,10 @@ import { debounce, DebouncedFunc } from 'lodash-es'
import {
AlertCircleIcon,
CheckIcon,
DatabaseIcon,
DownloadIcon,
ExternalLinkIcon,
PackageIcon,
PlugIcon,
RefreshCwIcon,
SearchIcon,
SettingsIcon,
@@ -835,9 +835,6 @@ onBeforeUnmount(() => {
window.electron.ipcRendererRemoveAllListeners(PICGO_CONFIG_PLUGIN)
window.electron.ipcRendererRemoveAllListeners(PICGO_HANDLE_PLUGIN_ING)
window.electron.ipcRendererRemoveAllListeners(PICGO_TOGGLE_PLUGIN)
// Reset body overflow
document.body.style.overflow = 'auto'
})
</script>

View File

@@ -0,0 +1,430 @@
<template>
<div class="relative flex h-full w-full items-center justify-center">
<div class="relative z-1 flex h-full w-full flex-col items-center justify-start gap-4 rounded-xl border-none p-4">
<div
class="flex w-full items-center justify-between gap-4 overflow-visible rounded-2xl border border-border-secondary px-6 py-2 shadow-md max-md:items-stretch max-md:p-5"
>
<div class="flex flex-1 flex-wrap items-center gap-4 p-1">
<FileCode :size="24" class="text-accent" />
<div>
<h1 class="m-0 text-2xl font-semibold tracking-tight text-main">{{ t('pages.scripts.title') }}</h1>
<p class="m-0 text-sm text-secondary">{{ t('pages.scripts.description') }}</p>
</div>
</div>
<div class="flex flex-wrap gap-3 overflow-visible">
<div class="flex max-w-[220px] min-w-[180px] flex-1 flex-col gap-1">
<MultiSelect
v-model:choosed="choosedCat"
:zero-placeholder="t('pages.scripts.chooseScriptType')"
:all-list="supportedScriptCategories"
/>
</div>
<CustomButton
type="primary"
:icon="FolderOpen"
:text="t('pages.scripts.openScriptFolder')"
@click="handleOpenScriptFolder"
/>
</div>
</div>
<!-- Plugin Grid -->
<div
class="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden rounded-2xl border border-border-secondary p-4 shadow-md"
>
<div class="no-scrollbar h-full w-full overflow-auto rounded-sm">
<div class="grid w-full grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-5 border-none p-1 max-md:gap-4">
<div
v-for="(item, index) in scriptsList"
:key="item.fileName + index"
class="group/config-card relative flex min-h-[160px] cursor-pointer flex-col gap-6 overflow-hidden rounded-xl border border-border-secondary p-5 shadow-sm transition-all duration-fast ease-apple hover:border-2 hover:border-accent hover:shadow-md [.disabled]:opacity-80"
:class="{
disabled:
!item.enabled && item.category !== 'manualTrigger' && item.category !== 'uploader.advancedplist',
}"
>
<div
class="absolute right-1 bottom-0 flex h-[15px] w-auto items-center rounded-md bg-accent/70 px-2 py-1 text-xs font-semibold text-white"
>
{{ supportedScriptCategories.find(cat => cat.type === item.category)?.name || item.category }}
</div>
<div class="relative z-1 flex flex-1 items-start justify-between">
<div
class="peer flex h-[40px] w-[40px] items-center justify-center rounded-lg border border-border-secondary text-accent transition-all duration-fast ease-apple group-hover/config-card:scale-105 [.is-active]:border-none [.is-active]:bg-accent [.is-active]:text-white"
:class="{ 'is-active': item.enabled }"
>
<FileCode :size="20" />
</div>
<div class="grid grid-cols-2 gap-1.5 transition-all duration-fast ease-apple">
<button
class="action-btn"
:title="t('pages.scripts.editScript')"
@click.stop="openEditPage(item.filePath)"
>
<Pencil :size="14" />
</button>
<button
class="action-btn danger"
:title="t('pages.scripts.deleteScript')"
@click.stop="() => deleteConfig(item.filePath)"
>
<Trash2 :size="14" />
</button>
<button
v-if="item.category === 'manualTrigger'"
class="action-btn bg-accent/50 text-white!"
:title="t('pages.scripts.runScript')"
@click.stop="runScript(item.filePath)"
>
<Play :size="14" />
</button>
<button
v-if="item.category !== 'manualTrigger' && item.category !== 'uploader.advancedplist'"
class="action-btn"
:title="item.enabled ? t('pages.scripts.disableScript') : t('pages.scripts.enableScript')"
@click.stop="toggleScript(item.filePath)"
>
<template v-if="!item.enabled">
<CheckCircle2 :size="14" />
</template>
<template v-else>
<XIcon :size="14" />
</template>
</button>
</div>
</div>
<div class="relative z-1 flex-1">
<div class="mx-0 mt-0 mb-2 flex items-center text-base font-semibold tracking-tight text-main">
{{ item.fileName }}
</div>
<div class="mb-3 flex items-center gap-1.5 text-xs text-tertiary">
<div class="flex items-center gap-1">
<Clock :size="12" />
<span>{{ formatDate(item.mtimeMs) }}</span>
</div>
<div
v-if="item.enabled"
class="inline-flex items-center gap-1.5 rounded-2xl bg-accent/40 px-3 py-1.5 text-xs font-medium text-white transition-all duration-fast ease-standard"
>
<CheckCircle2 :size="15" />
<span>{{ t('pages.scripts.enabled') }}</span>
</div>
<div
v-else
class="inline-flex items-center gap-1.5 rounded-2xl px-3 py-1.5 text-xs font-medium text-tertiary transition-all duration-fast ease-standard group-hover/config-card:bg-accent/10"
>
<span>{{ t('pages.scripts.disabled') }}</span>
</div>
</div>
</div>
</div>
<div
key="add-new"
class="group/new relative flex min-h-[180px] cursor-pointer flex-col items-center justify-center gap-6 overflow-hidden rounded-xl border-2 border-dashed border-border p-5 shadow-sm transition-all duration-fast ease-apple hover:border-solid hover:border-accent hover:bg-surface hover:shadow-md"
@click="openNewScriptsNameDialog"
>
<div class="flex flex-col items-center gap-3 transition-all duration-fast ease-apple">
<div
class="flex h-[56px] w-[56px] items-center justify-center rounded-xl border-2 border-dashed border-border text-tertiary transition-all duration-fast ease-apple group-hover/new:scale-105 group-hover/new:border-solid group-hover/new:border-accent group-hover/new:bg-accent/5 group-hover/new:text-accent"
>
<Plus :size="24" />
</div>
<div class="flex flex-col items-center gap-1">
<span class="text-base font-semibold text-secondary">{{ t('pages.scripts.addNew') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<CustomModal v-if="editorVisible" v-model:visible="editorVisible" :title="t('common.edit')">
<Editor v-model="editorContent" language="javascript" />
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="editorVisible = false" />
<CustomButton type="primary" :text="t('common.save')" @click="saveEditorContent" />
</template>
</CustomModal>
<CustomModal
v-if="newScriptNameVisible"
v-model:visible="newScriptNameVisible"
:title="t('pages.scripts.addNew')"
height="auto"
width="600px"
>
<div class="flex flex-col items-center justify-center gap-4 bg-bg-secondary p-6">
<SettingCard class="w-full">
<SingleSelect
v-model="newScriptCategory"
:title="t('pages.scripts.selectScriptType')"
:key-list="supportedScriptCategories.map(cat => cat.type)"
:fronticon="false"
>
<template #item="{ item }">
{{
supportedScriptCategories.find(cat => cat.type === item)
? supportedScriptCategories.find(cat => cat.type === item)?.name
: item
}}
</template>
</SingleSelect>
</SettingCard>
<SettingCard class="w-full">
<CustomInput
v-model="newScriptName"
:title="t('pages.scripts.pleaseEnterScriptName')"
placeholder="test.js"
/>
</SettingCard>
</div>
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="newScriptNameVisible = false" />
<CustomButton type="primary" :text="t('common.confirm')" @click="handleNewScriptNameConfirm" />
</template>
</CustomModal>
</div>
</template>
<script lang="ts" setup>
import dayjs from 'dayjs'
import { CheckCircle2, Clock, FileCode, FolderOpen, Pencil, Play, Plus, Trash2, XIcon } from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CustomButton from '@/components/common/CustomButton.vue'
import CustomInput from '@/components/common/CustomInput.vue'
import CustomModal from '@/components/common/CustomModal.vue'
import MultiSelect from '@/components/common/MultiSelect.vue'
import SettingCard from '@/components/common/SettingCard.vue'
import SingleSelect from '@/components/common/SingleSelect.vue'
import Editor from '@/components/Editor.vue'
import useConfirm from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import { getRawData } from '@/utils/common'
import { configPaths } from '@/utils/configPaths'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { II18nLanguage, IRPCActionType } from '@/utils/enum'
import { defaultScriptTemplate, defaultScriptTemplateEn } from '@/utils/static'
const { t } = useI18n()
const message = useMessage()
const { confirm } = useConfirm()
const scriptsMap = ref<Record<string, any>>({})
const choosedCat = ref<string[]>([])
const scriptsList = ref<IStringKeyMap[]>([])
const editorVisible = ref(false)
const editorContent = ref('')
const editingScriptName = ref<string[]>([])
const newScriptNameVisible = ref(false)
const newScriptName = ref('')
const newScriptCategory = ref('manualTrigger')
const supportedScriptCategories = [
{ type: 'onSoftwareOpen', name: t('pages.scripts.scriptsTypes.onSoftwareOpen') },
{ type: 'onSoftwareClose', name: t('pages.scripts.scriptsTypes.onSoftwareClose') },
{ type: 'preProcess', name: t('pages.scripts.scriptsTypes.preProcess') },
{ type: 'beforeTransform', name: t('pages.scripts.scriptsTypes.beforeTransform') },
{ type: 'transform', name: t('pages.scripts.scriptsTypes.transform') },
{ type: 'beforeUpload', name: t('pages.scripts.scriptsTypes.beforeUpload') },
{ type: 'upload', name: t('pages.scripts.scriptsTypes.upload') },
{ type: 'afterUpload', name: t('pages.scripts.scriptsTypes.afterUpload') },
{ type: 'onUploadSuccess', name: t('pages.scripts.scriptsTypes.onUploadSuccess') },
{ type: 'onGalleryRemove', name: t('pages.scripts.scriptsTypes.onGalleryRemove') },
{ type: 'manualTrigger', name: t('pages.scripts.scriptsTypes.manualTrigger') },
{ type: 'uploader.advancedplist', name: t('pages.scripts.scriptsTypes.uploader.advancedplist') },
]
const existingPathsSet = computed(() => {
return new Set(scriptsList.value.map(item => item.filePath.join('/')))
})
watch(scriptsMap, async () => {
await refreshList()
})
watch(choosedCat, async () => {
await refreshList()
})
async function refreshList() {
const result: string[][] = []
const keysToCheck = choosedCat.value.length > 0 ? choosedCat.value : supportedScriptCategories.map(cat => cat.type)
for (const key of keysToCheck) {
if (key.includes('.')) {
const parts = key.split('.')
const value = scriptsMap.value[parts[0]] ? scriptsMap.value[parts[0]][parts[1]] : undefined
if (value) {
Object.entries(value).forEach(([valueKey, item]: [string, any]) => {
if (item === null) {
result.push([parts[0], parts[1], valueKey])
}
})
}
} else {
const value = scriptsMap.value[key]
if (value) {
Object.entries(value).forEach(([valueKey, item]: [string, any]) => {
if (item === null) {
result.push([key, valueKey])
}
})
}
}
}
const fileStats =
(await window.electron.triggerRPC<IObj[]>(IRPCActionType.GET_FILES_STAT, getRawData(result), 'scripts')) || []
const disabledList = ((await getConfig(configPaths.scripts.disabledList)) as string[] | undefined) || []
console.log('disabledList', disabledList)
fileStats.forEach(file => {
const fullPath = file.filePath.join('/')
file.enabled = !disabledList.includes(fullPath)
})
scriptsList.value = fileStats
}
async function getScriptsMap() {
scriptsMap.value =
(await window.electron.triggerRPC<Record<string, any>>(IRPCActionType.LIST_SCRIPTS_FILES, [])) || {}
}
function formatDate(timestamp: number) {
const date = dayjs(timestamp)
return date.format('YYYY/MM/DD HH:mm:ss')
}
async function getTemplate() {
const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN
if (lang === II18nLanguage.ZH_CN || lang === II18nLanguage.ZH_TW) {
return defaultScriptTemplate
} else {
return defaultScriptTemplateEn
}
}
async function openEditPage(filePath: string[], mode: 'edit' | 'new' = 'edit') {
editingScriptName.value = filePath
if (mode === 'edit') {
const content =
(await window.electron.triggerRPC<string>(IRPCActionType.READ_SCRIPTS_FILE, getRawData(filePath))) || ''
editorContent.value = content
} else {
editorContent.value = await getTemplate()
}
editorVisible.value = true
}
async function saveEditorContent() {
const content = editorContent.value.trim()
try {
window.electron.sendRPC(IRPCActionType.WRITE_SCRIPT_FILE, getRawData(editingScriptName.value), content)
message.success(t('pages.settings.advanced.saveFileSuccess'))
await getScriptsMap()
} catch (error) {
console.error('Failed to save file:', error)
message.error(t('pages.settings.advanced.saveFileFailed'))
}
editorVisible.value = false
}
async function deleteConfig(scriptPath: string[]) {
const result = await confirm({
title: t('pages.scripts.deleteScriptTitle'),
message: t('pages.scripts.deleteScriptConfirm', { name: scriptPath[scriptPath.length - 1] }),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true,
})
if (!result) return
try {
window.electron.sendRPC(IRPCActionType.DELETE_SCRIPTS_FILE, getRawData(scriptPath))
message.success(t('pages.scripts.deleteSuccess'))
await getScriptsMap()
} catch (error) {
console.error('Failed to delete script file:', error)
message.error(t('pages.scripts.deleteFailed'))
}
}
function handleOpenScriptFolder() {
window.electron.sendRPC(IRPCActionType.PICLIST_OPEN_DIRECTORY, 'scripts', true)
}
function openNewScriptsNameDialog() {
newScriptName.value = ''
newScriptNameVisible.value = true
}
async function runScript(scriptPath: string[]) {
const result = await window.electron.triggerRPC(IRPCActionType.RUN_SCRIPT_FILE, getRawData(scriptPath))
if (result instanceof Error) {
const errorMessage = result.message || 'Unknown error'
message.error(`${t('pages.scripts.runScriptFailed', { errorMessage })}`)
} else {
message.success(t('pages.scripts.runScriptSuccess'))
}
}
function checkDup(fullPath: string[]) {
return existingPathsSet.value.has(fullPath.join('/'))
}
function handleNewScriptNameConfirm() {
let trimmedName = newScriptName.value.trim()
trimmedName = trimmedName.endsWith('.js') ? trimmedName : `${trimmedName}.js`
if (!trimmedName) {
message.error(t('pages.scripts.pleaseEnterScriptName'))
return
}
const scriptPath = newScriptCategory.value.includes('.')
? [...newScriptCategory.value.split('.'), trimmedName]
: [newScriptCategory.value, trimmedName]
if (checkDup(scriptPath)) {
message.error(t('pages.scripts.duplicateScriptNameError'))
return
}
newScriptNameVisible.value = false
openEditPage(scriptPath, 'new')
}
async function toggleScript(scriptPath: string[]) {
const disabledList = ((await getConfig(configPaths.scripts.disabledList)) as string[] | undefined) || []
const fullPath = scriptPath.join('/')
if (disabledList.includes(fullPath)) {
const index = disabledList.indexOf(fullPath)
if (index > -1) {
disabledList.splice(index, 1)
}
} else {
disabledList.push(fullPath)
}
saveConfig(configPaths.scripts.disabledList, disabledList)
await getScriptsMap()
}
onBeforeMount(async () => {
getScriptsMap()
})
onBeforeUnmount(() => {})
</script>
<script lang="ts">
export default {
name: 'ScriptPage',
}
</script>
<style scoped>
@import 'tailwindcss' reference;
@import '../assets/css/theme.css' reference;
@import '../assets/css/utilities.css' reference;
.action-btn {
@apply flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-md border border-accent/20 text-secondary transition-all duration-fast ease-standard hover:scale-105 hover:bg-accent/30 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 hover:not-disabled:[.danger]:border-danger hover:not-disabled:[.danger]:bg-danger;
}
</style>

View File

@@ -17,6 +17,58 @@
</div>
</div>
<div class="flex items-center justify-center gap-3">
<div class="relative">
<CustomButton
v-if="type === 'advancedplist'"
type="secondary"
:text="t('pages.scripts.editScript')"
@click="openScriptsList"
/>
<div
v-if="scriptsListVisible"
class="absolute top-full left-1/2 z-10 mt-2 w-max -translate-x-1/2 gap-2 rounded-md border-2 border-border bg-bg-tertiary px-3 py-1.5 text-sm font-medium text-main shadow-md transition-all duration-fast ease-apple"
>
<div class="no-scrollbar flex max-h-[200px] min-w-[150px] flex-col overflow-auto">
<div
v-for="script in scriptsList"
:key="script"
class="cursor-pointer rounded-md border-b border-border px-2 py-1 text-center whitespace-nowrap last:border-b-0 hover:bg-accent/20"
@click="handleScriptClick(script)"
>
{{ script }}
</div>
</div>
</div>
</div>
<CustomButton
v-if="type === 'advancedplist'"
type="primary"
:text="t('pages.scripts.createScript')"
@click="openNewScriptsNameDialog"
/>
<div class="relative">
<CustomButton
v-if="type === 'advancedplist'"
type="secondary"
:text="t('pages.scripts.deleteScript')"
@click="openDeleteScriptsList"
/>
<div
v-if="deleteScriptListVisible"
class="absolute top-full left-1/2 z-10 mt-2 w-max -translate-x-1/2 gap-2 rounded-md border-2 border-border bg-bg-tertiary px-3 py-1.5 text-sm font-medium text-main shadow-md transition-all duration-fast ease-apple"
>
<div class="no-scrollbar flex max-h-[200px] min-w-[150px] flex-col overflow-auto">
<div
v-for="script in scriptsList"
:key="script"
class="cursor-pointer rounded-md border-b border-border px-2 py-1 text-center whitespace-nowrap last:border-b-0 hover:bg-accent/20"
@click="deleteScript(script)"
>
{{ script }}
</div>
</div>
</div>
</div>
<button
class="relative inline-flex cursor-pointer items-center justify-center gap-2 overflow-hidden rounded-lg border-none bg-accent px-6 py-3 font-[inherit] text-sm font-semibold text-white shadow-sm transition-all duration-fast ease-apple disabled:cursor-not-allowed disabled:bg-surface disabled:text-secondary disabled:opacity-60"
:disabled="defaultPicBedG === type"
@@ -39,7 +91,7 @@
<div
v-for="(item, index) in curConfigList"
:key="item._id"
class="group/config-card relative flex min-h-[180px] cursor-pointer flex-col gap-6 overflow-hidden rounded-xl border border-border-secondary p-5 shadow-sm transition-all duration-fast ease-apple hover:border-accent hover:shadow-md [.is-active]:border-2 [.is-active]:border-accent [.is-active]:shadow-md"
class="group/config-card relative flex min-h-[180px] cursor-pointer flex-col gap-6 overflow-hidden rounded-xl border border-border-secondary p-5 shadow-sm transition-all duration-fast ease-apple hover:border-2 hover:border-accent hover:shadow-md [.is-active]:border-2 [.is-active]:border-accent [.is-active]:shadow-md"
:class="{ 'is-active': defaultConfigId === item._id }"
:style="{ '--delay': `${index * 50}ms` }"
@click="() => selectItem(item._id)"
@@ -135,6 +187,36 @@
</div>
</div>
</div>
<CustomModal v-if="editorVisible" v-model:visible="editorVisible" :title="t('common.edit')">
<Editor v-model="editorContent" language="javascript" />
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="editorVisible = false" />
<CustomButton type="primary" :text="t('common.save')" @click="saveEditorContent" />
</template>
</CustomModal>
<CustomModal
v-if="newScriptNameVisible"
v-model:visible="newScriptNameVisible"
:title="t('pages.scripts.addNew')"
height="auto"
width="400px"
>
<div class="flex items-center justify-center bg-bg-secondary p-6">
<SettingCard class="w-full">
<CustomInput
v-model="newScriptName"
:title="t('pages.scripts.pleaseEnterScriptName')"
placeholder="test.js"
/>
</SettingCard>
</div>
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="newScriptNameVisible = false" />
<CustomButton type="primary" :text="t('common.confirm')" @click="handleNewScriptNameConfirm" />
</template>
</CustomModal>
</div>
</template>
@@ -146,6 +228,11 @@ import { computed, onBeforeMount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import CustomButton from '@/components/common/CustomButton.vue'
import CustomInput from '@/components/common/CustomInput.vue'
import CustomModal from '@/components/common/CustomModal.vue'
import SettingCard from '@/components/common/SettingCard.vue'
import Editor from '@/components/Editor.vue'
import useConfirm from '@/hooks/useConfirm'
import { usePicBed } from '@/hooks/useGlobal'
import useMessage from '@/hooks/useMessage'
@@ -153,8 +240,9 @@ import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config'
import $bus from '@/utils/bus'
import { configPaths } from '@/utils/configPaths'
import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant'
import { saveConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { II18nLanguage, IRPCActionType } from '@/utils/enum'
import { defaultScriptTemplate, defaultScriptTemplateEn } from '@/utils/static'
const { t } = useI18n()
const message = useMessage()
@@ -166,6 +254,14 @@ const favoritePicbeds = useStorage<IFavoritePicbedItem[]>('favorite-picbeds', []
const type = ref('')
const curConfigList = ref<IStringKeyMap[]>([])
const defaultConfigId = ref('')
const scriptsListVisible = ref(false)
const scriptsList = ref<string[]>([])
const editorVisible = ref(false)
const editorContent = ref('')
const editingScriptName = ref('')
const newScriptNameVisible = ref(false)
const newScriptName = ref('')
const deleteScriptListVisible = ref(false)
const picBedName = computed(() => {
if (!picBedG.value || picBedG.value.length === 0) {
@@ -316,6 +412,122 @@ function setDefaultPicBed(type: string) {
message.success(t('pages.uploaderConfig.setSuccess'))
}
async function getScriptsList() {
const scriptsFiles = await window.electron.triggerRPC<Record<string, any>>(IRPCActionType.LIST_SCRIPTS_FILES, [
'uploader',
'advancedplist',
])
scriptsList.value = Object.keys(scriptsFiles || {}).filter(fileName => fileName.endsWith('.js'))
}
async function openScriptsList() {
if (scriptsListVisible.value) {
scriptsListVisible.value = false
return
}
await getScriptsList()
if (scriptsList.value.length === 0) {
message.info(t('pages.scripts.noScriptsFound'))
return
}
scriptsListVisible.value = true
}
function openNewScriptsNameDialog() {
newScriptName.value = ''
newScriptNameVisible.value = true
}
async function getTemplate() {
const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN
if (lang === II18nLanguage.ZH_CN || lang === II18nLanguage.ZH_TW) {
return defaultScriptTemplate
} else {
return defaultScriptTemplateEn
}
}
async function openEditScripts(scriptName: string, mode: 'edit' | 'new' = 'edit') {
editingScriptName.value = scriptName
if (mode === 'edit') {
const filePath = ['uploader', 'advancedplist', editingScriptName.value]
const content = (await window.electron.triggerRPC<string>(IRPCActionType.READ_SCRIPTS_FILE, filePath)) || ''
editorContent.value = content
} else {
editorContent.value = await getTemplate()
}
editorVisible.value = true
}
async function saveEditorContent() {
const file = ['uploader', 'advancedplist', editingScriptName.value]
const content = editorContent.value.trim()
try {
window.electron.sendRPC(IRPCActionType.WRITE_SCRIPT_FILE, file, content)
message.success(t('pages.settings.advanced.saveFileSuccess'))
await getScriptsList()
} catch (error) {
console.error('Failed to save file:', error)
message.error(t('pages.settings.advanced.saveFileFailed'))
}
editorVisible.value = false
}
function handleNewScriptNameConfirm() {
let trimmedName = newScriptName.value.trim()
trimmedName = trimmedName.endsWith('.js') ? trimmedName : `${trimmedName}.js`
if (!trimmedName) {
message.error(t('pages.scripts.pleaseEnterScriptName'))
return
}
if (scriptsList.value.includes(trimmedName)) {
message.error(t('pages.scripts.duplicateScriptNameError'))
return
}
newScriptNameVisible.value = false
openEditScripts(trimmedName, 'new')
}
function handleScriptClick(scriptName: string) {
scriptsListVisible.value = false
openEditScripts(scriptName)
}
function openDeleteScriptsList() {
if (deleteScriptListVisible.value) {
deleteScriptListVisible.value = false
return
}
getScriptsList().then(() => {
if (scriptsList.value.length === 0) {
message.info(t('pages.scripts.noScriptsFound'))
return
}
deleteScriptListVisible.value = true
})
}
async function deleteScript(scriptName: string) {
const result = await confirm({
title: t('pages.scripts.deleteScriptTitle'),
message: t('pages.scripts.deleteScriptConfirm', { name: scriptName }),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true,
})
if (!result) return
try {
const filePath = ['uploader', 'advancedplist', scriptName]
window.electron.sendRPC(IRPCActionType.DELETE_SCRIPTS_FILE, filePath)
message.success(t('pages.scripts.deleteSuccess'))
await getScriptsList()
} catch (error) {
console.error('Failed to delete script file:', error)
message.error(t('pages.scripts.deleteFailed'))
}
}
onBeforeRouteUpdate((to, _, next) => {
if (to.params.type && to.name === UPLOADER_CONFIG_PAGE) {
type.value = to.params.type as string
@@ -327,6 +539,7 @@ onBeforeRouteUpdate((to, _, next) => {
onBeforeMount(() => {
type.value = route.params.type as string
getCurrentConfigList()
getScriptsList()
})
</script>

View File

@@ -19,3 +19,4 @@ export const UPDATE_PAGE = 'UpdatePage'
export const UPLOAD_PAGE = 'UploadPage'
export const UPLOADER_CONFIG_PAGE = 'UploaderConfigPage'
export const MANAGE_EDIT_PAGE = 'ManageEditPage'
export const SCRIPT_PAGE = 'ScriptPage'

View File

@@ -12,6 +12,7 @@ import PicBedsPage from '@/pages/PicBed.vue'
import SettingPage from '@/pages/PicGoSetting.vue'
import PluginPage from '@/pages/Plugin.vue'
import RenamePage from '@/pages/RenamePage.vue'
import ScriptPage from '@/pages/ScriptPage.vue'
import ShortKeyPage from '@/pages/ShortKey.vue'
import Toolbox from '@/pages/Toolbox.vue'
import TrayPage from '@/pages/TrayPage.vue'
@@ -104,6 +105,11 @@ export default createRouter({
component: PluginPage,
name: config.PLUGIN_PAGE,
},
{
path: 'scripts',
component: ScriptPage,
name: config.SCRIPT_PAGE,
},
{
path: 'shortKey',
component: ShortKeyPage,

View File

@@ -93,6 +93,9 @@ export interface IConfigStruct {
needReload: boolean
picgoPlugins: IPicGoPlugins
uploader: IUploaderConfig
scripts: {
disabledList: string[]
}
buildIn: {
compress: IBuildInCompressOptions
watermark: IBuildInWaterMarkOptions
@@ -189,6 +192,9 @@ export const configPaths = {
needReload: 'needReload',
picgoPlugins: 'picgoPlugins',
uploader: 'uploader',
scripts: {
disabledList: 'scripts.disabledList',
},
buildIn: {
_name: 'buildIn',
compress: 'buildIn.compress',

View File

@@ -30,6 +30,7 @@ export const IRPCActionType = {
OPEN_WINDOW: 'OPEN_WINDOW',
OPEN_MINI_WINDOW: 'OPEN_MINI_WINDOW',
CLOSE_WINDOW: 'CLOSE_WINDOW',
RELOAD_WINDOW: 'RELOAD_WINDOW',
MINIMIZE_WINDOW: 'MINIMIZE_WINDOW',
SHOW_MINI_PAGE_MENU: 'SHOW_MINI_PAGE_MENU',
SHOW_MAIN_PAGE_MENU: 'SHOW_MAIN_PAGE_MENU',
@@ -41,6 +42,7 @@ export const IRPCActionType = {
MAIN_WINDOW_ON_TOP: 'MAIN_WINDOW_ON_TOP',
UPDATE_MINI_WINDOW_ICON: 'UPDATE_MINI_WINDOW_ICON',
REFRESH_SETTING_WINDOW: 'REFRESH_SETTING_WINDOW',
// picbed RPC
PICBED_GET_PICBED_CONFIG: 'PICBED_GET_PICBED_CONFIG',
PICBED_GET_CONFIG_LIST: 'PICBED_GET_CONFIG_LIST',
@@ -51,6 +53,8 @@ export const IRPCActionType = {
UPLOADER_UPDATE_CONFIG: 'UPLOADER_UPDATE_CONFIG',
UPLOADER_RESET_CONFIG: 'UPLOADER_RESET_CONFIG',
DELETE_ALL_API: 'DELETE_ALL_API',
GET_FILES_STAT: 'GET_FILES_STAT',
RUN_SCRIPT_FILE: 'RUN_SCRIPT_FILE',
// toolbox rpc
TOOLBOX_CHECK: 'TOOLBOX_CHECK',
@@ -65,9 +69,15 @@ export const IRPCActionType = {
PICLIST_OPEN_DIRECTORY: 'PICLIST_OPEN_DIRECTORY',
PICLIST_AUTO_START: 'PICLIST_AUTO_START',
PICLIST_AUTO_START_STATUS: 'PICLIST_AUTO_START_STATUS',
// file operation rpc
READ_FILE_CONTENT: 'READ_FILE_CONTENT',
WRITE_FILE_CONTENT: 'WRITE_FILE_CONTENT',
RELOAD_WINDOW: 'RELOAD_WINDOW',
CREATE_SCRIPTS_FILE: 'CREATE_SCRIPTS_FILE',
READ_SCRIPTS_FILE: 'READ_SCRIPTS_FILE',
LIST_SCRIPTS_FILES: 'LIST_SCRIPTS_FILES',
WRITE_SCRIPT_FILE: 'WRITE_SCRIPT_FILE',
DELETE_SCRIPTS_FILE: 'DELETE_SCRIPTS_FILE',
// shortkey setting rpc
SHORTKEY_UPDATE: 'SHORTKEY_UPDATE',

View File

@@ -67,3 +67,25 @@ export const picBedManualUrlList: IStringKeyMap = {
webdavplist: 'https://piclist.cn/en/configure.html#webdav',
},
}
export const defaultScriptTemplate = `
// ctx 为 核心PicList实例, extra为额外参数, 其中extra.galleryItem为当前删除的相册对象
// 可用额外API: axios, crypto, fs, path, os, setTimeout, setInterval, clearTimeout, clearInterval, base64Decode, base64Encode
// 图床上传脚本必须返回 ctx 对象, 其它脚本可根据需求返回任意数据
async function main(ctx, extra) {
// 在这里编写你的脚本代码
return ctx
}
`
export const defaultScriptTemplateEn = `
// ctx is the core PicList instance, extra is additional parameters, among which extra.galleryItem is the currently deleted album object
// Available additional APIs: axios, crypto, fs, path, os, setTimeout, setInterval, clearTimeout, clearInterval, base64Decode, base64Encode
// The image bed upload script must return the ctx object, other scripts can return any data as needed
async function main(ctx, extra) {
// Write your script code here
return ctx
}
`

View File

@@ -528,3 +528,22 @@ interface IFavoritePicbedItem {
type: string
configName: string
}
interface IuploadReturnCtxResult {
ctx: import('piclist').IPicGo | undefined
backupCtx: import('piclist').IPicGo | undefined
}
type IScriptLifecycle =
| 'onSoftwareOpen'
| 'onSoftwareClose'
| 'preProcess'
| 'beforeTransform'
| 'transform'
| 'beforeUpload'
| 'upload'
| 'afterUpload'
| 'onUploadSuccess'
| 'onGalleryRemove'
| 'manualTrigger'
| 'uploader.advancedplist'