🐛 Fix(custom): fix memory leak issues

This commit is contained in:
Kuingsmile
2025-08-11 17:38:39 +08:00
parent 0bf435a0da
commit 1a35831301
16 changed files with 7505 additions and 7507 deletions

View File

@@ -1,96 +1,101 @@
import crypto from 'node:crypto'
import path from 'node:path'
import { clipboard, contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import fs from 'fs-extra'
import yaml from 'js-yaml'
import mime from 'mime-types'
import { isReactive, isRef, toRaw, unref } from 'vue'
import type { IpcRendererListener } from '#/types/electron'
export const getRawData = (args: any): any => {
if (isRef(args)) return unref(args)
if (isReactive(args)) return toRaw(args)
if (Array.isArray(args)) return args.map(getRawData)
if (typeof args === 'object' && args !== null) {
const data = {} as Record<string, any>
for (const key in args) {
data[key] = getRawData(args[key])
}
return data
}
return args
}
function sendToMain (channel: string, ...args: any[]) {
ipcRenderer.send(channel, ...getRawData(args))
}
function sendRPC (action: string, ...args: any[]): void {
ipcRenderer.send('RPC_ACTIONS', action, getRawData(args))
}
async function triggerRPC<T> (action: string, ...args: any[]): Promise<T | undefined> {
return await ipcRenderer.invoke('RPC_ACTIONS_INVOKE', action, getRawData(args))
}
function sendRpcSync (action: string, ...args: any[]): any {
return ipcRenderer.sendSync('RPC_ACTIONS', action, getRawData(args))
}
try {
contextBridge.exposeInMainWorld('electron', {
setVisualZoomLevelLimits: (min: number, max: number) => {
webFrame.setVisualZoomLevelLimits(min, max)
},
clipboard: {
writeText: clipboard.writeText
},
platform: process.platform,
sendRpcSync,
triggerRPC,
sendToMain,
sendRPC,
ipcRendererOn: (channel: string, listener: IpcRendererListener) => {
ipcRenderer.on(channel, listener)
},
ipcRendererRemoveListener: (channel: string, listener: IpcRendererListener) => {
ipcRenderer.removeListener(channel, listener)
},
showFilePath (file: File) {
return webUtils.getPathForFile(file)
}
})
contextBridge.exposeInMainWorld('node', {
path: {
join: path.join,
dirname: path.dirname,
basename: path.basename,
normalize: path.normalize,
extname: path.extname,
sep: path.sep,
posix: {
sep: path.posix.sep
}
},
fs: {
remove: fs.remove,
readFile: fs.readFile,
statSync: fs.statSync
},
crypto: {
randomBytes: crypto.randomBytes,
createHash: crypto.createHash
},
yaml: {
load: yaml.load
},
mime: {
lookup: mime.lookup
}
})
} catch (error) {
console.error(error)
}
import crypto from 'node:crypto'
import path from 'node:path'
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webFrame, webUtils } from 'electron'
import fs from 'fs-extra'
import yaml from 'js-yaml'
import mime from 'mime-types'
import { isReactive, isRef, toRaw, unref } from 'vue'
export const getRawData = (args: any): any => {
if (isRef(args)) return unref(args)
if (isReactive(args)) return toRaw(args)
if (Array.isArray(args)) return args.map(getRawData)
if (typeof args === 'object' && args !== null) {
const data = {} as Record<string, any>
for (const key in args) {
data[key] = getRawData(args[key])
}
return data
}
return args
}
function sendToMain (channel: string, ...args: any[]) {
ipcRenderer.send(channel, ...getRawData(args))
}
function sendRPC (action: string, ...args: any[]): void {
ipcRenderer.send('RPC_ACTIONS', action, getRawData(args))
}
async function triggerRPC<T> (action: string, ...args: any[]): Promise<T | undefined> {
return await ipcRenderer.invoke('RPC_ACTIONS_INVOKE', action, getRawData(args))
}
function sendRpcSync (action: string, ...args: any[]): any {
return ipcRenderer.sendSync('RPC_ACTIONS', action, getRawData(args))
}
try {
contextBridge.exposeInMainWorld('electron', {
setVisualZoomLevelLimits: (min: number, max: number) => {
webFrame.setVisualZoomLevelLimits(min, max)
},
clipboard: {
writeText: clipboard.writeText
},
platform: process.platform,
sendRpcSync,
triggerRPC,
sendToMain,
sendRPC,
ipcRendererOn: (channel: string, listener: (...args: any[]) => void) => {
const subscription = (_: IpcRendererEvent, ...args: any[]) => listener(...args)
ipcRenderer.on(channel, subscription)
return () => {
ipcRenderer.removeListener(channel, subscription)
}
},
ipcRendererCountListeners: (channel: string): number => {
return ipcRenderer.listenerCount(channel)
},
ipcRendererRemoveAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel)
},
showFilePath (file: File) {
return webUtils.getPathForFile(file)
}
})
contextBridge.exposeInMainWorld('node', {
path: {
join: path.join,
dirname: path.dirname,
basename: path.basename,
normalize: path.normalize,
extname: path.extname,
sep: path.sep,
posix: {
sep: path.posix.sep
}
},
fs: {
remove: fs.remove,
readFile: fs.readFile,
statSync: fs.statSync
},
crypto: {
randomBytes: crypto.randomBytes,
createHash: crypto.createHash
},
yaml: {
load: yaml.load
},
mime: {
lookup: mime.lookup
}
})
} catch (error) {
console.error(error)
}

View File

@@ -50,7 +50,6 @@
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import { X } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -67,12 +66,9 @@ const inputBoxOptions = reactive({
placeholder: ''
})
onBeforeMount(() => {
window.electron.ipcRendererOn(SHOW_INPUT_BOX, ipcEventHandler)
$bus.on(SHOW_INPUT_BOX, initInputBoxValue)
})
let removeInputBoxListenerCallback: (() => void) = () => {}
function ipcEventHandler (_: IpcRendererEvent, options: IShowInputBoxOption) {
function handleIpcInputBoxEvent (options: IShowInputBoxOption) {
initInputBoxValue(options)
}
@@ -96,16 +92,23 @@ function handleInputBoxConfirm () {
$bus.emit(SHOW_INPUT_BOX_RESPONSE, inputBoxValue.value)
}
onBeforeMount(() => {
removeInputBoxListenerCallback = window.electron.ipcRendererOn(SHOW_INPUT_BOX, handleIpcInputBoxEvent)
$bus.on(SHOW_INPUT_BOX, initInputBoxValue)
})
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener(SHOW_INPUT_BOX, ipcEventHandler)
removeInputBoxListenerCallback()
$bus.off(SHOW_INPUT_BOX)
})
</script>
<script lang="ts">
export default {
name: 'InputBoxDialog'
}
</script>
<style scoped>
.inputbox-overlay {
position: fixed;

View File

@@ -2,7 +2,10 @@
<nav class="navigation">
<div class="title-bar">
<div class="app-title">
<div class="app-text">
<div
class="app-text"
@click="openGithubPage"
>
{{ t('app.title') }}
</div>
<div class="app-version">
@@ -79,18 +82,6 @@
class="qr-dialog"
@close="qrcodeVisible = false"
>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="dialog-overlay" />
</TransitionChild>
<div class="dialog-container">
<TransitionChild
as="template"
@@ -217,7 +208,7 @@ import { pick } from 'lodash-es'
import { CheckIcon, ChevronDownIcon, CopyIcon, DatabaseIcon, FolderIcon, Info, PieChartIcon, PlugIcon, Settings, UploadIcon } from 'lucide-vue-next'
import QrcodeVue from 'qrcode.vue'
import pkg from 'root/package.json'
import { computed, nextTick, onBeforeMount, reactive, Ref, ref, watch } from 'vue'
import { computed, nextTick, onBeforeMount, onBeforeUnmount, reactive, Ref, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import useMessage from '@/hooks/useMessage'
@@ -237,6 +228,8 @@ const qrcodeVisible = ref(false)
const choosedPicBedForQRCode: Ref<string[]> = ref([])
const picBedConfigString = ref('')
let removeIpcListener: () => void = () => {}
watch(
() => choosedPicBedForQRCode,
val => {
@@ -280,9 +273,17 @@ const navigationItems = computed(() => [
}
])
function openGithubPage () {
window.electron.sendRPC(IRPCActionType.OPEN_URL, 'https://github.com/Kuingsmile/PicList')
}
onBeforeMount(() => {
updatePicBedGlobal()
window.electron.ipcRendererOn(SHOW_MAIN_PAGE_QRCODE, qrCodeHandler)
removeIpcListener = window.electron.ipcRendererOn(SHOW_MAIN_PAGE_QRCODE, qrCodeHandler)
})
onBeforeUnmount(() => {
removeIpcListener()
})
</script>
@@ -333,6 +334,11 @@ onBeforeMount(() => {
letter-spacing: -0.025em;
}
.app-text:hover {
cursor: pointer;
color: var(--color-blue-common);
}
.app-version {
font-size: 10px;
font-weight: 500;
@@ -536,16 +542,11 @@ onBeforeMount(() => {
inset: 0;
z-index: 50;
display: flex;
overflow-y: auto;
align-items: center;
justify-content: center;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.dialog-container {
position: fixed;
inset: 0;
@@ -561,7 +562,7 @@ onBeforeMount(() => {
.dialog-panel {
width: 100%;
max-width: 500px;
background: var(--color-surface);
background: var(--color-background-primary);
border-radius: 16px;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
@@ -634,11 +635,11 @@ onBeforeMount(() => {
right: 0;
z-index: 10;
margin-top: 4px;
background: var(--color-surface);
background: var(--color-background-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
max-height: 200px;
max-height: 300px;
overflow-y: auto;
}

View File

@@ -74,7 +74,6 @@
</template>
<script setup lang="ts">
import type { IpcRendererEvent } from 'electron'
import { MinusIcon, PinIcon, ShrinkIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref } from 'vue'
@@ -100,7 +99,8 @@ function openMiniWindow () {
function closeWindow () {
window.electron.sendRPC(IRPCActionType.CLOSE_WINDOW)
}
const uploadProcessHandler = (_event: IpcRendererEvent, data: { progress: number }) => {
const uploadProcessHandler = (data: { progress: number }) => {
isShowprogress.value = data.progress !== 100 && data.progress !== 0
progress.value = data.progress
}
@@ -110,7 +110,7 @@ onBeforeMount(() => {
})
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener('updateProgress', uploadProcessHandler)
window.electron.ipcRendererRemoveAllListeners('updateProgress')
})
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,243 +1,244 @@
<template>
<div id="mini-page">
<div
id="upload-area"
:class="{
'is-dragover': dragover,
uploading: isShowingProgress,
linux: osGlobal === 'linux'
}"
:style="{ backgroundPosition: '0 ' + progress + '%' }"
@drop.prevent="onDrop"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
>
<img
v-if="!dragover && !isShowingProgress"
:src="logoPath ? logoPath : '/squareLogo.png'"
style="width: 100%; height: 100%; border-radius: 50%"
draggable="false"
@dragstart.prevent
>
<div
id="upload-dragger"
@dblclick="openUploadWindow"
>
<input
id="file-uploader"
type="file"
multiple
@change="onChange"
>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import type { IConfig } from 'piclist'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { isUrl } from '@/utils/common'
import { getConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum'
import { osGlobal } from '@/utils/global'
import type { IFileWithPath } from '#/types/types'
const logoPath = ref('')
const dragover = ref(false)
const progress = ref(0)
const isShowingProgress = ref(false)
const draggingState = ref(false)
const wX = ref(-1)
const wY = ref(-1)
const screenX = ref(-1)
const screenY = ref(-1)
async function initLogoPath () {
const config = await getConfig<IConfig>()
if (config) {
if (config.settings?.isCustomMiniIcon && config.settings?.customMiniIcon) {
logoPath.value =
'data:image/jpg;base64,' +
(await window.electron.triggerRPC(IRPCActionType.MANAGE_CONVERT_PATH_TO_BASE64, config.settings.customMiniIcon))
}
}
}
const uploadProgressHandler = (_: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
isShowingProgress.value = true
progress.value = _progress
} else {
progress.value = 100
}
}
const updateMiniIconHandler = async () => {
await initLogoPath()
}
onBeforeMount(async () => {
await initLogoPath()
window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererOn('updateMiniIcon', updateMiniIconHandler)
window.addEventListener('mousedown', handleMouseDown, false)
window.addEventListener('mousemove', handleMouseMove, false)
window.addEventListener('mouseup', handleMouseUp, false)
})
watch(progress, val => {
if (val === 100) {
setTimeout(() => {
isShowingProgress.value = false
}, 1000)
setTimeout(() => {
progress.value = 0
}, 1200)
}
})
function onDrop (e: DragEvent) {
dragover.value = false
// send files first
if (e.dataTransfer?.files?.length) {
ipcSendFiles(e.dataTransfer.files)
} else if (e.dataTransfer?.items) {
const items = e.dataTransfer.items
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
}
}
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: urlMatch[1]
}
])
}
}
function openUploadWindow () {
// @ts-expect-error file-uploader
document.getElementById('file-uploader').click()
}
function onChange (e: any) {
ipcSendFiles(e.target.files)
// @ts-expect-error file-uploader
document.getElementById('file-uploader').value = ''
}
function ipcSendFiles (files: FileList) {
const sendFiles: IFileWithPath[] = []
Array.from(files).forEach(item => {
const obj = {
name: item.name,
path: window.electron.showFilePath(item)
}
sendFiles.push(obj)
})
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, sendFiles)
}
function handleMouseDown (e: MouseEvent) {
draggingState.value = true
wX.value = e.pageX
wY.value = e.pageY
screenX.value = e.screenX
screenY.value = e.screenY
}
function handleMouseMove (e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
if (draggingState.value) {
const xLoc = e.screenX - wX.value
const yLoc = e.screenY - wY.value
window.electron.sendRPC(IRPCActionType.SET_MINI_WINDOW_POS, {
x: xLoc,
y: yLoc,
width: 64,
height: 64
})
}
}
function handleMouseUp (e: MouseEvent) {
draggingState.value = false
if (screenX.value === e.screenX && screenY.value === e.screenY) {
if (e.button === 0) {
// left mouse
openUploadWindow()
} else {
openContextMenu()
}
}
}
function openContextMenu () {
window.electron.sendRPC(IRPCActionType.SHOW_MINI_PAGE_MENU)
}
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererRemoveListener('updateMiniIcon', updateMiniIconHandler)
window.removeEventListener('mousedown', handleMouseDown, false)
window.removeEventListener('mousemove', handleMouseMove, false)
window.removeEventListener('mouseup', handleMouseUp, false)
})
</script>
<script lang="ts">
export default {
name: 'MiniPage'
}
</script>
<style lang="stylus">
#mini-page
color #FFF
height 100vh
width 100vw
border-radius 50%
text-align center
line-height 100vh
font-size 40px
background-size 90vh 90vw
background-position center center
background-repeat no-repeat
position relative
box-sizing border-box
cursor pointer
&.linux
border-radius 0
background-size 100vh 100vw
#upload-area
height 100%
width 100%
border-radius 50%
&.linux
border-radius 0
&.uploading
background: linear-gradient(to top, #409EFF 50%, #fff 51%)
background-size 200%
#upload-dragger
height 100%
&.is-dragover
background rgba(0,0,0,0.3)
#file-uploader
display none
</style>
<template>
<div id="mini-page">
<div
id="upload-area"
:class="{
'is-dragover': dragover,
uploading: isShowingProgress,
linux: osGlobal === 'linux'
}"
:style="{ backgroundPosition: '0 ' + progress + '%' }"
@drop.prevent="onDrop"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
>
<img
v-if="!dragover && !isShowingProgress"
:src="logoPath ? logoPath : '/squareLogo.png'"
style="width: 100%; height: 100%; border-radius: 50%"
draggable="false"
@dragstart.prevent
>
<div
id="upload-dragger"
@dblclick="openUploadWindow"
>
<input
id="file-uploader"
type="file"
multiple
@change="onChange"
>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { IConfig } from 'piclist'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { isUrl } from '@/utils/common'
import { getConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum'
import { osGlobal } from '@/utils/global'
import type { IFileWithPath } from '#/types/types'
const logoPath = ref('')
const dragover = ref(false)
const progress = ref(0)
const isShowingProgress = ref(false)
const draggingState = ref(false)
const wX = ref(-1)
const wY = ref(-1)
const screenX = ref(-1)
const screenY = ref(-1)
let removeListeners: () => void = () => {}
async function initLogoPath () {
const config = await getConfig<IConfig>()
if (config) {
if (config.settings?.isCustomMiniIcon && config.settings?.customMiniIcon) {
logoPath.value =
'data:image/jpg;base64,' +
(await window.electron.triggerRPC(IRPCActionType.MANAGE_CONVERT_PATH_TO_BASE64, config.settings.customMiniIcon))
}
}
}
const uploadProgressHandler = (p: number) => {
if (p !== -1) {
isShowingProgress.value = true
progress.value = p
} else {
progress.value = 100
}
}
const updateMiniIconHandler = async () => {
await initLogoPath()
}
watch(progress, val => {
if (val === 100) {
setTimeout(() => {
isShowingProgress.value = false
}, 1000)
setTimeout(() => {
progress.value = 0
}, 1200)
}
})
function onDrop (e: DragEvent) {
dragover.value = false
// send files first
if (e.dataTransfer?.files?.length) {
ipcSendFiles(e.dataTransfer.files)
} else if (e.dataTransfer?.items) {
const items = e.dataTransfer.items
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer!.getData(items[0].type)
if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
}
}
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: urlMatch[1]
}
])
}
}
function openUploadWindow () {
// @ts-expect-error file-uploader
document.getElementById('file-uploader').click()
}
function onChange (e: any) {
ipcSendFiles(e.target.files)
// @ts-expect-error file-uploader
document.getElementById('file-uploader').value = ''
}
function ipcSendFiles (files: FileList) {
const sendFiles: IFileWithPath[] = []
Array.from(files).forEach(item => {
const obj = {
name: item.name,
path: window.electron.showFilePath(item)
}
sendFiles.push(obj)
})
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, sendFiles)
}
function handleMouseDown (e: MouseEvent) {
draggingState.value = true
wX.value = e.pageX
wY.value = e.pageY
screenX.value = e.screenX
screenY.value = e.screenY
}
function handleMouseMove (e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
if (draggingState.value) {
const xLoc = e.screenX - wX.value
const yLoc = e.screenY - wY.value
window.electron.sendRPC(IRPCActionType.SET_MINI_WINDOW_POS, {
x: xLoc,
y: yLoc,
width: 64,
height: 64
})
}
}
function handleMouseUp (e: MouseEvent) {
draggingState.value = false
if (screenX.value === e.screenX && screenY.value === e.screenY) {
if (e.button === 0) {
// left mouse
openUploadWindow()
} else {
openContextMenu()
}
}
}
function openContextMenu () {
window.electron.sendRPC(IRPCActionType.SHOW_MINI_PAGE_MENU)
}
onBeforeMount(async () => {
await initLogoPath()
removeListeners = window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererOn('updateMiniIcon', updateMiniIconHandler)
window.addEventListener('mousedown', handleMouseDown, false)
window.addEventListener('mousemove', handleMouseMove, false)
window.addEventListener('mouseup', handleMouseUp, false)
})
onBeforeUnmount(() => {
removeListeners()
window.electron.ipcRendererRemoveAllListeners('updateMiniIcon')
window.removeEventListener('mousedown', handleMouseDown, false)
window.removeEventListener('mousemove', handleMouseMove, false)
window.removeEventListener('mouseup', handleMouseUp, false)
})
</script>
<script lang="ts">
export default {
name: 'MiniPage'
}
</script>
<style lang="stylus">
#mini-page
color #FFF
height 100vh
width 100vw
border-radius 50%
text-align center
line-height 100vh
font-size 40px
background-size 90vh 90vw
background-position center center
background-repeat no-repeat
position relative
box-sizing border-box
cursor pointer
&.linux
border-radius 0
background-size 100vh 100vw
#upload-area
height 100%
width 100%
border-radius 50%
&.linux
border-radius 0
&.uploading
background: linear-gradient(to top, #409EFF 50%, #fff 51%)
background-size 200%
#upload-dragger
height 100%
&.is-dragover
background rgba(0,0,0,0.3)
#file-uploader
display none
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,369 +1,369 @@
<template>
<div class="rename-container">
<div class="rename-card">
<form @submit.prevent="confirmName">
<div class="form-content">
<div class="form-group">
<div class="input-wrapper">
<input
ref="fileNameInput"
v-model="form.fileName"
type="text"
class="form-input"
:class="{ 'input-error': validationError }"
:placeholder="t('pages.rename.placeholder')"
autofocus
@keyup.enter="confirmName"
@input="clearValidationError"
>
<button
v-if="form.fileName"
type="button"
class="input-clear"
@click="clearFileName"
>
<XIcon :size="16" />
</button>
</div>
<div
v-if="validationError"
class="validation-error"
>
{{ validationError }}
</div>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="!form.fileName.trim()"
>
{{ $t('common.confirm') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import { XIcon } from 'lucide-vue-next'
import { nextTick, onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '@/utils/constant'
const { t } = useI18n()
const id = ref<string | null>(null)
const fileNameInput = ref<HTMLInputElement>()
const validationError = ref<string>('')
const form = reactive({
fileName: '',
originName: ''
})
const handleFileName = (_: IpcRendererEvent, newName: string, _originName: string, _id: string) => {
form.fileName = newName
form.originName = _originName
id.value = _id
nextTick(() => {
fileNameInput.value?.focus()
fileNameInput.value?.select()
})
}
window.electron.ipcRendererOn(RENAME_FILE_NAME, handleFileName)
onBeforeMount(() => {
window.electron.sendToMain(GET_RENAME_FILE_NAME, '')
})
function validateFileName (fileName: string): string {
if (!fileName.trim()) {
return 'File name is required'
}
const invalidChars = /[<>:"/\\|?*]/g
if (invalidChars.test(fileName)) {
return 'File name contains invalid characters'
}
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
if (reservedNames.test(fileName.trim())) {
return 'This is a reserved file name'
}
return ''
}
function confirmName () {
const error = validateFileName(form.fileName)
if (error) {
validationError.value = error
return
}
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.fileName)
}
function cancel () {
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.originName)
}
function clearFileName () {
form.fileName = ''
validationError.value = ''
nextTick(() => {
fileNameInput.value?.focus()
})
}
function clearValidationError () {
if (validationError.value) {
validationError.value = ''
}
}
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener(RENAME_FILE_NAME, handleFileName)
})
</script>
<script lang="ts">
export default {
name: 'RenamePage'
}
</script>
<style scoped>
.rename-container {
padding: 2rem;
min-height: 100vh;
background: var(--color-background-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.rename-card {
background: var(--color-background-primary);
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid var(--color-border);
width: 100%;
max-width: 500px;
overflow: hidden;
}
/* Form */
.form-content {
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
padding-right: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--color-blue-common);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.form-input.input-error {
border-color: #f56c6c;
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2);
}
.input-clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.input-clear:hover {
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
.validation-error {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #f56c6c;
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Actions */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 2rem 2rem;
background: var(--color-background-tertiary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: fit-content;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.btn-primary {
background: #409eff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #66b1ff;
}
.btn-secondary {
background: var(--color-background-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-background-secondary);
border-color: var(--color-accent);
}
/* Responsive Design */
@media (max-width: 768px) {
.rename-container {
padding: 1rem;
}
.rename-card {
max-width: none;
}
.form-actions {
padding: 1rem 1.5rem 1.5rem;
flex-direction: column-reverse;
}
.btn {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.rename-container {
padding: 0.75rem;
}
.form-actions {
padding: 1rem;
}
}
/* Focus styles for accessibility */
.btn:focus-visible,
.input-clear:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.form-input:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Animation for error state */
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.input-error {
animation: shake 0.3s ease-in-out;
}
/* Dark mode adjustments */
:root.dark .rename-card,
:root.auto.dark .rename-card {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
</style>
<template>
<div class="rename-container">
<div class="rename-card">
<form @submit.prevent="confirmName">
<div class="form-content">
<div class="form-group">
<div class="input-wrapper">
<input
ref="fileNameInput"
v-model="form.fileName"
type="text"
class="form-input"
:class="{ 'input-error': validationError }"
:placeholder="t('pages.rename.placeholder')"
autofocus
@keyup.enter="confirmName"
@input="clearValidationError"
>
<button
v-if="form.fileName"
type="button"
class="input-clear"
@click="clearFileName"
>
<XIcon :size="16" />
</button>
</div>
<div
v-if="validationError"
class="validation-error"
>
{{ validationError }}
</div>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="!form.fileName.trim()"
>
{{ $t('common.confirm') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts" setup>
import { XIcon } from 'lucide-vue-next'
import { nextTick, onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '@/utils/constant'
const { t } = useI18n()
const id = ref<string | null>(null)
const fileNameInput = ref<HTMLInputElement>()
const validationError = ref<string>('')
const form = reactive({
fileName: '',
originName: ''
})
const handleFileName = (newName: string, _originName: string, _id: string) => {
form.fileName = newName
form.originName = _originName
id.value = _id
nextTick(() => {
fileNameInput.value?.focus()
fileNameInput.value?.select()
})
}
window.electron.ipcRendererOn(RENAME_FILE_NAME, handleFileName)
function validateFileName (fileName: string): string {
if (!fileName.trim()) {
return 'File name is required'
}
const invalidChars = /[<>:"/\\|?*]/g
if (invalidChars.test(fileName)) {
return 'File name contains invalid characters'
}
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
if (reservedNames.test(fileName.trim())) {
return 'This is a reserved file name'
}
return ''
}
function confirmName () {
const error = validateFileName(form.fileName)
if (error) {
validationError.value = error
return
}
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.fileName)
}
function cancel () {
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.originName)
}
function clearFileName () {
form.fileName = ''
validationError.value = ''
nextTick(() => {
fileNameInput.value?.focus()
})
}
function clearValidationError () {
if (validationError.value) {
validationError.value = ''
}
}
onBeforeMount(() => {
window.electron.sendToMain(GET_RENAME_FILE_NAME, '')
})
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveAllListeners(RENAME_FILE_NAME)
})
</script>
<script lang="ts">
export default {
name: 'RenamePage'
}
</script>
<style scoped>
.rename-container {
padding: 2rem;
min-height: 100vh;
background: var(--color-background-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.rename-card {
background: var(--color-background-primary);
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
border: 1px solid var(--color-border);
width: 100%;
max-width: 500px;
overflow: hidden;
}
/* Form */
.form-content {
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
padding-right: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
transition: all 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--color-blue-common);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.form-input.input-error {
border-color: #f56c6c;
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2);
}
.input-clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.input-clear:hover {
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
.validation-error {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #f56c6c;
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Actions */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 2rem 2rem;
background: var(--color-background-tertiary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: fit-content;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.btn-primary {
background: #409eff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #66b1ff;
}
.btn-secondary {
background: var(--color-background-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-background-secondary);
border-color: var(--color-accent);
}
/* Responsive Design */
@media (max-width: 768px) {
.rename-container {
padding: 1rem;
}
.rename-card {
max-width: none;
}
.form-actions {
padding: 1rem 1.5rem 1.5rem;
flex-direction: column-reverse;
}
.btn {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.rename-container {
padding: 0.75rem;
}
.form-actions {
padding: 1rem;
}
}
/* Focus styles for accessibility */
.btn:focus-visible,
.input-clear:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.form-input:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Animation for error state */
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.input-error {
animation: shake 0.3s ease-in-out;
}
/* Dark mode adjustments */
:root.dark .rename-card,
:root.auto.dark .rename-card {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -1,284 +1,284 @@
<template>
<div class="toolbox-container">
<!-- Header Card -->
<div class="toolbox-card header-card">
<div class="card-header">
<div class="header-content">
<img
class="header-logo"
:src="defaultLogo"
alt="Toolbox Logo"
>
<div class="header-text">
<h1 class="header-title">
{{ t('pages.toolbox.title') }}
</h1>
<p class="header-subtitle">
{{ t('pages.toolbox.description') }}
</p>
</div>
</div>
<div class="header-actions">
<template v-if="progress !== 100">
<button
class="action-button"
:class="{ disabled: isLoading }"
:disabled="isLoading"
@click="handleCheck"
>
<span>{{ t('pages.toolbox.startScan') }}</span>
</button>
</template>
<template v-else-if="isAllSuccess">
<div class="success-tips">
{{ t('pages.toolbox.success') }}
</div>
</template>
<template v-else-if="!isAllSuccess">
<template v-if="canFixLength !== 0">
<button
class="action-button"
@click="handleFix"
>
<span>{{ t('pages.toolbox.startFix') }}</span>
</button>
</template>
<template v-else>
<div class="cant-fix-container">
<span class="cant-fix-text">{{ $t('pages.toolbox.autoFixFail') }}</span>
<button
class="action-button secondary small"
@click="handleCheck"
>
<span>{{ t('pages.toolbox.reScan') }}</span>
</button>
</div>
</template>
</template>
</div>
</div>
</div>
<!-- Progress Card -->
<div class="toolbox-card progress-card">
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="progress-text">{{ Math.round(progress) }}%</span>
</div>
</div>
<!-- Items Card -->
<div class="toolbox-card items-card">
<div class="items-list">
<div
v-for="(item, key) in fixList"
:key="key"
class="item"
:class="{
'item-active': activeTypes.includes(key),
'item-error': item.status === IToolboxItemCheckStatus.ERROR,
'item-success': item.status === IToolboxItemCheckStatus.SUCCESS,
'item-loading': item.status === IToolboxItemCheckStatus.LOADING
}"
>
<div
class="item-header"
@click="toggleItem(key)"
>
<div class="item-title">
<span>{{ item.title }}</span>
<toolbox-status-icon :status="item.status" />
</div>
<div class="item-chevron">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</div>
</div>
<transition name="item-content">
<div
v-if="activeTypes.includes(key)"
class="item-content"
>
<div class="item-message">
{{ item.msg || '' }}
</div>
<template v-if="item.handler && item.handlerText && item.value">
<div class="item-actions">
<toolbox-handler
:value="item.value"
:status="item.status"
:handler="item.handler"
:handler-text="item.handlerText"
/>
</div>
</template>
</div>
</transition>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolboxHandler from '@/components/ToolboxHandler.vue'
import ToolboxStatusIcon from '@/components/ToolboxStatusIcon.vue'
import useConfirm from '@/hooks/useConfirm'
import { IRPCActionType, IToolboxItemCheckStatus, IToolboxItemType } from '@/utils/enum'
import type { IToolboxCheckRes } from '#/types/rpc'
import type { IToolboxMap } from '#/types/view'
const { t } = useI18n()
const { confirm } = useConfirm()
const defaultLogo = ref('/roundLogo.png')
const activeTypes = ref<string[]>([])
const fixList = reactive<IToolboxMap>({
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: {
title: t('pages.toolbox.checkConfigFileBroken'),
status: IToolboxItemCheckStatus.INIT,
handlerText: t('pages.toolbox.openConfigFile'),
handler (value: string) {
window.electron.sendRPC(IRPCActionType.OPEN_FILE, value)
}
},
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: {
title: t('pages.toolbox.checkGalleryFileBroken'),
status: IToolboxItemCheckStatus.INIT
},
[IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD]: {
title: t('pages.toolbox.checkProblemWithClipboardPicUpload'), // picgo-image-clipboard folder
status: IToolboxItemCheckStatus.INIT,
handlerText: t('pages.toolbox.openFilePath'),
handler (value: string) {
window.electron.sendRPC(IRPCActionType.OPEN_FILE, value)
}
},
[IToolboxItemType.HAS_PROBLEM_WITH_PROXY]: {
title: t('pages.toolbox.checkProblemWithProxy'),
status: IToolboxItemCheckStatus.INIT,
hasNoFixMethod: true
}
})
const progress = computed(() => {
const total = Object.keys(fixList).length
const done = Object.keys(fixList).filter(key => {
const status = fixList[key].status
return status !== IToolboxItemCheckStatus.INIT && status !== IToolboxItemCheckStatus.LOADING
}).length
return (done / total) * 100
})
const isAllSuccess = computed(() => {
return Object.keys(fixList).every(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.SUCCESS
})
})
const isLoading = computed(() => {
return Object.keys(fixList).some(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.LOADING
})
})
const canFixLength = computed(() => {
return Object.keys(fixList).filter(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.ERROR && !fixList[key].hasNoFixMethod
}).length
})
const toggleItem = (key: string) => {
const index = activeTypes.value.indexOf(key)
if (index > -1) {
activeTypes.value.splice(index, 1)
} else {
activeTypes.value.push(key)
}
}
const toolboxCheckResHandler = (_event: any, { type, msg = '', status, value = '' }: IToolboxCheckRes) => {
fixList[type].status = status
fixList[type].msg = msg
fixList[type].value = value
if (status === IToolboxItemCheckStatus.ERROR) {
activeTypes.value.push(type)
}
}
window.electron.ipcRendererOn(IRPCActionType.TOOLBOX_CHECK_RES, toolboxCheckResHandler)
const handleCheck = () => {
activeTypes.value = []
Object.keys(fixList).forEach(key => {
fixList[key].status = IToolboxItemCheckStatus.LOADING
fixList[key].msg = ''
fixList[key].value = ''
})
window.electron.sendRPC(IRPCActionType.TOOLBOX_CHECK)
}
const handleFix = async () => {
const fixRes = await Promise.all(
Object.keys(fixList)
.filter(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.ERROR && !fixList[key].hasNoFixMethod
})
.map(async key => {
return window.electron.triggerRPC<IToolboxCheckRes>(IRPCActionType.TOOLBOX_CHECK_FIX, key)
})
)
fixRes
.filter(item => item !== null)
.forEach(item => {
if (item) {
fixList[item.type].status = item.status
fixList[item.type].msg = item.msg
fixList[item.type].value = item.value
}
})
confirm({
title: t('pages.toolbox.notice'),
message: t('pages.toolbox.fixDoneNeedReload'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true
}).then(result => {
if (!result) return
window.electron.sendRPC(IRPCActionType.RELOAD_APP)
})
}
onUnmounted(() => {
window.electron.ipcRendererRemoveListener(IRPCActionType.TOOLBOX_CHECK_RES, toolboxCheckResHandler)
})
</script>
<script lang="ts">
export default {
name: 'ToolBoxPage'
}
</script>
<style scoped src="./css/ToolboxPage.css"></style>
<template>
<div class="toolbox-container">
<!-- Header Card -->
<div class="toolbox-card header-card">
<div class="card-header">
<div class="header-content">
<img
class="header-logo"
:src="defaultLogo"
alt="Toolbox Logo"
>
<div class="header-text">
<h1 class="header-title">
{{ t('pages.toolbox.title') }}
</h1>
<p class="header-subtitle">
{{ t('pages.toolbox.description') }}
</p>
</div>
</div>
<div class="header-actions">
<template v-if="progress !== 100">
<button
class="action-button"
:class="{ disabled: isLoading }"
:disabled="isLoading"
@click="handleCheck"
>
<span>{{ t('pages.toolbox.startScan') }}</span>
</button>
</template>
<template v-else-if="isAllSuccess">
<div class="success-tips">
{{ t('pages.toolbox.success') }}
</div>
</template>
<template v-else-if="!isAllSuccess">
<template v-if="canFixLength !== 0">
<button
class="action-button"
@click="handleFix"
>
<span>{{ t('pages.toolbox.startFix') }}</span>
</button>
</template>
<template v-else>
<div class="cant-fix-container">
<span class="cant-fix-text">{{ $t('pages.toolbox.autoFixFail') }}</span>
<button
class="action-button secondary small"
@click="handleCheck"
>
<span>{{ t('pages.toolbox.reScan') }}</span>
</button>
</div>
</template>
</template>
</div>
</div>
</div>
<!-- Progress Card -->
<div class="toolbox-card progress-card">
<div class="progress-container">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="progress-text">{{ Math.round(progress) }}%</span>
</div>
</div>
<!-- Items Card -->
<div class="toolbox-card items-card">
<div class="items-list">
<div
v-for="(item, key) in fixList"
:key="key"
class="item"
:class="{
'item-active': activeTypes.includes(key),
'item-error': item.status === IToolboxItemCheckStatus.ERROR,
'item-success': item.status === IToolboxItemCheckStatus.SUCCESS,
'item-loading': item.status === IToolboxItemCheckStatus.LOADING
}"
>
<div
class="item-header"
@click="toggleItem(key)"
>
<div class="item-title">
<span>{{ item.title }}</span>
<toolbox-status-icon :status="item.status" />
</div>
<div class="item-chevron">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</div>
</div>
<transition name="item-content">
<div
v-if="activeTypes.includes(key)"
class="item-content"
>
<div class="item-message">
{{ item.msg || '' }}
</div>
<template v-if="item.handler && item.handlerText && item.value">
<div class="item-actions">
<toolbox-handler
:value="item.value"
:status="item.status"
:handler="item.handler"
:handler-text="item.handlerText"
/>
</div>
</template>
</div>
</transition>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolboxHandler from '@/components/ToolboxHandler.vue'
import ToolboxStatusIcon from '@/components/ToolboxStatusIcon.vue'
import useConfirm from '@/hooks/useConfirm'
import { IRPCActionType, IToolboxItemCheckStatus, IToolboxItemType } from '@/utils/enum'
import type { IToolboxCheckRes } from '#/types/rpc'
import type { IToolboxMap } from '#/types/view'
const { t } = useI18n()
const { confirm } = useConfirm()
const defaultLogo = ref('/roundLogo.png')
const activeTypes = ref<string[]>([])
const fixList = reactive<IToolboxMap>({
[IToolboxItemType.IS_CONFIG_FILE_BROKEN]: {
title: t('pages.toolbox.checkConfigFileBroken'),
status: IToolboxItemCheckStatus.INIT,
handlerText: t('pages.toolbox.openConfigFile'),
handler (value: string) {
window.electron.sendRPC(IRPCActionType.OPEN_FILE, value)
}
},
[IToolboxItemType.IS_GALLERY_FILE_BROKEN]: {
title: t('pages.toolbox.checkGalleryFileBroken'),
status: IToolboxItemCheckStatus.INIT
},
[IToolboxItemType.HAS_PROBLEM_WITH_CLIPBOARD_PIC_UPLOAD]: {
title: t('pages.toolbox.checkProblemWithClipboardPicUpload'), // picgo-image-clipboard folder
status: IToolboxItemCheckStatus.INIT,
handlerText: t('pages.toolbox.openFilePath'),
handler (value: string) {
window.electron.sendRPC(IRPCActionType.OPEN_FILE, value)
}
},
[IToolboxItemType.HAS_PROBLEM_WITH_PROXY]: {
title: t('pages.toolbox.checkProblemWithProxy'),
status: IToolboxItemCheckStatus.INIT,
hasNoFixMethod: true
}
})
const progress = computed(() => {
const total = Object.keys(fixList).length
const done = Object.keys(fixList).filter(key => {
const status = fixList[key].status
return status !== IToolboxItemCheckStatus.INIT && status !== IToolboxItemCheckStatus.LOADING
}).length
return (done / total) * 100
})
const isAllSuccess = computed(() => {
return Object.keys(fixList).every(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.SUCCESS
})
})
const isLoading = computed(() => {
return Object.keys(fixList).some(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.LOADING
})
})
const canFixLength = computed(() => {
return Object.keys(fixList).filter(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.ERROR && !fixList[key].hasNoFixMethod
}).length
})
const toggleItem = (key: string) => {
const index = activeTypes.value.indexOf(key)
if (index > -1) {
activeTypes.value.splice(index, 1)
} else {
activeTypes.value.push(key)
}
}
const toolboxCheckResHandler = ({ type, msg = '', status, value = '' }: IToolboxCheckRes) => {
fixList[type].status = status
fixList[type].msg = msg
fixList[type].value = value
if (status === IToolboxItemCheckStatus.ERROR) {
activeTypes.value.push(type)
}
}
window.electron.ipcRendererOn(IRPCActionType.TOOLBOX_CHECK_RES, toolboxCheckResHandler)
const handleCheck = () => {
activeTypes.value = []
Object.keys(fixList).forEach(key => {
fixList[key].status = IToolboxItemCheckStatus.LOADING
fixList[key].msg = ''
fixList[key].value = ''
})
window.electron.sendRPC(IRPCActionType.TOOLBOX_CHECK)
}
const handleFix = async () => {
const fixRes = await Promise.all(
Object.keys(fixList)
.filter(key => {
const status = fixList[key].status
return status === IToolboxItemCheckStatus.ERROR && !fixList[key].hasNoFixMethod
})
.map(async key => {
return window.electron.triggerRPC<IToolboxCheckRes>(IRPCActionType.TOOLBOX_CHECK_FIX, key)
})
)
fixRes
.filter(item => item !== null)
.forEach(item => {
if (item) {
fixList[item.type].status = item.status
fixList[item.type].msg = item.msg
fixList[item.type].value = item.value
}
})
confirm({
title: t('pages.toolbox.notice'),
message: t('pages.toolbox.fixDoneNeedReload'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true
}).then(result => {
if (!result) return
window.electron.sendRPC(IRPCActionType.RELOAD_APP)
})
}
onUnmounted(() => {
window.electron.ipcRendererRemoveAllListeners(IRPCActionType.TOOLBOX_CHECK_RES)
})
</script>
<script lang="ts">
export default {
name: 'ToolBoxPage'
}
</script>
<style scoped src="./css/ToolboxPage.css"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
<span class="provider-name">{{ picBedName }}</span>
<span class="provider-config">{{ picBedConfigName || 'Default' }}</span>
</div>
<ChevronDownIcon
<EditIcon
:size="16"
class="provider-arrow"
/>
@@ -31,7 +31,7 @@
class="action-button"
@click="handleChangePicBed"
>
<DatabaseIcon :size="16" />
<ArrowLeftRightIcon :size="16" />
<span>{{ t('pages.upload.changePicBed') }}</span>
</button>
</div>
@@ -173,7 +173,7 @@
<div
v-if="imageProcessDialogVisible"
class="modal-overlay"
@click="imageProcessDialogVisible = false"
@click.stop
>
<div
class="modal-container"
@@ -200,8 +200,7 @@
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import { ChevronDownIcon, ClipboardIcon, DatabaseIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next'
import { ArrowLeftRightIcon, ClipboardIcon, EditIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@@ -247,30 +246,23 @@ watch(picBedGlobal, () => {
getDefaultPicBed()
})
const uploadProgressHandler = (_event: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
let removeUploadProgressListenerCallback: (() => void) = () => {}
let removeSyncPicBedListenerCallback: (() => void) = () => {}
function uploadProgressHandler (p: number): void {
if (p !== -1) {
showProgress.value = true
progress.value = _progress
progress.value = p
} else {
progress.value = 100
showError.value = true
}
}
const syncPicBedHandler = () => {
function syncPicBedHandler (): void {
getDefaultPicBed()
}
onBeforeMount(() => {
updatePicBedGlobal()
window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
getUseShortUrl()
getPasteStyle()
getDefaultPicBed()
window.electron.ipcRendererOn('syncPicBed', syncPicBedHandler)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
})
const handleImageProcess = () => {
imageProcessDialogVisible.value = true
}
@@ -308,12 +300,6 @@ async function handlePicBedNameClick (_picBedName: string, picBedConfigName: str
})
}
onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
window.electron.ipcRendererRemoveListener('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererRemoveListener('syncPicBed', syncPicBedHandler)
})
function onDrop (e: DragEvent) {
dragover.value = false
@@ -434,6 +420,23 @@ async function getDefaultPicBed () {
async function handleChangePicBed () {
window.electron.sendRPC(IRPCActionType.SHOW_UPLOAD_PAGE_MENU)
}
onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
removeUploadProgressListenerCallback()
removeSyncPicBedListenerCallback()
})
onBeforeMount(() => {
updatePicBedGlobal()
getUseShortUrl()
getPasteStyle()
getDefaultPicBed()
removeUploadProgressListenerCallback = window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
removeSyncPicBedListenerCallback = window.electron.ipcRendererOn('syncPicBed', syncPicBedHandler)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
})
</script>
<script lang="ts">

View File

@@ -1,204 +1,203 @@
<template>
<div class="config-container">
<!-- Header Card -->
<div class="config-card header-card">
<div class="card-header">
<h1 class="page-title">
{{ t('pages.uploaderConfig.title') }}
</h1>
</div>
</div>
<!-- Config Items Card -->
<div class="config-card main-card">
<div class="config-grid">
<div
v-for="item in curConfigList"
:key="item._id"
:class="`config-item ${defaultConfigId === item._id ? 'selected' : ''}`"
@click="() => selectItem(item._id)"
>
<div class="config-content">
<div class="config-name">
{{ item._configName }}
</div>
<div class="config-update-time">
{{ formatTime(item._updatedAt) }}
</div>
<div
v-if="defaultConfigId === item._id"
class="default-badge"
>
{{ t('pages.uploaderConfig.selected') }}
</div>
</div>
<div class="config-actions">
<button
class="action-btn edit-btn"
:title="t('pages.uploaderConfig.edit')"
@click.stop="openEditPage(item._id)"
>
<Edit :size="16" />
</button>
<button
class="action-btn delete-btn"
:class="curConfigList.length <= 1 ? 'disabled' : ''"
:title="t('pages.uploaderConfig.delete')"
:disabled="curConfigList.length <= 1"
@click.stop="() => deleteConfig(item._id)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
<!-- Add New Config Button -->
<div
class="config-item config-item-add"
@click="addNewConfig"
>
<div class="add-content">
<Plus :size="32" />
<span class="add-text">{{ t('pages.uploaderConfig.addNew') }}</span>
</div>
</div>
</div>
</div>
<!-- Actions Card -->
<div class="config-card actions-card">
<div class="card-actions">
<button
class="primary-button"
:disabled="store?.state.defaultPicBed === type"
@click="setDefaultPicBed(type)"
>
<DatabaseIcon :size="16" />
<span>{{ t('pages.uploaderConfig.setAsDefault') }}</span>
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import dayjs from 'dayjs'
import { DatabaseIcon, Edit, Plus, Trash2 } from 'lucide-vue-next'
import { onBeforeMount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import useConfirm from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import { useStore } from '@/hooks/useStore'
import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config'
import { configPaths } from '@/utils/configPaths'
import { saveConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum'
import type { IStringKeyMap, IUploaderConfigItem } from '#/types/types'
const { t } = useI18n()
const message = useMessage()
const { confirm } = useConfirm()
const router = useRouter()
const route = useRoute()
const type = ref('')
const curConfigList = ref<IStringKeyMap[]>([])
const defaultConfigId = ref('')
const store = useStore()
async function selectItem (id: string) {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_SELECT, type.value, id)
if (store?.state.defaultPicBed === type.value) {
window.electron.sendRPC(
IRPCActionType.TRAY_SET_TOOL_TIP,
`${type.value} ${curConfigList.value.find(item => item._id === id)?._configName || ''}`
)
}
defaultConfigId.value = id
}
onBeforeRouteUpdate((to, _, next) => {
if (to.params.type && to.name === UPLOADER_CONFIG_PAGE) {
type.value = to.params.type as string
getCurrentConfigList()
}
next()
})
onBeforeMount(() => {
type.value = route.params.type as string
getCurrentConfigList()
})
async function getCurrentConfigList () {
const configList = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)
curConfigList.value = configList?.configList ?? []
defaultConfigId.value = configList?.defaultId ?? ''
}
function openEditPage (configId: string) {
router.push({
name: PICBEDS_PAGE,
params: {
type: type.value,
configId
},
query: {
defaultConfigId: defaultConfigId.value
}
})
}
function formatTime (time: number): string {
return dayjs(time).format('YYYY-MM-DD HH:mm')
}
async function deleteConfig (id: string) {
confirm({
title: t('pages.uploaderConfig.deleteTitle'),
message: t('pages.uploaderConfig.deleteConfirm'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true
}).then(async result => {
if (!result) return
const res = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_DELETE_CONFIG, type.value, id)
if (!res) return
curConfigList.value = res.configList
defaultConfigId.value = res.defaultId
message.success(t('pages.uploaderConfig.deleteSuccess'))
})
}
function addNewConfig () {
router.push({
name: PICBEDS_PAGE,
params: {
type: type.value,
configId: ''
}
})
}
function setDefaultPicBed (type: string) {
saveConfig({
[configPaths.picBed.current]: type,
[configPaths.picBed.uploader]: type
})
store?.setDefaultPicBed(type)
const currentConfigName = curConfigList.value.find(item => item._id === defaultConfigId.value)?._configName
window.electron.sendRPC(IRPCActionType.TRAY_SET_TOOL_TIP, `${type} ${currentConfigName || ''}`)
message.success(t('pages.uploaderConfig.setSuccess'))
}
</script>
<script lang="ts">
export default {
name: 'UploaderConfigPage'
}
</script>
<style scoped src="./css/UploaderConfigPage.css"></style>
<template>
<div class="config-container">
<!-- Header Card -->
<div class="config-card header-card">
<div class="card-header">
<h1 class="page-title">
{{ t('pages.uploaderConfig.title') }}
</h1>
</div>
</div>
<!-- Config Items Card -->
<div class="config-card main-card">
<div class="config-grid">
<div
v-for="item in curConfigList"
:key="item._id"
:class="`config-item ${defaultConfigId === item._id ? 'selected' : ''}`"
@click="() => selectItem(item._id)"
>
<div class="config-content">
<div class="config-name">
{{ item._configName }}
</div>
<div class="config-update-time">
{{ formatTime(item._updatedAt) }}
</div>
<div
v-if="defaultConfigId === item._id"
class="default-badge"
>
{{ t('pages.uploaderConfig.selected') }}
</div>
</div>
<div class="config-actions">
<button
class="action-btn edit-btn"
:title="t('pages.uploaderConfig.edit')"
@click.stop="openEditPage(item._id)"
>
<Edit :size="16" />
</button>
<button
class="action-btn delete-btn"
:class="curConfigList.length <= 1 ? 'disabled' : ''"
:title="t('pages.uploaderConfig.delete')"
:disabled="curConfigList.length <= 1"
@click.stop="() => deleteConfig(item._id)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
<!-- Add New Config Button -->
<div
class="config-item config-item-add"
@click="addNewConfig"
>
<div class="add-content">
<Plus :size="32" />
<span class="add-text">{{ t('pages.uploaderConfig.addNew') }}</span>
</div>
</div>
</div>
</div>
<!-- Actions Card -->
<div class="config-card actions-card">
<div class="card-actions">
<button
class="primary-button"
:disabled="store?.state.defaultPicBed === type"
@click="setDefaultPicBed(type)"
>
<DatabaseIcon :size="16" />
<span>{{ t('pages.uploaderConfig.setAsDefault') }}</span>
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import dayjs from 'dayjs'
import { DatabaseIcon, Edit, Plus, Trash2 } from 'lucide-vue-next'
import { onBeforeMount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import useConfirm from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import { useStore } from '@/hooks/useStore'
import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config'
import { configPaths } from '@/utils/configPaths'
import { saveConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum'
import type { IStringKeyMap, IUploaderConfigItem } from '#/types/types'
const { t } = useI18n()
const message = useMessage()
const { confirm } = useConfirm()
const router = useRouter()
const route = useRoute()
const type = ref('')
const curConfigList = ref<IStringKeyMap[]>([])
const defaultConfigId = ref('')
const store = useStore()
async function selectItem (id: string) {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_SELECT, type.value, id)
if (store?.state.defaultPicBed === type.value) {
window.electron.sendRPC(
IRPCActionType.TRAY_SET_TOOL_TIP,
`${type.value} ${curConfigList.value.find(item => item._id === id)?._configName || ''}`
)
}
defaultConfigId.value = id
}
onBeforeRouteUpdate((to, _, next) => {
if (to.params.type && to.name === UPLOADER_CONFIG_PAGE) {
type.value = to.params.type as string
getCurrentConfigList()
}
next()
})
onBeforeMount(() => {
type.value = route.params.type as string
getCurrentConfigList()
})
async function getCurrentConfigList () {
const configList = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)
curConfigList.value = configList?.configList ?? []
defaultConfigId.value = configList?.defaultId ?? ''
}
function openEditPage (configId: string) {
router.push({
name: PICBEDS_PAGE,
params: {
type: type.value,
configId
},
query: {
defaultConfigId: defaultConfigId.value
}
})
}
function formatTime (time: number): string {
return dayjs(time).format('YYYY-MM-DD HH:mm')
}
async function deleteConfig (id: string) {
const result = await confirm({
title: t('pages.uploaderConfig.deleteTitle'),
message: t('pages.uploaderConfig.deleteConfirm'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true
})
if (!result) return
const res = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_DELETE_CONFIG, type.value, id)
if (!res) return
curConfigList.value = res.configList
defaultConfigId.value = res.defaultId
message.success(t('pages.uploaderConfig.deleteSuccess'))
}
function addNewConfig () {
router.push({
name: PICBEDS_PAGE,
params: {
type: type.value,
configId: ''
}
})
}
function setDefaultPicBed (type: string) {
saveConfig({
[configPaths.picBed.current]: type,
[configPaths.picBed.uploader]: type
})
store?.setDefaultPicBed(type)
const currentConfigName = curConfigList.value.find(item => item._id === defaultConfigId.value)?._configName
window.electron.sendRPC(IRPCActionType.TRAY_SET_TOOL_TIP, `${type} ${currentConfigName || ''}`)
message.success(t('pages.uploaderConfig.setSuccess'))
}
</script>
<script lang="ts">
export default {
name: 'UploaderConfigPage'
}
</script>
<style scoped src="./css/UploaderConfigPage.css"></style>

View File

@@ -107,7 +107,6 @@ html, body {
.provider-button:hover .provider-arrow {
color: var(--color-accent);
transform: rotate(180deg);
}
.header-actions {

View File

View File

@@ -1,74 +1,74 @@
// src/global.d.ts
import crypto from 'node:crypto'
import path from 'node:path'
import { clipboard } from 'electron'
import fs from 'fs-extra'
import yaml from 'js-yaml'
import mime from 'mime-types'
import { VNode } from 'vue'
import { IpcRendererListener } from '#/types/electron'
import { ILocales, ILocalesKey } from '#/types/i18n'
import { IStringKeyMap } from '#/types/types'
declare global {
export namespace JSX {
export interface Element extends VNode {}
export interface IntrinsicElements {
[elem: string]: any
}
}
export interface Window {
electron: {
platform: string
setVisualZoomLevelLimits: (min: number, max: number) => void
sendRpcSync: (action: string, ...args: any[]) => any
triggerRPC: <T>(action: string, ...args: any[]) => Promise<T | undefined>
sendToMain: (channel: string, ...args: any[]) => void
sendRPC: (action: string, ...args: any[]) => void
ipcRendererOn: (channel: string, listener: IpcRendererListener) => void
ipcRendererRemoveListener: (channel: string, listener: IpcRendererListener) => void
clipboard: {
writeText: typeof clipboard.writeText
},
showFilePath: (file: File) => string
}
node: {
path: {
join: typeof path.join
dirname: typeof path.dirname
basename: typeof path.basename
normalize: typeof path.normalize
extname: typeof path.extname
sep: typeof path.sep,
posix: {
sep: typeof path.posix.sep
}
}
crypto: {
randomBytes: typeof crypto.randomBytes
createHash: typeof crypto.createHash
}
fs: {
remove: typeof fs.remove
readFile: typeof fs.readFile
statSync: typeof fs.statSync
}
yaml: {
load: typeof yaml.load
}
mime: {
lookup: typeof mime.lookup
}
}
i18n: {
setLocales: (lang: string, locales: ILocales) => void
translate: (key: ILocalesKey, args?: IStringKeyMap) => string
}
}
}
// src/global.d.ts
import crypto from 'node:crypto'
import path from 'node:path'
import { clipboard } from 'electron'
import fs from 'fs-extra'
import yaml from 'js-yaml'
import mime from 'mime-types'
import { VNode } from 'vue'
import { ILocales, ILocalesKey } from '#/types/i18n'
import { IStringKeyMap } from '#/types/types'
declare global {
export namespace JSX {
export interface Element extends VNode {}
export interface IntrinsicElements {
[elem: string]: any
}
}
export interface Window {
electron: {
platform: string
setVisualZoomLevelLimits: (min: number, max: number) => void
sendRpcSync: (action: string, ...args: any[]) => any
triggerRPC: <T>(action: string, ...args: any[]) => Promise<T | undefined>
sendToMain: (channel: string, ...args: any[]) => void
sendRPC: (action: string, ...args: any[]) => void
ipcRendererOn: (channel: string, listener: (...args: any[]) => void) => () => void
ipcRendererCountListeners: (channel: string) => number
ipcRendererRemoveAllListeners: (channel: string) => void
clipboard: {
writeText: typeof clipboard.writeText
},
showFilePath: (file: File) => string
}
node: {
path: {
join: typeof path.join
dirname: typeof path.dirname
basename: typeof path.basename
normalize: typeof path.normalize
extname: typeof path.extname
sep: typeof path.sep,
posix: {
sep: typeof path.posix.sep
}
}
crypto: {
randomBytes: typeof crypto.randomBytes
createHash: typeof crypto.createHash
}
fs: {
remove: typeof fs.remove
readFile: typeof fs.readFile
statSync: typeof fs.statSync
}
yaml: {
load: typeof yaml.load
}
mime: {
lookup: typeof mime.lookup
}
}
i18n: {
setLocales: (lang: string, locales: ILocales) => void
translate: (key: ILocalesKey, args?: IStringKeyMap) => string
}
}
}