Feature(custom): rewrite upload config and plugin page

This commit is contained in:
Kuingsmile
2025-08-07 14:52:46 +08:00
parent ca95b7aa49
commit a9c7cecd00
29 changed files with 5198 additions and 1801 deletions

View File

@@ -20,11 +20,11 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"i18n-ally.localesPaths": ["src\\renderer\\i18n\\locales"], "i18n-ally.localesPaths": ["src\\renderer\\i18n\\locales", "resources\\i18n"],
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.namespace": true, "i18n-ally.namespace": true,
"i18n-ally.enabledParsers": ["json"], "i18n-ally.enabledParsers": ["json", "yaml"],
"i18n-ally.sourceLanguage": "en", "i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN", "i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.enabledFrameworks": ["vue"], "i18n-ally.enabledFrameworks": ["vue"],

View File

@@ -121,6 +121,7 @@
"dpdm": "^3.14.0", "dpdm": "^3.14.0",
"electron": "^36.7.3", "electron": "^36.7.3",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-devtools-installer": "^4.0.0",
"electron-vite": "^4.0.0", "electron-vite": "^4.0.0",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-plugin-prettier": "^5.5.3", "eslint-plugin-prettier": "^5.5.3",

View File

@@ -122,12 +122,12 @@ if (db.get(configPaths.settings.miniWindowOntop)) {
} }
const renameWindowOptions = { const renameWindowOptions = {
height: 175, height: 250,
width: 300, width: 350,
show: true, show: true,
fullscreenable: false, fullscreenable: false,
resizable: false, icon: logo,
vibrancy: 'ultra-dark', resizable: true,
webPreferences: { webPreferences: {
sandbox: false, sandbox: false,
preload: preloadPath, preload: preloadPath,

View File

@@ -74,7 +74,7 @@ const getPluginList = async (): Promise<IPicGoPlugin[]> => {
fullName: pluginList[i], fullName: pluginList[i],
author: pluginPKG.author.name || pluginPKG.author, author: pluginPKG.author.name || pluginPKG.author,
description: pluginPKG.description, description: pluginPKG.description,
logo: 'file://' + path.join(pluginPath, 'logo.png').split(path.sep).join('/'), logo: path.join(pluginPath, 'logo.png').split(path.sep).join('/'),
version: pluginPKG.version, version: pluginPKG.version,
gui, gui,
config: { config: {

View File

@@ -13,6 +13,7 @@ import { uploadChoosedFiles, uploadClipboardFiles } from 'apis/app/uploader/apis
import windowManager from 'apis/app/window/windowManager' import windowManager from 'apis/app/window/windowManager'
import axios from 'axios' import axios from 'axios'
import { app, dialog, globalShortcut, Notification, protocol, screen, shell } from 'electron' import { app, dialog, globalShortcut, Notification, protocol, screen, shell } from 'electron'
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
import updater from 'electron-updater' import updater from 'electron-updater'
import fs from 'fs-extra' import fs from 'fs-extra'
@@ -167,10 +168,17 @@ class LifeCycle {
#onReady () { #onReady () {
const readyFunction = async () => { const readyFunction = async () => {
windowManager.create(IWindowList.TRAY_WINDOW) if (process.env.NODE_ENV !== 'production') {
windowManager.create(IWindowList.SETTING_WINDOW) installExtension(VUEJS_DEVTOOLS).then(name => {
console.log(`Added Extension: ${JSON.stringify(name)}`)
}).catch(err => {
console.log('An error occurred: ', err)
})
const setwin = windowManager.get(IWindowList.SETTING_WINDOW) const setwin = windowManager.get(IWindowList.SETTING_WINDOW)
setwin?.webContents?.openDevTools({ mode: 'detach' }) setwin?.webContents?.openDevTools({ mode: 'detach' })
}
windowManager.create(IWindowList.TRAY_WINDOW)
windowManager.create(IWindowList.SETTING_WINDOW)
const isAutoListenClipboard = db.get(configPaths.settings.isAutoListenClipboard) || false const isAutoListenClipboard = db.get(configPaths.settings.isAutoListenClipboard) || false
const ClipboardWatcher = clipboardPoll const ClipboardWatcher = clipboardPoll
if (isAutoListenClipboard) { if (isAutoListenClipboard) {

View File

@@ -13,11 +13,13 @@ import type { IConfig } from 'piclist'
import { onBeforeMount, onMounted } from 'vue' import { onBeforeMount, onMounted } from 'vue'
import UIServiceProvider from '@/components/ui/UIServiceProvider.vue' import UIServiceProvider from '@/components/ui/UIServiceProvider.vue'
import { useATagClick } from '@/hooks/useATagClick'
import { useStore } from '@/hooks/useStore' import { useStore } from '@/hooks/useStore'
import { getConfig } from '@/utils/dataSender' import { getConfig } from '@/utils/dataSender'
import { pageReloadCount } from '@/utils/global' import { pageReloadCount } from '@/utils/global'
import { useAppStore } from './hooks/useAppStore' import { useAppStore } from './hooks/useAppStore'
useATagClick()
const store = useStore() const store = useStore()
const appStore = useAppStore() const appStore = useAppStore()

View File

@@ -1,257 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
id="config-form"
:class="props.colorMode === 'white' ? 'white' : ''"
>
<el-form
ref="$form"
label-position="left"
label-width="50%"
:model="ruleForm"
size="small"
>
<el-form-item
:label="$t('UPLOADER_CONFIG_NAME')"
required
prop="_configName"
>
<el-input
v-model="ruleForm._configName"
type="input"
:placeholder="$t('UPLOADER_CONFIG_PLACEHOLDER')"
/>
</el-form-item>
<!-- dynamic config -->
<el-form-item
v-for="(item, index) in configList"
:key="item.name + index"
:required="item.required"
:prop="item.name"
>
<template #label>
<el-row align="middle">
{{ item.alias || item.name }}
<template v-if="item.tips">
<el-tooltip
class="item"
effect="dark"
placement="right"
:persistent="false"
teleported
>
<template #content>
<span
class="config-form-common-tips"
v-html="transformMarkdownToHTML(item.tips)"
/>
</template>
<el-icon class="ml-[4px] cursor-pointer hover:text-blue">
<InfoFilled />
</el-icon>
</el-tooltip>
</template>
</el-row>
</template>
<el-input
v-if="item.type === 'input' || item.type === 'password'"
v-model="ruleForm[item.name]"
type="input"
:placeholder="item.message || item.name"
/>
<el-select
v-else-if="item.type === 'list' && item.choices"
v-model="ruleForm[item.name]"
:placeholder="item.message || item.name"
:persistent="false"
teleported
>
<el-option
v-for="choice in item.choices"
:key="choice.name || choice.value || choice"
:label="choice.name || choice.value || choice"
:value="choice.value || choice"
/>
</el-select>
<el-select
v-else-if="item.type === 'checkbox' && item.choices"
v-model="ruleForm[item.name]"
:placeholder="item.message || item.name"
multiple
collapse-tags
:persistent="false"
teleported
>
<el-option
v-for="choice in item.choices"
:key="choice.value || choice"
:label="choice.name || choice.value || choice"
:value="choice.value || choice"
/>
</el-select>
<el-switch
v-else-if="item.type === 'confirm'"
v-model="ruleForm[item.name]"
:active-text="item.confirmText || 'yes'"
:inactive-text="item.cancelText || 'no'"
/>
</el-form-item>
<slot />
</el-form>
</div>
</template>
<script lang="ts" setup>
import { InfoFilled } from '@element-plus/icons-vue'
import type { FormInstance } from 'element-plus'
import { cloneDeep, union } from 'lodash-es'
import { marked } from 'marked'
import { reactive, ref, toRefs, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getConfig } from '@/utils/dataSender'
import type { IPicGoPluginConfig, IStringKeyMap } from '#/types/types'
interface IProps {
config: any[]
type: 'uploader' | 'transformer' | 'plugin'
id: string
colorMode?: 'white' | 'dark'
}
const props = defineProps<IProps>()
const $route = useRoute()
const $form = ref<FormInstance>()
const configList = ref<IPicGoPluginConfig[]>([])
const ruleForm = reactive<IStringKeyMap>({})
watch(
toRefs(props.config),
(val: IPicGoPluginConfig[]) => {
handleConfigChange(val)
},
{
deep: true,
immediate: true
}
)
function handleConfigChange (val: any) {
handleConfig(val)
}
async function validate (): Promise<IStringKeyMap | false> {
return new Promise(resolve => {
$form.value?.validate((valid: boolean) => {
if (valid) {
resolve(ruleForm)
} else {
resolve(false)
}
})
})
}
function transformMarkdownToHTML (markdown: string) {
try {
return marked.parse(markdown)
} catch (e) {
return markdown
}
}
function getConfigType () {
switch (props.type) {
case 'plugin': {
return props.id
}
case 'uploader': {
return `picBed.${props.id}`
}
case 'transformer': {
return `transformer.${props.id}`
}
default:
return 'unknown'
}
}
async function handleConfig (val: IPicGoPluginConfig[]) {
const config = await getCurConfigFormData()
const configId = $route.params.configId
Object.assign(ruleForm, config)
if (val.length > 0) {
configList.value = cloneDeep(val).map(item => {
if (!configId) return item
let defaultValue = item.default !== undefined ? item.default : item.type === 'checkbox' ? [] : null
if (item.type === 'checkbox') {
const defaults =
item.choices
?.filter((i: any) => {
return i.checked
})
.map((i: any) => i.value) || []
defaultValue = union(defaultValue, defaults)
}
if (config && config[item.name] !== undefined) {
defaultValue = config[item.name]
}
ruleForm[item.name] = defaultValue
return item
})
}
}
async function getCurConfigFormData () {
const configId = $route.params.configId
const curTypeConfigList = (await getConfig<IStringKeyMap[]>(`uploader.${props.id}.configList`)) || []
return curTypeConfigList.find(i => i._id === configId) || {}
}
function updateRuleForm (key: string, value: any) {
try {
ruleForm[key] = value
} catch (e) {
console.log(e)
}
}
defineExpose({
updateRuleForm,
validate,
getConfigType
})
</script>
<style lang="stylus">
.config-form-common-tips
a
color #409EFF
text-decoration none
#config-form
.el-form
label
line-height 22px
padding-bottom 0
&-item
display: flex
justify-content space-between
border-bottom 1px solid darken(#eee, 50%)
padding-bottom 16px
&:last-child
border-bottom none
&__content
justify-content flex-end
.el-button-group
width 100%
.el-button
width 50%
.el-radio-group
margin-left 25px
.el-switch__label
&.is-active
color #409EFF
&.white
.el-form-item
border-bottom 1px solid #ddd
</style>

View File

@@ -1,213 +0,0 @@
<template>
<div
id="config-form"
:class="props.colorMode === 'white' ? 'white' : ''"
>
<el-form
ref="$form"
label-position="left"
label-width="50%"
:model="ruleForm"
size="small"
>
<el-form-item
:label="$t('UPLOADER_CONFIG_NAME')"
required
prop="_configName"
>
<el-input
v-model="ruleForm._configName"
type="input"
:placeholder="$t('UPLOADER_CONFIG_PLACEHOLDER')"
/>
</el-form-item>
<!-- dynamic config -->
<el-form-item
v-for="(item, index) in configList"
:key="item.name + index"
:label="item.alias || item.name"
:required="item.required"
:prop="item.name"
>
<el-input
v-if="item.type === 'input' || item.type === 'password'"
v-model="ruleForm[item.name]"
type="input"
:placeholder="item.message || item.name"
/>
<el-select
v-else-if="item.type === 'list' && item.choices"
v-model="ruleForm[item.name]"
:placeholder="item.message || item.name"
:persistent="false"
teleported
>
<el-option
v-for="choice in item.choices"
:key="choice.name || choice.value || choice"
:label="choice.name || choice.value || choice"
:value="choice.value || choice"
/>
</el-select>
<el-select
v-else-if="item.type === 'checkbox' && item.choices"
v-model="ruleForm[item.name]"
:placeholder="item.message || item.name"
multiple
collapse-tags
:persistent="false"
teleported
>
<el-option
v-for="choice in item.choices"
:key="choice.value || choice"
:label="choice.name || choice.value || choice"
:value="choice.value || choice"
/>
</el-select>
<el-switch
v-else-if="item.type === 'confirm'"
v-model="ruleForm[item.name]"
active-text="yes"
inactive-text="no"
/>
</el-form-item>
<slot />
</el-form>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { cloneDeep, union } from 'lodash-es'
import { reactive, ref, watch } from 'vue'
import { getConfig } from '@/utils/dataSender'
import type { IPicGoPluginConfig, IStringKeyMap } from '#/types/types'
interface IProps {
config: any[]
type: 'uploader' | 'transformer' | 'plugin'
id: string
colorMode?: 'white' | 'dark'
}
const props = defineProps<IProps>()
const $form = ref<FormInstance>()
const configList = ref<IPicGoPluginConfig[]>([])
const ruleForm = reactive<IStringKeyMap>({})
watch(
() => props.config,
(val: IPicGoPluginConfig[]) => {
handleConfigChange(val)
},
{
deep: true,
immediate: true
}
)
function handleConfigChange (val: any) {
handleConfig(val)
}
async function validate (): Promise<IStringKeyMap | false> {
return new Promise(resolve => {
$form.value?.validate((valid: boolean) => {
if (valid) {
resolve(ruleForm)
} else {
resolve(false)
}
})
})
}
function getConfigType () {
switch (props.type) {
case 'plugin': {
return props.id
}
case 'uploader': {
return `picBed.${props.id}`
}
case 'transformer': {
return `transformer.${props.id}`
}
default:
return 'unknown'
}
}
async function handleConfig (val: IPicGoPluginConfig[]) {
const config = await getCurConfigFormData()
Object.assign(ruleForm, config)
if (val.length > 0) {
configList.value = cloneDeep(val).map(item => {
let defaultValue = item.default !== undefined ? item.default : item.type === 'checkbox' ? [] : null
if (item.type === 'checkbox') {
const defaults =
item.choices
?.filter((i: any) => {
return i.checked
})
.map((i: any) => i.value) || []
defaultValue = union(defaultValue, defaults)
}
if (config && config[item.name] !== undefined) {
defaultValue = config[item.name]
}
ruleForm[item.name] = defaultValue
return item
})
}
}
async function getCurConfigFormData () {
return (await getConfig<IStringKeyMap>(`${props.id}`)) || {}
}
function updateRuleForm (key: string, value: any) {
try {
ruleForm[key] = value
} catch (e) {
console.log(e)
}
}
defineExpose({
updateRuleForm,
validate,
getConfigType
})
</script>
<style lang="stylus">
#config-form
.el-form
label
line-height 22px
padding-bottom 0
&-item
display: flex
justify-content space-between
border-bottom 1px solid darken(#eee, 50%)
padding-bottom 16px
&:last-child
border-bottom none
&__content
justify-content flex-end
.el-button-group
width 100%
.el-button
width 50%
.el-radio-group
margin-left 25px
.el-switch__label
&.is-active
color #409EFF
&.white
.el-form-item
border-bottom 1px solid #ddd
</style>

View File

@@ -1,40 +1,65 @@
<template> <template>
<el-dialog <Teleport to="body">
v-model="showInputBoxVisible" <div
:title="inputBoxOptions.title || $t('INPUT')" v-if="showInputBoxVisible"
:modal-append-to-body="false" class="inputbox-overlay"
append-to-body
>
<el-input
v-model="inputBoxValue"
:placeholder="inputBoxOptions.placeholder"
/>
<template #footer>
<el-button
round
@click="handleInputBoxCancel" @click="handleInputBoxCancel"
> >
{{ $t('CANCEL') }} <div
</el-button> class="inputbox-container"
<el-button @click.stop
type="primary" >
round <div class="inputbox-header">
<h3 class="inputbox-title">
{{ inputBoxOptions.title || t('pages.inputBox.title') }}
</h3>
<button
class="inputbox-close"
@click="handleInputBoxCancel"
>
<X :size="20" />
</button>
</div>
<div class="inputbox-content">
<input
v-model="inputBoxValue"
:placeholder="inputBoxOptions.placeholder"
class="inputbox-input"
type="text"
@keyup.enter="handleInputBoxConfirm"
@keyup.escape="handleInputBoxCancel"
>
</div>
<div class="inputbox-actions">
<button
class="inputbox-btn cancel-btn"
@click="handleInputBoxCancel"
>
{{ t('common.cancel') }}
</button>
<button
class="inputbox-btn confirm-btn primary"
@click="handleInputBoxConfirm" @click="handleInputBoxConfirm"
> >
{{ $t('CONFIRM') }} {{ t('common.confirm') }}
</el-button> </button>
</template> </div>
</el-dialog> </div>
</div>
</Teleport>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { IpcRendererEvent } from 'electron' import type { IpcRendererEvent } from 'electron'
import { X } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue' import { onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import $bus from '@/utils/bus' import $bus from '@/utils/bus'
import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant' import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant'
import type { IShowInputBoxOption } from '#/types/types' import type { IShowInputBoxOption } from '#/types/types'
const { t } = useI18n()
const inputBoxValue = ref('') const inputBoxValue = ref('')
const showInputBoxVisible = ref(false) const showInputBoxVisible = ref(false)
const inputBoxOptions = reactive({ const inputBoxOptions = reactive({
@@ -81,4 +106,174 @@ export default {
name: 'InputBoxDialog' name: 'InputBoxDialog'
} }
</script> </script>
<style lang="stylus"></style> <style scoped>
.inputbox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.inputbox-container {
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 32rem;
width: 90%;
max-height: 80vh;
overflow: hidden;
}
:root.dark .inputbox-container,
:root.auto.dark .inputbox-container {
background: rgb(31 41 55);
border: 1px solid rgb(55 65 81);
}
.inputbox-header {
padding: 1.5rem 1.5rem 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.inputbox-title {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17 24 39);
margin: 0;
}
:root.dark .inputbox-title,
:root.auto.dark .inputbox-title {
color: rgb(243 244 246);
}
.inputbox-close {
background: none;
border: none;
color: rgb(107 114 128);
cursor: pointer;
padding: 0.25rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.inputbox-close:hover {
background: rgb(243 244 246);
color: rgb(17 24 39);
}
:root.dark .inputbox-close,
:root.auto.dark .inputbox-close {
color: rgb(156 163 175);
}
:root.dark .inputbox-close:hover,
:root.auto.dark .inputbox-close:hover {
background: rgb(55 65 81);
color: rgb(243 244 246);
}
.inputbox-content {
padding: 1rem 1.5rem;
}
.inputbox-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgb(209 213 219);
border-radius: 0.5rem;
background: white;
color: rgb(17 24 39);
font-size: 0.875rem;
font-family: inherit;
transition: all 0.2s ease;
outline: none;
}
.inputbox-input:focus {
border-color: rgb(59 130 246);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.inputbox-input::placeholder {
color: rgb(156 163 175);
}
:root.dark .inputbox-input,
:root.auto.dark .inputbox-input {
background: rgb(55 65 81);
border-color: rgb(75 85 99);
color: rgb(243 244 246);
}
:root.dark .inputbox-input:focus,
:root.auto.dark .inputbox-input:focus {
border-color: rgb(59 130 246);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
:root.dark .inputbox-input::placeholder,
:root.auto.dark .inputbox-input::placeholder {
color: rgb(107 114 128);
}
.inputbox-actions {
display: flex;
gap: 0.75rem;
padding: 0 1.5rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.inputbox-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
min-width: 4rem;
}
.cancel-btn {
background: rgb(243 244 246);
color: rgb(75 85 99);
border: 1px solid rgb(209 213 219);
}
.cancel-btn:hover {
background: rgb(229 231 235);
}
:root.dark .cancel-btn,
:root.auto.dark .cancel-btn {
background: rgb(55 65 81);
color: rgb(209 213 219);
border-color: rgb(75 85 99);
}
:root.dark .cancel-btn:hover,
:root.auto.dark .cancel-btn:hover {
background: rgb(75 85 99);
}
.confirm-btn.primary {
background: rgb(59 130 246);
color: white;
}
.confirm-btn.primary:hover {
background: rgb(37 99 235);
}
</style>

View File

@@ -65,7 +65,7 @@
:title="$t('navigation.moreOptions')" :title="$t('navigation.moreOptions')"
@click="openMenu" @click="openMenu"
> >
<BadgeInfoIcon :size="20" /> <Info :size="20" />
</button> </button>
</div> </div>
</nav> </nav>
@@ -215,7 +215,7 @@ import {
} from '@headlessui/vue' } from '@headlessui/vue'
import { ElMessage as $message } from 'element-plus' import { ElMessage as $message } from 'element-plus'
import { pick } from 'lodash-es' import { pick } from 'lodash-es'
import { BadgeInfoIcon, CheckIcon, ChevronDownIcon, CopyIcon, DatabaseIcon, FolderIcon, PieChartIcon, PlugIcon, Settings, UploadIcon } from 'lucide-vue-next' import { CheckIcon, ChevronDownIcon, CopyIcon, DatabaseIcon, FolderIcon, Info, PieChartIcon, PlugIcon, Settings, UploadIcon } from 'lucide-vue-next'
import QrcodeVue from 'qrcode.vue' import QrcodeVue from 'qrcode.vue'
import pkg from 'root/package.json' import pkg from 'root/package.json'
import { computed, nextTick, onBeforeMount, reactive, Ref, ref, watch } from 'vue' import { computed, nextTick, onBeforeMount, reactive, Ref, ref, watch } from 'vue'

View File

@@ -0,0 +1,775 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
id="config-form"
:class="[{ white: props.colorMode === 'white' }]"
>
<form
ref="$form"
class="config-form"
@submit.prevent
>
<!-- Config Name Field -->
<div class="form-group required">
<label class="form-label">{{ t('pages.configForm.configName') }}</label>
<div class="form-control">
<input
v-model="ruleForm._configName"
type="text"
class="form-input"
:placeholder="t('pages.configForm.configNamePlaceholder')"
:class="{ error: validationErrors._configName }"
@input="clearFieldError('_configName')"
>
<div
v-if="validationErrors._configName"
class="error-message"
>
{{ validationErrors._configName }}
</div>
</div>
</div>
<!-- Dynamic Config Fields -->
<div
v-for="(item, index) in configList"
:key="item.name + index"
class="form-group"
:class="{ required: item.required }"
>
<div class="form-label-wrapper">
<label class="form-label">{{ item.alias || item.name }}</label>
<div
v-if="showTooltips && item.tips"
class="tooltip-wrapper"
>
<div
class="info-icon"
@click="toggleTooltip(item.name + index)"
>
<Info :size="20" />
</div>
<div
v-show="visibleTooltips[item.name + index]"
class="tooltip-content"
v-html="transformMarkdownToHTML(item.tips)"
/>
</div>
</div>
<div class="form-control">
<!-- Text/Password Input -->
<input
v-if="item.type === 'input' || item.type === 'password'"
v-model="ruleForm[item.name]"
type="text"
class="form-input"
:placeholder="item.message || item.name"
:class="{ error: validationErrors[item.name] }"
@input="clearFieldError(item.name)"
>
<!-- Select (Single) -->
<div
v-else-if="item.type === 'list' && item.choices"
class="select-wrapper"
>
<select
v-model="ruleForm[item.name]"
class="form-select"
:class="{ error: validationErrors[item.name] }"
@change="clearFieldError(item.name)"
>
<option
value=""
disabled
>
{{ item.message || item.name }}
</option>
<option
v-for="choice in item.choices"
:key="choice.name || choice.value || choice"
:value="choice.value || choice"
>
{{ choice.name || choice.value || choice }}
</option>
</select>
<div class="select-arrow">
<ChevronDownIcon :size="20" />
</div>
</div>
<!-- Multi-Select (Checkbox style) -->
<div
v-else-if="item.type === 'checkbox' && item.choices"
class="checkbox-group"
>
<div
v-for="choice in item.choices"
:key="choice.value || choice"
class="checkbox-item"
>
<label class="checkbox-label">
<input
type="checkbox"
:value="choice.value || choice"
:checked="Array.isArray(ruleForm[item.name]) && ruleForm[item.name].includes(choice.value || choice)"
class="checkbox-input"
@change="handleCheckboxChange(item.name, choice.value || choice, $event)"
>
<span class="checkbox-custom" />
<span class="checkbox-text">{{ choice.name || choice.value || choice }}</span>
</label>
</div>
</div>
<!-- Switch/Toggle -->
<label
v-else-if="item.type === 'confirm'"
class="switch-label"
>
<input
v-model="ruleForm[item.name]"
type="checkbox"
class="switch-input"
@change="clearFieldError(item.name)"
>
<span class="switch-slider">
<span class="switch-button" />
</span>
<span class="switch-text">
{{ ruleForm[item.name] ? (item.confirmText || 'Yes') : (item.cancelText || 'No') }}
</span>
</label>
<!-- Validation Error -->
<div
v-if="validationErrors[item.name]"
class="error-message"
>
{{ validationErrors[item.name] }}
</div>
</div>
</div>
<slot />
</form>
</div>
</template>
<script lang="ts" setup>
import { cloneDeep, union } from 'lodash-es'
import { ChevronDownIcon, Info } from 'lucide-vue-next'
import { marked } from 'marked'
import { reactive, ref, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { getConfig } from '@/utils/dataSender'
import type { IPicGoPluginConfig, IStringKeyMap } from '#/types/types'
interface IProps {
config: any[]
type: 'uploader' | 'transformer' | 'plugin'
id: string
colorMode?: 'white' | 'dark'
mode?: 'picbed' | 'plugin'
showTooltips?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
colorMode: undefined,
mode: 'picbed',
showTooltips: true
})
const $route = useRoute()
const $form = ref<HTMLFormElement>()
const { t } = useI18n()
const configList = ref<IPicGoPluginConfig[]>([])
const ruleForm = reactive<IStringKeyMap>({})
const validationErrors = reactive<IStringKeyMap>({})
const visibleTooltips = reactive<{ [key: string]: boolean }>({})
// Watch for config changes
watch(
toRefs(props.config),
(val: IPicGoPluginConfig[]) => {
handleConfigChange(val)
},
{
deep: true,
immediate: true
}
)
function handleConfigChange (val: any) {
handleConfig(val)
}
function validateField (fieldName: string, value: any, config?: IPicGoPluginConfig): string | null {
if (fieldName === '_configName') {
if (!value || value.trim() === '') {
return 'Configuration name is required'
}
return null
}
if (config?.required && (!value || (Array.isArray(value) && value.length === 0))) {
return `${config.alias || config.name} is required`
}
return null
}
function validateForm (): boolean {
const errors: IStringKeyMap = {}
const configNameError = validateField('_configName', ruleForm._configName)
if (configNameError) {
errors._configName = configNameError
}
configList.value.forEach(config => {
const error = validateField(config.name, ruleForm[config.name], config)
if (error) {
errors[config.name] = error
}
})
Object.keys(validationErrors).forEach(key => {
delete validationErrors[key]
})
Object.assign(validationErrors, errors)
return Object.keys(errors).length === 0
}
function clearFieldError (fieldName: string) {
if (validationErrors[fieldName]) {
delete validationErrors[fieldName]
}
}
function toggleTooltip (key: string) {
visibleTooltips[key] = !visibleTooltips[key]
Object.keys(visibleTooltips).forEach(otherKey => {
if (otherKey !== key) {
visibleTooltips[otherKey] = false
}
})
}
function handleCheckboxChange (fieldName: string, value: any, event: Event) {
const target = event.target as HTMLInputElement
const currentValues = Array.isArray(ruleForm[fieldName]) ? [...ruleForm[fieldName]] : []
if (target.checked) {
if (!currentValues.includes(value)) {
currentValues.push(value)
}
} else {
const index = currentValues.indexOf(value)
if (index > -1) {
currentValues.splice(index, 1)
}
}
ruleForm[fieldName] = currentValues
clearFieldError(fieldName)
}
async function validate (): Promise<IStringKeyMap | false> {
return new Promise(resolve => {
const isValid = validateForm()
if (isValid) {
resolve(ruleForm)
} else {
resolve(false)
}
})
}
function transformMarkdownToHTML (markdown: string) {
try {
return marked.parse(markdown)
} catch (e) {
return markdown
}
}
function getConfigType () {
switch (props.type) {
case 'plugin': {
return props.id
}
case 'uploader': {
return `picBed.${props.id}`
}
case 'transformer': {
return `transformer.${props.id}`
}
default:
return 'unknown'
}
}
async function handleConfig (val: IPicGoPluginConfig[]) {
const config = await getCurConfigFormData()
const configId = props.mode === 'picbed' ? $route.params.configId : null
Object.assign(ruleForm, config)
if (val.length > 0) {
configList.value = cloneDeep(val).map(item => {
// For plugin mode, don't check configId
if (props.mode === 'plugin' || !configId) {
let defaultValue = item.default !== undefined ? item.default : item.type === 'checkbox' ? [] : null
if (item.type === 'checkbox') {
const defaults =
item.choices
?.filter((i: any) => i.checked)
.map((i: any) => i.value) || []
defaultValue = union(defaultValue, defaults)
}
if (config && config[item.name] !== undefined) {
defaultValue = config[item.name]
}
ruleForm[item.name] = defaultValue
return item
}
let defaultValue = item.default !== undefined ? item.default : item.type === 'checkbox' ? [] : null
if (item.type === 'checkbox') {
const defaults =
item.choices
?.filter((i: any) => i.checked)
.map((i: any) => i.value) || []
defaultValue = union(defaultValue, defaults)
}
if (config && config[item.name] !== undefined) {
defaultValue = config[item.name]
}
ruleForm[item.name] = defaultValue
return item
})
}
}
async function getCurConfigFormData () {
if (props.mode === 'plugin') {
return (await getConfig<IStringKeyMap>(`${props.id}`)) || {}
} else {
const configId = $route.params.configId
const curTypeConfigList = (await getConfig<IStringKeyMap[]>(`uploader.${props.id}.configList`)) || []
return curTypeConfigList.find(i => i._id === configId) || {}
}
}
function updateRuleForm (key: string, value: any) {
try {
ruleForm[key] = value
clearFieldError(key)
} catch (e) {
console.log(e)
}
}
defineExpose({
updateRuleForm,
validate,
getConfigType
})
</script>
<style scoped>
#config-form {
width: 100%;
}
.config-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Form Groups */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group.required .form-label::after {
content: ' *';
color: var(--color-error, #ef4444);
}
.form-label-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
line-height: 1.25;
}
.form-control {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Tooltip Styles */
.tooltip-wrapper {
position: relative;
}
.info-icon {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-secondary);
transition: var(--transition-fast);
border-radius: 50%;
padding: 2px;
}
.info-icon:hover {
color: var(--color-accent);
background: rgba(0, 122, 255, 0.1);
}
.tooltip-content {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 200px;
max-width: 300px;
padding: 0.75rem;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-primary);
}
/* Input Styles */
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
transition: var(--transition-fast);
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.form-input::placeholder {
color: var(--color-text-secondary);
}
.form-input.error {
border-color: var(--color-error, #ef4444);
}
.form-input.error:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
/* Select Styles */
.select-wrapper {
position: relative;
}
.form-select {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
transition: var(--transition-fast);
appearance: none;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.form-select.error {
border-color: var(--color-error, #ef4444);
}
.form-select.error:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
.select-arrow {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
pointer-events: none;
transition: var(--transition-fast);
}
.select-wrapper:hover .select-arrow,
.form-select:focus + .select-arrow {
color: var(--color-accent);
}
/* Checkbox Group Styles */
.checkbox-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
}
.checkbox-item {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-primary);
transition: var(--transition-fast);
}
.checkbox-label:hover {
color: var(--color-accent);
}
.checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.checkbox-custom {
position: relative;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-elevated);
transition: var(--transition-fast);
flex-shrink: 0;
}
.checkbox-custom::after {
content: '';
position: absolute;
left: 3px;
top: 0px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
opacity: 0;
transition: var(--transition-fast);
}
.checkbox-input:checked + .checkbox-custom {
background: var(--color-accent);
border-color: var(--color-accent);
}
.checkbox-input:checked + .checkbox-custom::after {
opacity: 1;
}
.checkbox-input:focus + .checkbox-custom {
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.checkbox-text {
flex: 1;
}
/* Switch Styles */
.switch-label {
display: flex;
align-items: center;
gap: 1rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-primary);
}
.switch-input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.switch-slider {
position: relative;
width: 3rem;
height: 1.5rem;
background: var(--color-border);
border-radius: 0.75rem;
transition: var(--transition-fast);
flex-shrink: 0;
}
.switch-button {
position: absolute;
top: 2px;
left: 2px;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
transition: var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.switch-input:checked + .switch-slider {
background: var(--color-accent);
}
.switch-input:checked + .switch-slider .switch-button {
transform: translateX(1.5rem);
}
.switch-input:focus + .switch-slider {
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.switch-text {
font-weight: 500;
color: var(--color-text-secondary);
}
.switch-input:checked ~ .switch-text {
color: var(--color-accent);
}
/* Error Message */
.error-message {
font-size: 0.75rem;
color: var(--color-error, #ef4444);
margin-top: 0.25rem;
}
/* White theme adjustments */
.white .form-input,
.white .form-select {
background: white;
border-color: #ddd;
}
.white .form-input:focus,
.white .form-select:focus {
border-color: var(--color-accent);
}
.white .checkbox-custom {
background: white;
border-color: #ddd;
}
.white .switch-slider {
background: #ddd;
}
.white .tooltip-content {
background: white;
border-color: #ddd;
}
/* Responsive Design */
@media (max-width: 768px) {
.config-form {
gap: 1.25rem;
}
.form-input,
.form-select {
padding: 0.625rem 0.875rem;
}
.form-select {
padding-right: 2.25rem;
}
.tooltip-content {
min-width: 150px;
max-width: 250px;
}
}
/* Dark mode adjustments */
:root.dark .form-input,
:root.auto.dark .form-input,
:root.dark .form-select,
:root.auto.dark .form-select {
background: var(--color-surface-elevated);
border-color: var(--color-border);
}
:root.dark .checkbox-custom,
:root.auto.dark .checkbox-custom {
background: var(--color-surface-elevated);
border-color: var(--color-border);
}
:root.dark .switch-slider,
:root.auto.dark .switch-slider {
background: var(--color-border);
}
:root.dark .tooltip-content,
:root.auto.dark .tooltip-content {
background: var(--color-surface-elevated);
border-color: var(--color-border);
}
/* Focus styles for accessibility */
.form-input:focus-visible,
.form-select:focus-visible,
.checkbox-input:focus-visible + .checkbox-custom,
.switch-input:focus-visible + .switch-slider,
.info-icon:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>

View File

@@ -144,16 +144,18 @@ export default {
.message-toast { .message-toast {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 24rem; max-width: 24rem;
min-width: 20rem;
pointer-events: all; pointer-events: all;
background: white; background: white;
border: 1px solid rgb(229 231 235); border: 1px solid rgb(229 231 235);
word-wrap: break-word;
} }
:root.dark .message-toast, :root.dark .message-toast,
@@ -196,6 +198,7 @@ export default {
.message-icon { .message-icon {
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.125rem;
} }
.message-content { .message-content {
@@ -203,6 +206,11 @@ export default {
color: rgb(75 85 99); color: rgb(75 85 99);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: 1.25rem;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
min-width: 0;
} }
:root.dark .message-content, :root.dark .message-content,
@@ -220,6 +228,8 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
margin-top: 0.125rem;
} }
.message-close:hover { .message-close:hover {

View File

@@ -6,11 +6,14 @@ export function useATagClick () {
const handleATagClick = (e: MouseEvent) => { const handleATagClick = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) { if (e.target instanceof HTMLAnchorElement) {
if (e.target.href) { if (e.target.href) {
// avoid opening localhost development URLs in external browser
if (!e.target.href.startsWith('http://localhost:3000')) {
e.preventDefault() e.preventDefault()
window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href) window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href)
} }
} }
} }
}
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleATagClick) document.addEventListener('click', handleATagClick)
}) })

View File

@@ -1,7 +1,7 @@
{ {
"app": { "title": "PicList" }, "app": { "title": "PicList" },
"titleBar": { "alwaysOnTop": "Always On Top", "close": "Close", "minimize": "Minimize", "miniWindow": "Mini Window" }, "titleBar": { "alwaysOnTop": "Always On Top", "close": "Close", "minimize": "Minimize", "miniWindow": "Mini Window" },
"common": { "confirm": "Confirm", "cancel": "Cancel", "close": "Close" }, "common": { "confirm": "Confirm", "cancel": "Cancel", "close": "Close", "reset": "Reset", "import": "Import" },
"navigation": { "navigation": {
"upload": "Upload", "upload": "Upload",
"manage": "Manage", "manage": "Manage",
@@ -220,6 +220,7 @@
"enableAdvancedRname": "Enable Advanced Rename", "enableAdvancedRname": "Enable Advanced Rename",
"advancedRnameFormat": "Advanced Rename Format", "advancedRnameFormat": "Advanced Rename Format",
"availablePlaceholders": "Available Placeholders", "availablePlaceholders": "Available Placeholders",
"copySuccess": "Copy Successful: {content}",
"placeholder": { "placeholder": {
"categoryTime": "Time Related", "categoryTime": "Time Related",
"categoryHash": "Hash Related", "categoryHash": "Hash Related",
@@ -320,6 +321,91 @@
"hasNewVersion": "PicList has been updated, please click OK to restart and trigger the update", "hasNewVersion": "PicList has been updated, please click OK to restart and trigger the update",
"networkError": "Network error, please check your network connection" "networkError": "Network error, please check your network connection"
} }
},
"plugin": {
"title": "Plugins",
"description": "PicList Plugin Management",
"importLocal": "Import Local Plugin",
"updateAll": "Update All Plugins",
"list": "Plugin List",
"searchPlaceholder": "Search for PicGo plugins on npm, or click the button above to view the excellent plugin list",
"needRestart": "Need Restart to Take Effect",
"restartApp": "Restart Application",
"loading": "Loading...",
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"doingSomething": "Doing Something...",
"settings": "Settings",
"disabled": "Disabled",
"noPluginsFound": "No Plugins Found",
"tryDifferentSearch": "Try Different Search Keywords",
"NoPluginsInstalled": "No Plugins Installed",
"installPluginsToGetStarted": "Please install plugins to get started",
"browsePlugins": "Browse Plugins",
"configThing": "Config {c}",
"pluginList": "Plugin List",
"notGuiImplement": "This plugin does not have a GUI implementation, continue?",
"updateSuccess": "Update Success",
"setSuccess": "Set Success"
},
"inputBox": {
"title": "Input Box"
},
"configForm": {
"configName": "Config Name",
"configNamePlaceholder": "Please enter config name"
},
"rename": {
"placeholder": "Please enter new file name"
},
"shortKey": {
"title": "Shortcut Key",
"name": "Shortcut Key Name",
"bind": "Shortcut Key Binding",
"status": "Status",
"source": "Source",
"handle": "Action",
"noBinding": "Not Bound",
"enable": "Enable",
"disable": "Disable",
"enabled": "Enabled",
"disabled": "Disabled",
"edit": "Edit",
"changeUpload": "Change Upload Shortcut",
"keyBinding": "Key Binding",
"pressKeys": "Press the keys to set the shortcut",
"pressHint": "Click the input box and press the keys you want to bind"
},
"picBedConfigs": {
"title": "Config",
"viewDoc": "View Document",
"copyAPI": "Copy API Address",
"noConfigOptions": "No Config Options",
"setSuccess": "Set Success",
"setFailedInfo": "Set Failed, Please Check If Config Options Are Correct",
"loadConfigFailed": "Load Config Failed, Please Check If Config File Is Correct",
"loadPicBedListFailed": "Load PicBed List Failed",
"importConfigSuccess": "Import Config Success",
"importConfigFailed": "Import Config Failed",
"resetSuccess": "Reset Success",
"resetFailed": "Reset Failed, Please Check If Config Options Are Correct",
"viewDocFailed": "View Document Failed",
"noConfigs": "No Configs",
"copyAPISucceed": "Copy API Address Succeed",
"copyAPIFailed": "Copy API Address Failed"
},
"uploaderConfig": {
"title": "PicBed Config",
"selected": "Selected",
"edit": "Edit",
"delete": "Delete",
"addNew": "Add New",
"setAsDefault": "Set as Default PicBed",
"setSuccess": "Set Success",
"deleteTitle": "Notification",
"deleteConfirm": "Are you sure you want to delete this PicBed config?",
"deleteSuccess": "Delete Success"
} }
}, },
"OPEN_MAIN_WINDOW": "Open Main Window", "OPEN_MAIN_WINDOW": "Open Main Window",
@@ -339,7 +425,6 @@
"TOOLBOX_SUCCESS_TIPS": "Congratulations, no problems were found", "TOOLBOX_SUCCESS_TIPS": "Congratulations, no problems were found",
"UPLOAD_VIEW_HINT": "Click to open picbeds settings", "UPLOAD_VIEW_HINT": "Click to open picbeds settings",
"REFRESH": "Refresh", "REFRESH": "Refresh",
"PLUGIN_SETTINGS": "Plugins",
"CHOOSE_PICBED": "Choose Picbed", "CHOOSE_PICBED": "Choose Picbed",
"COPY_PICBED_CONFIG": "Copy Picbed Config", "COPY_PICBED_CONFIG": "Copy Picbed Config",
"COPY_PICBED_CONFIG_SUCCEED": "Copy Picbed Config Succeed", "COPY_PICBED_CONFIG_SUCCEED": "Copy Picbed Config Succeed",
@@ -396,14 +481,6 @@
"WAIT_TO_UPLOAD": "Wait to Upload", "WAIT_TO_UPLOAD": "Wait to Upload",
"ALREADY_UPLOAD": "Already Uploaded", "ALREADY_UPLOAD": "Already Uploaded",
"TIPS_DRAG_VALID_PICTURE_OR_URL": "Drag valid picture or url to here", "TIPS_DRAG_VALID_PICTURE_OR_URL": "Drag valid picture or url to here",
"PLUGIN_SEARCH_PLACEHOLDER": "Search picgo plugins on npm, or click the button to view the awesome plugins list",
"PLUGIN_INSTALL": "Install",
"PLUGIN_INSTALLING": "Installing...",
"PLUGIN_INSTALLED": "Installed",
"PLUGIN_DOING_SOMETHING": "Doing...",
"PLUGIN_LIST": "Plugin List",
"PLUGIN_IMPORT_LOCAL": "Import Local Plugins",
"PLUGIN_UPDATE_ALL": "Update All Plugins",
"TIPS_REMOVE_LINK": "This operation will remove the picture from the album, continue?", "TIPS_REMOVE_LINK": "This operation will remove the picture from the album, continue?",
"TIPS_WILL_REMOVE_CHOOSED_IMAGES": "This operation will remove the picture from the album, continue?", "TIPS_WILL_REMOVE_CHOOSED_IMAGES": "This operation will remove the picture from the album, continue?",
"TIPS_MUST_CONTAINS_URL": "Must contains $url or $fileName or $extName", "TIPS_MUST_CONTAINS_URL": "Must contains $url or $fileName or $extName",
@@ -412,7 +489,6 @@
"TIPS_PLEASE_CHOOSE_LOG_LEVEL": "Please choose log level", "TIPS_PLEASE_CHOOSE_LOG_LEVEL": "Please choose log level",
"TIPS_SET_SUCCEED": "Set successfully", "TIPS_SET_SUCCEED": "Set successfully",
"TIPS_RESET_SUCCEED": "Reset successfully", "TIPS_RESET_SUCCEED": "Reset successfully",
"TIPS_PLUGIN_NOT_GUI_IMPLEMENT": "This plugin is not optimized for the GUI, continue?",
"MANAGE_SETTING_TITLE": "Manage Setting", "MANAGE_SETTING_TITLE": "Manage Setting",
"MANAGE_SETTING_ISAUTOREFRESH_TITLE": "Auto refresh file list when entering new directory", "MANAGE_SETTING_ISAUTOREFRESH_TITLE": "Auto refresh file list when entering new directory",
"MANAGE_SETTING_ISAUTOREFRESH_TIPS": "Only applies to non-paginated mode, data is cached to indexdb to speed up loading speed", "MANAGE_SETTING_ISAUTOREFRESH_TIPS": "Only applies to non-paginated mode, data is cached to indexdb to speed up loading speed",
@@ -968,6 +1044,5 @@
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "Public Read", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "Public Read",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "Private", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "Private",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "Authenticated Read", "MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "Authenticated Read",
"PLUGIN_UPDATE_SUCCEED": "Plugin update succeed",
"TIPS_NOTICE": "Tips" "TIPS_NOTICE": "Tips"
} }

View File

@@ -1,7 +1,7 @@
{ {
"app": { "title": "PicList" }, "app": { "title": "PicList" },
"titleBar": { "alwaysOnTop": "置顶", "close": "关闭", "minimize": "最小化", "miniWindow": "迷你窗口" }, "titleBar": { "alwaysOnTop": "置顶", "close": "关闭", "minimize": "最小化", "miniWindow": "迷你窗口" },
"common": { "confirm": "确认", "cancel": "取消", "close": "关闭" }, "common": { "confirm": "确认", "cancel": "取消", "close": "关闭", "reset": "重置", "import": "导入" },
"navigation": { "navigation": {
"upload": "上传", "upload": "上传",
"manage": "管理", "manage": "管理",
@@ -215,6 +215,7 @@
"enableAdvancedRname": "开启高级重命名", "enableAdvancedRname": "开启高级重命名",
"advancedRnameFormat": "高级重命名格式", "advancedRnameFormat": "高级重命名格式",
"availablePlaceholders": "可用占位符", "availablePlaceholders": "可用占位符",
"copySuccess": "复制成功: {content}",
"placeholder": { "placeholder": {
"categoryTime": "时间相关", "categoryTime": "时间相关",
"categoryHash": "哈希相关", "categoryHash": "哈希相关",
@@ -315,6 +316,91 @@
"hasNewVersion": "PicList 更新啦,请点击确定重启触发更新", "hasNewVersion": "PicList 更新啦,请点击确定重启触发更新",
"networkError": "网络错误,请检查网络连接" "networkError": "网络错误,请检查网络连接"
} }
},
"plugin": {
"title": "插件",
"description": "PicList 插件管理页面",
"importLocal": "导入本地插件",
"updateAll": "更新全部插件",
"list": "插件列表",
"searchPlaceholder": "搜索 npm 上的 PicGo 插件,或者点击上方按钮查看优秀插件列表",
"needRestart": "需要重启生效",
"restartApp": "重启应用",
"loading": "加载中...",
"install": "安装",
"installing": "安装中",
"installed": "已安装",
"doingSomething": "进行中",
"settings": "设置",
"disabled": "已禁用",
"noPluginsFound": "未找到插件",
"tryDifferentSearch": "尝试不同的搜索关键词",
"NoPluginsInstalled": "暂无已安装插件",
"installPluginsToGetStarted": "请先安装插件以开始使用",
"browsePlugins": "浏览插件",
"configThing": "配置 {c}",
"pluginList": "插件列表",
"notGuiImplement": "该插件未对可视化界面进行优化, 是否继续安装?",
"updateSuccess": "插件更新成功",
"setSuccess": "设置成功"
},
"inputBox": {
"title": "输入框"
},
"configForm": {
"configName": "配置名",
"configNamePlaceholder": "请输入配置名称"
},
"rename": {
"placeholder": "请输入新的文件名"
},
"shortKey": {
"title": "快捷键",
"name": "快捷键名称",
"bind": "快捷键绑定",
"status": "状态",
"source": "来源",
"handle": "操作",
"noBinding": "未绑定",
"enable": "启用",
"disable": "禁用",
"enabled": "已启用",
"disabled": "已禁用",
"edit": "编辑",
"changeUpload": "修改上传快捷键",
"keyBinding": "按键绑定",
"pressKeys": "按下要设置的快捷键",
"pressHint": "点击输入框并按下你想要绑定的按键"
},
"picBedConfigs": {
"title": "配置",
"viewDoc": "查看文档",
"copyAPI": "复制API地址",
"noConfigOptions": "暂无配置项",
"setSuccess": "设置成功",
"setFailedInfo": "设置失败, 请检查配置项是否正确",
"loadConfigFailed": "加载配置失败, 请检查配置文件是否正确",
"loadPicBedListFailed": "加载图床列表失败",
"importConfigSuccess": "导入配置成功",
"importConfigFailed": "导入配置失败",
"resetSuccess": "重置成功",
"resetFailed": "重置失败, 请检查配置项是否正确",
"viewDocFailed": "查看文档失败",
"noConfigs": "配置为空",
"copyAPISucceed": "复制API地址成功",
"copyAPIFailed": "复制API地址失败"
},
"uploaderConfig": {
"title": "图床配置",
"selected": "已选中",
"edit": "编辑",
"delete": "删除",
"addNew": "新增",
"setAsDefault": "设为默认图床",
"setSuccess": "设置成功",
"deleteTitle": "通知",
"deleteConfirm": "确认删除图床配置吗?",
"deleteSuccess": "删除成功"
} }
}, },
"OPEN_MAIN_WINDOW": "打开主窗口", "OPEN_MAIN_WINDOW": "打开主窗口",
@@ -334,7 +420,6 @@
"TOOLBOX_SUCCESS_TIPS": "恭喜你,没有检查出问题", "TOOLBOX_SUCCESS_TIPS": "恭喜你,没有检查出问题",
"UPLOAD_VIEW_HINT": "点击打开图床设置", "UPLOAD_VIEW_HINT": "点击打开图床设置",
"REFRESH": "刷新", "REFRESH": "刷新",
"PLUGIN_SETTINGS": "插件",
"CHOOSE_PICBED": "选择图床", "CHOOSE_PICBED": "选择图床",
"COPY_PICBED_CONFIG": "复制图床配置", "COPY_PICBED_CONFIG": "复制图床配置",
"COPY_PICBED_CONFIG_SUCCEED": "复制图床配置成功", "COPY_PICBED_CONFIG_SUCCEED": "复制图床配置成功",
@@ -391,14 +476,6 @@
"WAIT_TO_UPLOAD": "等待上传", "WAIT_TO_UPLOAD": "等待上传",
"ALREADY_UPLOAD": "已上传", "ALREADY_UPLOAD": "已上传",
"TIPS_DRAG_VALID_PICTURE_OR_URL": "请拖入合法的图片文件或者图片URL地址", "TIPS_DRAG_VALID_PICTURE_OR_URL": "请拖入合法的图片文件或者图片URL地址",
"PLUGIN_SEARCH_PLACEHOLDER": "搜索npm上的PicGo插件或者点击上方按钮查看优秀插件列表",
"PLUGIN_INSTALL": "安装",
"PLUGIN_INSTALLING": "安装中",
"PLUGIN_INSTALLED": "已安装",
"PLUGIN_DOING_SOMETHING": "进行中",
"PLUGIN_LIST": "插件列表",
"PLUGIN_IMPORT_LOCAL": "导入本地插件",
"PLUGIN_UPDATE_ALL": "更新全部插件",
"TIPS_REMOVE_LINK": "此操作将把该图片移出相册, 是否继续?", "TIPS_REMOVE_LINK": "此操作将把该图片移出相册, 是否继续?",
"TIPS_WILL_REMOVE_CHOOSED_IMAGES": "将在相册中移除刚才选中的 ${m} 张图片,是否继续?", "TIPS_WILL_REMOVE_CHOOSED_IMAGES": "将在相册中移除刚才选中的 ${m} 张图片,是否继续?",
"TIPS_MUST_CONTAINS_URL": "必须含有$url 或 $fileName 或 $extName", "TIPS_MUST_CONTAINS_URL": "必须含有$url 或 $fileName 或 $extName",
@@ -407,7 +484,6 @@
"TIPS_PLEASE_CHOOSE_LOG_LEVEL": "请选择日志记录等级", "TIPS_PLEASE_CHOOSE_LOG_LEVEL": "请选择日志记录等级",
"TIPS_SET_SUCCEED": "设置成功", "TIPS_SET_SUCCEED": "设置成功",
"TIPS_RESET_SUCCEED": "重置成功", "TIPS_RESET_SUCCEED": "重置成功",
"TIPS_PLUGIN_NOT_GUI_IMPLEMENT": "该插件未对可视化界面进行优化, 是否继续安装?",
"MANAGE_SETTING_TITLE": "管理页面设置", "MANAGE_SETTING_TITLE": "管理页面设置",
"MANAGE_SETTING_ISAUTOREFRESH_TITLE": "每次进入新目录时,是否自动刷新文件列表", "MANAGE_SETTING_ISAUTOREFRESH_TITLE": "每次进入新目录时,是否自动刷新文件列表",
"MANAGE_SETTING_ISAUTOREFRESH_TIPS": "仅对不分页模式有效,默认在加载过一次后自动缓存到数据库来加快下次加载速度", "MANAGE_SETTING_ISAUTOREFRESH_TIPS": "仅对不分页模式有效,默认在加载过一次后自动缓存到数据库来加快下次加载速度",
@@ -963,6 +1039,5 @@
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "公共读", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "公共读",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "私有", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "私有",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "授权读", "MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "授权读",
"PLUGIN_UPDATE_SUCCEED": "插件更新成功",
"TIPS_NOTICE": "注意" "TIPS_NOTICE": "注意"
} }

View File

@@ -1,7 +1,7 @@
{ {
"app": { "title": "PicList" }, "app": { "title": "PicList" },
"titleBar": { "alwaysOnTop": "置頂", "close": "關閉", "minimize": "最小化", "miniWindow": "迷你視窗" }, "titleBar": { "alwaysOnTop": "置頂", "close": "關閉", "minimize": "最小化", "miniWindow": "迷你視窗" },
"common": { "confirm": "確認", "cancel": "取消", "close": "關閉" }, "common": { "confirm": "確認", "cancel": "取消", "close": "關閉", "reset": "重置", "import": "匯入" },
"navigation": { "navigation": {
"upload": "上傳", "upload": "上傳",
"manage": "管理", "manage": "管理",
@@ -215,6 +215,7 @@
"enableAdvancedRname": "開啟高級重命名", "enableAdvancedRname": "開啟高級重命名",
"advancedRnameFormat": "高級重命名格式", "advancedRnameFormat": "高級重命名格式",
"availablePlaceholders": "可用占位符", "availablePlaceholders": "可用占位符",
"copySuccess": "複製成功: {content}",
"placeholder": { "placeholder": {
"categoryTime": "時間相關", "categoryTime": "時間相關",
"categoryHash": "哈希相關", "categoryHash": "哈希相關",
@@ -315,6 +316,91 @@
"hasNewVersion": "PicList 更新啦,請點擊確定重啟觸發更新", "hasNewVersion": "PicList 更新啦,請點擊確定重啟觸發更新",
"networkError": "網絡錯誤,請檢查網絡連接" "networkError": "網絡錯誤,請檢查網絡連接"
} }
},
"plugin": {
"title": "插件",
"description": "PicList 插件管理頁面",
"importLocal": "匯入本地插件",
"updateAll": "更新全部插件",
"list": "插件列表",
"searchPlaceholder": "搜尋 npm 上的 PicGo 插件,或者點擊上方按鈕查看優秀插件列表",
"needRestart": "需要重啟生效",
"restartApp": "重啟應用",
"loading": "載入中...",
"install": "安裝",
"installing": "安裝中",
"installed": "已安裝",
"doingSomething": "進行中",
"settings": "設定",
"disabled": "已停用",
"noPluginsFound": "未找到插件",
"tryDifferentSearch": "嘗試不同的搜尋關鍵詞",
"NoPluginsInstalled": "尚未安裝任何插件",
"installPluginsToGetStarted": "請先安裝插件以開始使用",
"browsePlugins": "瀏覽插件",
"configThing": "配置 {c}",
"pluginList": "插件列表",
"notGuiImplement": "該插件未針對圖形介面進行優化,是否繼續安裝?",
"updateSuccess": "插件更新成功",
"setSuccess": "設定成功"
},
"inputBox": {
"title": "輸入框"
},
"configForm": {
"configName": "配置名稱",
"configNamePlaceholder": "請輸入配置名稱"
},
"rename": {
"placeholder": "請輸入新的檔案名稱"
},
"shortKey": {
"title": "快捷鍵",
"name": "快捷鍵名稱",
"bind": "快捷鍵綁定",
"status": "狀態",
"source": "來源",
"handle": "操作",
"noBinding": "未綁定",
"enable": "啟用",
"disable": "停用",
"enabled": "已啟用",
"disabled": "已停用",
"edit": "編輯",
"changeUpload": "修改上傳快捷鍵",
"keyBinding": "按鍵綁定",
"pressKeys": "按下要設定的快捷鍵",
"pressHint": "點擊輸入框並按下你想要綁定的按鍵"
},
"picBedConfigs": {
"title": "配置",
"viewDoc": "查看文件",
"copyAPI": "複製 API 位址",
"noConfigOptions": "暫無配置項",
"setSuccess": "設定成功",
"setFailedInfo": "設定失敗,請檢查配置項是否正確",
"loadConfigFailed": "載入配置失敗,請檢查配置文件是否正確",
"loadPicBedListFailed": "載入圖床列表失敗",
"importConfigSuccess": "匯入配置成功",
"importConfigFailed": "匯入配置失敗",
"resetSuccess": "重設成功",
"resetFailed": "重設失敗,請檢查配置項是否正確",
"viewDocFailed": "查看文件失敗",
"noConfigs": "配置為空",
"copyAPISucceed": "複製 API 位址成功",
"copyAPIFailed": "複製 API 位址失敗"
},
"uploaderConfig": {
"title": "圖床配置",
"selected": "已選取",
"edit": "編輯",
"delete": "刪除",
"addNew": "新增",
"setAsDefault": "設為預設圖床",
"setSuccess": "設定成功",
"deleteTitle": "通知",
"deleteConfirm": "確認刪除圖床配置嗎?",
"deleteSuccess": "刪除成功"
} }
}, },
"OPEN_MAIN_WINDOW": "打開主視窗", "OPEN_MAIN_WINDOW": "打開主視窗",
@@ -334,7 +420,6 @@
"TOOLBOX_SUCCESS_TIPS": "恭喜你,沒有檢查出問題", "TOOLBOX_SUCCESS_TIPS": "恭喜你,沒有檢查出問題",
"UPLOAD_VIEW_HINT": "點擊打開圖床設定", "UPLOAD_VIEW_HINT": "點擊打開圖床設定",
"REFRESH": "刷新", "REFRESH": "刷新",
"PLUGIN_SETTINGS": "插件",
"CHOOSE_PICBED": "選擇圖床", "CHOOSE_PICBED": "選擇圖床",
"COPY_PICBED_CONFIG": "複製圖床設定", "COPY_PICBED_CONFIG": "複製圖床設定",
"COPY_PICBED_CONFIG_SUCCEED": "複製圖床設定成功", "COPY_PICBED_CONFIG_SUCCEED": "複製圖床設定成功",
@@ -391,14 +476,6 @@
"WAIT_TO_UPLOAD": "等待上傳", "WAIT_TO_UPLOAD": "等待上傳",
"ALREADY_UPLOAD": "已上傳", "ALREADY_UPLOAD": "已上傳",
"TIPS_DRAG_VALID_PICTURE_OR_URL": "請拖入合法的圖片檔案或者圖片URL地址", "TIPS_DRAG_VALID_PICTURE_OR_URL": "請拖入合法的圖片檔案或者圖片URL地址",
"PLUGIN_SEARCH_PLACEHOLDER": "搜尋npm上的PicGo插件或者點擊上方按鈕查看優秀插件列表",
"PLUGIN_INSTALL": "安裝",
"PLUGIN_INSTALLING": "安裝中",
"PLUGIN_INSTALLED": "已安裝",
"PLUGIN_DOING_SOMETHING": "進行中",
"PLUGIN_LIST": "插件列表",
"PLUGIN_IMPORT_LOCAL": "導入本地插件",
"PLUGIN_UPDATE_ALL": "更新全部插件",
"TIPS_REMOVE_LINK": "此操作將在相簿中移除該圖片,是否繼續?", "TIPS_REMOVE_LINK": "此操作將在相簿中移除該圖片,是否繼續?",
"TIPS_WILL_REMOVE_CHOOSED_IMAGES": "將在相簿中移除剛才選中的 ${m} 張圖片,是否繼續?", "TIPS_WILL_REMOVE_CHOOSED_IMAGES": "將在相簿中移除剛才選中的 ${m} 張圖片,是否繼續?",
"TIPS_MUST_CONTAINS_URL": "必須含有$url 或 $fileName 或 $extName", "TIPS_MUST_CONTAINS_URL": "必須含有$url 或 $fileName 或 $extName",
@@ -407,7 +484,6 @@
"TIPS_PLEASE_CHOOSE_LOG_LEVEL": "請選擇記錄等級", "TIPS_PLEASE_CHOOSE_LOG_LEVEL": "請選擇記錄等級",
"TIPS_SET_SUCCEED": "設定成功", "TIPS_SET_SUCCEED": "設定成功",
"TIPS_RESET_SUCCEED": "重置成功", "TIPS_RESET_SUCCEED": "重置成功",
"TIPS_PLUGIN_NOT_GUI_IMPLEMENT": "該插件未對GUI進行優化是否繼續安裝",
"MANAGE_SETTING_TITLE": "管理設定", "MANAGE_SETTING_TITLE": "管理設定",
"MANAGE_SETTING_ISAUTOREFRESH_TITLE": "每次進入新目錄時,是否自動重新整理檔案列表", "MANAGE_SETTING_ISAUTOREFRESH_TITLE": "每次進入新目錄時,是否自動重新整理檔案列表",
"MANAGE_SETTING_ISAUTOREFRESH_TIPS": "僅對不分頁模式有效,預設會在載入後自動快取至資料庫以提升下次載入速度", "MANAGE_SETTING_ISAUTOREFRESH_TIPS": "僅對不分頁模式有效,預設會在載入後自動快取至資料庫以提升下次載入速度",
@@ -963,6 +1039,5 @@
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "公共讀", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PUBLIC_R": "公共讀",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "私有", "MANAGE_NEW_BUCKET_S3PLIST_ACL_PRIVATE": "私有",
"MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "授權讀", "MANAGE_NEW_BUCKET_S3PLIST_ACL_AUTHENTICATED_READ": "授權讀",
"PLUGIN_UPDATE_SUCCEED": "插件更新成功",
"TIPS_NOTICE": "注意" "TIPS_NOTICE": "注意"
} }

View File

@@ -1,5 +1,9 @@
<template> <template>
<div id="main" class="app-container"> <div
id="main"
class="app-container"
>
<InputBoxDialog />
<TitleBar /> <TitleBar />
<div class="app-background"> <div class="app-background">
<div class="bg-gradient" /> <div class="bg-gradient" />
@@ -8,9 +12,15 @@
<main class="main-content"> <main class="main-content">
<div class="content-container"> <div class="content-container">
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition name="page" mode="out-in"> <transition
name="page"
mode="out-in"
>
<keep-alive :include="keepAlivePages"> <keep-alive :include="keepAlivePages">
<component :is="Component" :key="route.path" /> <component
:is="Component"
:key="route.path"
/>
</keep-alive> </keep-alive>
</transition> </transition>
</router-view> </router-view>
@@ -22,6 +32,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import InputBoxDialog from '@/components/InputBoxDialog.vue'
import Navigation from '@/components/NavigationPage.vue' import Navigation from '@/components/NavigationPage.vue'
import TitleBar from '@/components/ui/TitleBar.vue' import TitleBar from '@/components/ui/TitleBar.vue'
@@ -68,7 +79,7 @@ export default { name: 'MainPage' }
--color-accent-hover: #0056b3; --color-accent-hover: #0056b3;
--color-blue-common: #409eff; --color-blue-common: #409eff;
--color-success: #34c759; --color-success: #34c759;
--color-warning: #ff9500; --color-warning: #f1930f;
--color-danger: #ff3b30; --color-danger: #ff3b30;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +1,314 @@
<template> <template>
<div id="plugin-view"> <div class="plugin-container">
<div class="view-title"> <!-- Header Card -->
{{ $t('PLUGIN_SETTINGS') }} - <div class="plugin-card header-card">
<el-tooltip <div class="card-header">
:content="pluginListToolTip" <div class="header-content">
placement="right" <div class="header-icon">
:persistent="false" <DatabaseIcon :size="24" />
teleported </div>
> <div>
<el-icon <h1>{{ t('pages.plugin.title') }}</h1>
class="el-icon-goods" <p>{{ t('pages.plugin.description') }}</p>
@click="goAwesomeList" </div>
> </div>
<Goods /> <div class="header-actions">
</el-icon> <button
</el-tooltip> class="action-button secondary"
<el-tooltip :title="t('pages.plugin.importLocal')"
:content="updateAllToolTip"
placement="left"
:persistent="false"
teleported
>
<el-icon
class="el-icon-update"
@click="handleUpdateAllPlugin"
>
<Refresh />
</el-icon>
</el-tooltip>
<el-tooltip
:content="importLocalPluginToolTip"
placement="left"
>
<el-icon
class="el-icon-download"
:persistent="false"
teleported
@click="handleImportLocalPlugin" @click="handleImportLocalPlugin"
> >
<Download /> <DownloadIcon :size="16" />
</el-icon> {{ t('pages.plugin.importLocal') }}
</el-tooltip> </button>
<button
class="action-button secondary"
:title="t('pages.plugin.updateAll')"
@click="handleUpdateAllPlugin"
>
<RefreshCwIcon :size="16" />
{{ t('pages.plugin.updateAll') }}
</button>
<button
class="action-button"
:title="t('pages.plugin.pluginList')"
@click="goAwesomeList"
>
<ExternalLinkIcon :size="16" />
{{ t('pages.plugin.list') }}
</button>
</div> </div>
<el-row </div>
class="handle-bar" </div>
:class="{ 'cut-width': pluginList.length > 6 }"
> <!-- Search Card -->
<el-input <div class="plugin-card search-card">
<div class="search-container">
<div class="search-input-wrapper">
<SearchIcon
class="search-icon"
:size="20"
/>
<input
v-model="searchText" v-model="searchText"
:placeholder="$t('PLUGIN_SEARCH_PLACEHOLDER')" type="text"
size="small" class="search-input"
:placeholder="t('pages.plugin.searchPlaceholder')"
> >
<template #suffix> <button
<el-icon v-if="searchText"
class="el-input__icon" class="clear-button"
style="cursor: pointer"
@click="cleanSearch" @click="cleanSearch"
> >
<close /> <XIcon :size="16" />
</el-icon> </button>
</template> </div>
</el-input> </div>
</el-row> </div>
<el-row
id="pluginList" <!-- Reload Notice -->
v-loading="loading" <transition name="notice">
:gutter="10" <div
class="plugin-list" v-if="needReload"
class="plugin-card notice-card"
> >
<el-col <div class="notice-content">
<AlertCircleIcon
class="notice-icon"
:size="20"
/>
<span class="notice-text">{{ t('pages.plugin.needRestart') }}</span>
<button
class="action-button small"
@click="reloadApp"
>
{{ t('pages.plugin.restartApp') }}
</button>
</div>
</div>
</transition>
<!-- Loading Overlay -->
<div
v-if="loading"
class="loading-overlay"
>
<div class="loading-spinner" />
<span class="loading-text">{{ t('pages.plugin.loading') }}</span>
</div>
<!-- Plugin Grid -->
<div class="plugin-grid">
<div
v-for="item in pluginList" v-for="item in pluginList"
:key="item.fullName" :key="item.fullName"
class="plugin-item__container" class="plugin-card plugin-item-card"
:xs="24" :class="{ disabled: !item.enabled && !searchText }"
:sm="pluginList.length === 1 ? 24 : 12"
:md="pluginList.length === 1 ? 24 : 12"
:lg="pluginList.length === 1 ? 24 : 12"
:xl="pluginList.length === 1 ? 24 : 12"
>
<div
class="plugin-item"
:class="{ darwin: osGlobal === 'darwin' }"
> >
<!-- Plugin Badge -->
<div <div
v-if="!item.gui" v-if="!item.gui"
class="cli-only-badge" class="cli-badge"
title="CLI only"
> >
CLI CLI
</div> </div>
<!-- Update Badge -->
<div
v-if="latestVersionMap[item.fullName] && latestVersionMap[item.fullName] !== item.version"
class="update-badge"
>
NEW
</div>
<!-- Plugin Header -->
<div class="plugin-header">
<img <img
class="plugin-item__logo" class="plugin-logo"
:src="item.logo" :src="item.logo"
:onerror="defaultLogo" :onerror="defaultLogo"
alt=""
> >
<div <div class="plugin-info">
class="plugin-item__content" <h3
:class="{ disabled: !item.enabled }" class="plugin-name"
>
<div
class="plugin-item__name"
@click="openHomepage(item.homepage)" @click="openHomepage(item.homepage)"
> >
{{ item.name }} <small>{{ ' ' + item.version }}</small> &nbsp; {{ item.name }}
<!-- 升级提示 --> <span class="plugin-version">v{{ item.version }}</span>
<el-tag </h3>
v-if="latestVersionMap[item.fullName] && latestVersionMap[item.fullName] !== item.version" <p class="plugin-author">
type="success"
size="small"
round
effect="plain"
>
new
</el-tag>
</div>
<div
class="plugin-item__desc"
:title="item.description"
>
{{ item.description }}
</div>
<div class="plugin-item__info-bar">
<span class="plugin-item__author">
{{ item.author.replace(/<.*>/, '') }} {{ item.author.replace(/<.*>/, '') }}
</span> </p>
<span class="plugin-item__config"> </div>
</div>
<!-- Plugin Description -->
<div class="plugin-description">
<p :title="item.description">
{{ item.description }}
</p>
</div>
<!-- Plugin Actions -->
<div class="plugin-actions">
<template v-if="searchText"> <template v-if="searchText">
<template v-if="!item.hasInstall"> <template v-if="!item.hasInstall">
<span <button
v-if="!item.ing" v-if="!item.ing"
class="config-button install" class="plugin-button install-button"
@click="installPlugin(item)" @click="installPlugin(item)"
> >
{{ $t('PLUGIN_INSTALL') }} <DownloadIcon :size="16" />
</span> {{ t('pages.plugin.install') }}
<span </button>
v-else-if="item.ing" <button
class="config-button ing"
>
{{ $t('PLUGIN_INSTALLING') }}
</span>
</template>
<span
v-else v-else
class="config-button ing" class="plugin-button installing-button"
disabled
> >
{{ $t('PLUGIN_INSTALLED') }} <div class="button-spinner" />
</span> {{ t('pages.plugin.installing') }}
</button>
</template>
<button
v-else
class="plugin-button installed-button"
disabled
>
<CheckIcon :size="16" />
{{ t('pages.plugin.installed') }}
</button>
</template> </template>
<template v-else> <template v-else>
<span <button
v-if="item.ing" v-if="item.ing"
class="config-button ing" class="plugin-button processing-button"
disabled
> >
{{ $t('PLUGIN_DOING_SOMETHING') }} <div class="button-spinner" />
</span> {{ t('pages.plugin.doingSomething') }}
</button>
<template v-else> <template v-else>
<el-icon <button
v-if="item.enabled" v-if="item.enabled"
class="el-icon-setting" class="plugin-button settings-button"
@click="buildContextMenu(item)" @click="buildContextMenu(item)"
> >
<Tools /> <SettingsIcon :size="16" />
</el-icon> {{ t('pages.plugin.settings') }}
<el-icon </button>
<button
v-else v-else
class="el-icon-remove-outline" class="plugin-button disabled-button"
@click="buildContextMenu(item)" @click="buildContextMenu(item)"
> >
<Remove /> <XCircleIcon :size="16" />
</el-icon> {{ t('pages.plugin.disabled') }}
</button>
</template> </template>
</template> </template>
</span>
</div> </div>
</div> </div>
</div> </div>
</el-col>
</el-row> <!-- Empty State -->
<el-row <div
v-show="needReload" v-if="!loading && pluginList.length === 0"
class="reload-mask" class="plugin-card empty-state"
:class="{ 'cut-width': pluginList.length > 6 }"
justify="center"
> >
<el-button <div class="empty-content">
type="primary" <PackageIcon
size="small" class="empty-icon"
round :size="48"
@click="reloadApp" />
<h3>{{ searchText ? t('pages.plugin.noPluginsFound') : t('pages.plugin.NoPluginsInstalled') }}</h3>
<p>{{ searchText ? t('pages.plugin.tryDifferentSearch') : t('pages.plugin.installPluginsToGetStarted') }}</p>
<button
v-if="!searchText"
class="action-button"
@click="goAwesomeList"
> >
{{ $t('TIPS_NEED_RELOAD') }} <ExternalLinkIcon :size="16" />
</el-button> {{ t('pages.plugin.browsePlugins') }}
</el-row> </button>
<el-dialog </div>
v-model="dialogVisible" </div>
:modal-append-to-body="false"
:title=" <!-- Config Modal -->
$t('CONFIG_THING', { <transition name="modal">
c: configName <div
}) v-if="dialogVisible"
" class="modal-overlay"
width="70%" @click="dialogVisible = false"
append-to-body
> >
<div
class="modal-container"
@click.stop
>
<div class="modal-header">
<h2 class="modal-title">
{{ t('pages.plugin.configThing', { c: configName }) }}
</h2>
<button
class="modal-close"
@click="dialogVisible = false"
>
<XIcon :size="20" />
</button>
</div>
<div class="modal-content">
<config-form <config-form
:id="configName" :id="configName"
ref="$configForm" ref="$configForm"
:config="config" :config="config"
:type="currentType" :type="currentType"
color-mode="white" color-mode="white"
mode="plugin"
:show-tooltips="false"
/> />
<template #footer> </div>
<el-button <div class="modal-footer">
round <button
class="btn btn-secondary"
@click="dialogVisible = false" @click="dialogVisible = false"
> >
{{ $t('CANCEL') }} {{ t('common.cancel') }}
</el-button> </button>
<el-button <button
type="primary" class="btn btn-primary"
round
@click="handleConfirmConfig" @click="handleConfirmConfig"
> >
{{ $t('CONFIRM') }} {{ t('common.confirm') }}
</el-button> </button>
</template> </div>
</el-dialog> </div>
</div>
</transition>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Close, Download, Goods, Refresh, Remove, Tools } from '@element-plus/icons-vue'
import type { IpcRendererEvent } from 'electron' import type { IpcRendererEvent } from 'electron'
import { ElMessageBox } from 'element-plus'
import { debounce, DebouncedFunc } from 'lodash-es' import { debounce, DebouncedFunc } from 'lodash-es'
import {
AlertCircleIcon,
CheckIcon,
DatabaseIcon,
DownloadIcon,
ExternalLinkIcon,
PackageIcon,
RefreshCwIcon,
SearchIcon,
SettingsIcon,
XCircleIcon,
XIcon
} from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, onMounted, reactive, ref, toRaw, watch } from 'vue' import { computed, onBeforeMount, onBeforeUnmount, onMounted, reactive, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ConfigForm from '@/components/ConfigFormForPlugin.vue' import ConfigForm from '@/components/UnifiedConfigForm.vue'
import { handleStreamlinePluginName } from '@/utils/common' import { getRawData, handleStreamlinePluginName } from '@/utils/common'
import { configPaths } from '@/utils/configPaths' import { configPaths } from '@/utils/configPaths'
import { import {
PICGO_CONFIG_PLUGIN, PICGO_CONFIG_PLUGIN,
@@ -249,11 +318,10 @@ import {
} from '@/utils/constant' } from '@/utils/constant'
import { getConfig, saveConfig } from '@/utils/dataSender' import { getConfig, saveConfig } from '@/utils/dataSender'
import { IRPCActionType } from '@/utils/enum' import { IRPCActionType } from '@/utils/enum'
import { osGlobal, updatePicBedGlobal } from '@/utils/global' import { updatePicBedGlobal } from '@/utils/global'
import type { INPMSearchResultObject, IPicGoPlugin } from '#/types/types' import type { INPMSearchResultObject, IPicGoPlugin } from '#/types/types'
const { t } = useI18n() const { t } = useI18n()
const $confirm = ElMessageBox.confirm
const searchText = ref('') const searchText = ref('')
const pluginList = ref<IPicGoPlugin[]>([]) const pluginList = ref<IPicGoPlugin[]>([])
const config = ref<any[]>([]) const config = ref<any[]>([])
@@ -264,11 +332,9 @@ const pluginNameList = ref<string[]>([])
const loading = ref(true) const loading = ref(true)
const needReload = ref(false) const needReload = ref(false)
const latestVersionMap = reactive<{ [key: string]: string }>({}) const latestVersionMap = reactive<{ [key: string]: string }>({})
const pluginListToolTip = t('PLUGIN_LIST')
const importLocalPluginToolTip = t('PLUGIN_IMPORT_LOCAL')
const updateAllToolTip = t('PLUGIN_UPDATE_ALL')
const defaultLogo = ref('this.src=\'/roundLogo.png\'') const defaultLogo = ref('this.src=\'/roundLogo.png\'')
const $configForm = ref<InstanceType<typeof ConfigForm> | null>(null) const $configForm = ref<InstanceType<typeof ConfigForm> | null>(null)
const npmSearchText = computed(() => { const npmSearchText = computed(() => {
return searchText.value.match('picgo-plugin-') return searchText.value.match('picgo-plugin-')
? searchText.value ? searchText.value
@@ -276,11 +342,11 @@ const npmSearchText = computed(() => {
? `picgo-plugin-${searchText.value}` ? `picgo-plugin-${searchText.value}`
: searchText.value : searchText.value
}) })
let getSearchResult: DebouncedFunc<(val: string) => void> let getSearchResult: DebouncedFunc<(val: string) => void>
watch(npmSearchText, (val: string) => { watch(npmSearchText, (val: string) => {
if (val) { if (val) {
loading.value = true
pluginList.value = [] pluginList.value = []
getSearchResult(val) getSearchResult(val)
} else { } else {
@@ -290,11 +356,9 @@ watch(npmSearchText, (val: string) => {
watch(dialogVisible, (val: boolean) => { watch(dialogVisible, (val: boolean) => {
if (val) { if (val) {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. document.body.style.overflow = 'hidden'
document.querySelector('.main-content.el-row').style.zIndex = 101
} else { } else {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. document.body.style.overflow = 'auto'
document.querySelector('.main-content.el-row').style.zIndex = 10
} }
}) })
@@ -393,7 +457,6 @@ const picgoHandlePluginIngHandler = (_: IpcRendererEvent, fullName: string) => {
item.ing = true item.ing = true
} }
}) })
loading.value = true
} }
const picgoTogglePluginHandler = (_: IpcRendererEvent, fullName: string, enabled: boolean) => { const picgoTogglePluginHandler = (_: IpcRendererEvent, fullName: string, enabled: boolean) => {
@@ -421,14 +484,11 @@ onBeforeMount(async () => {
}) })
async function buildContextMenu (plugin: IPicGoPlugin) { async function buildContextMenu (plugin: IPicGoPlugin) {
window.electron.sendRPC(IRPCActionType.SHOW_PLUGIN_PAGE_MENU, plugin) window.electron.sendRPC(IRPCActionType.SHOW_PLUGIN_PAGE_MENU, getRawData(plugin))
} }
function handleResize () { function handleResize () {
const myDiv = document.getElementById('pluginList') as HTMLElement // No longer needed with new layout
const windowHeight = window.innerHeight
const newHeight = windowHeight * 0.75
myDiv.style.height = newHeight + 'px'
} }
onMounted(() => { onMounted(() => {
@@ -441,18 +501,10 @@ function getPluginList () {
function installPlugin (item: IPicGoPlugin) { function installPlugin (item: IPicGoPlugin) {
if (!item.gui) { if (!item.gui) {
$confirm(t('TIPS_PLUGIN_NOT_GUI_IMPLEMENT'), t('TIPS_NOTICE'), { if (confirm(t('pages.plugin.notGuiImplement'))) {
confirmButtonText: t('CONFIRM'),
cancelButtonText: t('CANCEL'),
type: 'warning'
})
.then(() => {
item.ing = true item.ing = true
window.electron.sendRPC(IRPCActionType.PLUGIN_INSTALL, item.fullName) window.electron.sendRPC(IRPCActionType.PLUGIN_INSTALL, item.fullName)
}) }
.catch(() => {
console.log('Install canceled')
})
} else { } else {
item.ing = true item.ing = true
window.electron.sendRPC(IRPCActionType.PLUGIN_INSTALL, item.fullName) window.electron.sendRPC(IRPCActionType.PLUGIN_INSTALL, item.fullName)
@@ -468,12 +520,14 @@ async function handleReload () {
needReload: true needReload: true
}) })
needReload.value = true needReload.value = true
const successNotification = new Notification(t('PLUGIN_UPDATE_SUCCEED'), { if ('Notification' in window) {
body: t('TIPS_NEED_RELOAD') const successNotification = new Notification(t('pages.plugin.updateSuccess'), {
body: t('pages.plugin.needRestart')
}) })
successNotification.onclick = () => { successNotification.onclick = () => {
reloadApp() reloadApp()
} }
}
} }
function cleanSearch () { function cleanSearch () {
@@ -500,12 +554,14 @@ async function handleConfirmConfig () {
}) })
break break
} }
if ('Notification' in window) {
const successNotification = new Notification(t('SETTINGS_RESULT'), { const successNotification = new Notification(t('SETTINGS_RESULT'), {
body: t('TIPS_SET_SUCCEED') body: t('pages.plugin.setSuccess')
}) })
successNotification.onclick = () => { successNotification.onclick = () => {
return true return true
} }
}
dialogVisible.value = false dialogVisible.value = false
getPluginList() getPluginList()
} }
@@ -605,179 +661,16 @@ onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener(PICGO_CONFIG_PLUGIN, picgoConfigPluginHandler) window.electron.ipcRendererRemoveListener(PICGO_CONFIG_PLUGIN, picgoConfigPluginHandler)
window.electron.ipcRendererRemoveListener(PICGO_HANDLE_PLUGIN_ING, picgoHandlePluginIngHandler) window.electron.ipcRendererRemoveListener(PICGO_HANDLE_PLUGIN_ING, picgoHandlePluginIngHandler)
window.electron.ipcRendererRemoveListener(PICGO_TOGGLE_PLUGIN, picgoTogglePluginHandler) window.electron.ipcRendererRemoveListener(PICGO_TOGGLE_PLUGIN, picgoTogglePluginHandler)
// Reset body overflow
document.body.style.overflow = 'auto'
}) })
</script> </script>
<script lang="ts"> <script lang="ts">
export default { export default {
name: 'PluginPage' name: 'PluginPage'
} }
</script> </script>
<style lang="stylus">
$darwinBg = #172426 <style scoped src="./css/PluginPage.css"></style>
#plugin-view
position absolute
left 142px
right 0
.el-loading-mask
background-color rgba(0, 0, 0, 0.8)
.plugin-list
align-content flex-start
height: 600px;
box-sizing: border-box;
padding: 8px 15px;
overflow-y: auto;
overflow-x: hidden;
position: absolute;
top: 70px;
left: 5px;
transition: all 0.2s ease-in-out 0.1s;
width: 100%
.el-loading-mask
left: 20px
width: calc(100% - 40px)
.view-title
color #eee
font-size 20px
text-align center
margin 10px auto
position relative
i.el-icon-goods
margin-left 4px
font-size 20px
vertical-align middle
cursor pointer
transition color .2s ease-in-out
&:hover
color #49B1F5
i.el-icon-update
position absolute
right 35px
top 8px
font-size 20px
vertical-align middle
cursor pointer
transition color .2s ease-in-out
&:hover
color #49B1F5
i.el-icon-download
position absolute
right 5px
top 8px
font-size 20px
vertical-align middle
cursor pointer
transition color .2s ease-in-out
&:hover
color #49B1F5
.handle-bar
margin-bottom 20px
&.cut-width
padding-right: 8px
.el-input__inner
border-radius 0
.plugin-item
box-sizing border-box
height 80px
background #444
padding 8px
user-select text
transition all .2s ease-in-out
position relative
&__container
height 80px
margin-bottom 10px
.cli-only-badge
position absolute
right 0px
top 0
font-size 12px
padding 3px 8px
background #49B1F5
color #eee
&.darwin
background transparentify($darwinBg, #000, 0.75)
&:hover
background transparentify($darwinBg, #000, 0.85)
&:hover
background #333
&__logo
width 64px
height 64px
float left
&__content
float left
width calc(100% - 72px)
height 64px
color #ddd
margin-left 8px
display flex
flex-direction column
justify-content space-between
&.disabled
color #aaa
&__name
font-size 16px
height 22px
line-height 22px
font-weight 600
cursor pointer
text-overflow ellipsis
white-space nowrap
overflow hidden
transition all .2s ease-in-out
&:hover
color: #1B9EF3
&__desc
font-size 14px
height 21px
line-height 21px
overflow hidden
text-overflow ellipsis
white-space nowrap
&__info-bar
font-size 14px
height 21px
line-height 28px
position relative
&__author
overflow hidden
text-overflow ellipsis
white-space nowrap
&__config
float right
font-size 16px
cursor pointer
transition all .2s ease-in-out
&:hover
color: #1B9EF3
.config-button
font-size 12px
color #ddd
background #222
padding 1px 8px
height 18px
line-height 18px
text-align center
position absolute
top 4px
right 20px
transition all .2s ease-in-out
&.reload
right 0px
&.ing
right 0px
&.install
right 0px
&:hover
background: #1B9EF3
color #fff
.reload-mask
position absolute
width calc(100% - 40px)
bottom -320px
text-align center
background rgba(0,0,0,0.4)
padding 10px 0
&.cut-width
width calc(100% - 48px)
</style>

View File

@@ -1,65 +1,73 @@
<template> <template>
<div id="rename-page"> <div class="rename-container">
<el-form <div class="rename-card">
ref="formRef" <form @submit.prevent="confirmName">
:model="form" <div class="form-content">
@submit.prevent <div class="form-group">
> <div class="input-wrapper">
<el-form-item <input
:label="$t('FILE_RENAME')" ref="fileNameInput"
prop="fileName"
:rules="[{ required: true, message: 'file name is required', trigger: 'blur' }]"
>
<el-input
v-model="form.fileName" v-model="form.fileName"
size="small" type="text"
class="form-input"
:class="{ 'input-error': validationError }"
:placeholder="t('pages.rename.placeholder')"
autofocus autofocus
@keyup.enter="confirmName" @keyup.enter="confirmName"
@input="clearValidationError"
> >
<template #suffix> <button
<el-icon v-if="form.fileName"
class="el-input__icon" type="button"
style="cursor: pointer" class="input-clear"
@click="form.fileName = ''" @click="clearFileName"
> >
<close /> <XIcon :size="16" />
</el-icon> </button>
</template> </div>
</el-input> <div
</el-form-item> v-if="validationError"
</el-form> class="validation-error"
<el-row> >
<div class="pull-right"> {{ validationError }}
<el-button </div>
round </div>
size="small" </div>
<!-- Actions -->
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
@click="cancel" @click="cancel"
> >
{{ $t('CANCEL') }} {{ $t('common.cancel') }}
</el-button> </button>
<el-button <button
type="primary" type="submit"
round class="btn btn-primary"
size="small" :disabled="!form.fileName.trim()"
@click="confirmName"
> >
{{ $t('CONFIRM') }} {{ $t('common.confirm') }}
</el-button> </button>
</div>
</form>
</div> </div>
</el-row>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Close } from '@element-plus/icons-vue'
import type { IpcRendererEvent } from 'electron' import type { IpcRendererEvent } from 'electron'
import type { FormInstance } from 'element-plus' import { XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue' import { nextTick, onBeforeMount, onBeforeUnmount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '@/utils/constant' import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '@/utils/constant'
const { t } = useI18n()
const id = ref<string | null>(null) const id = ref<string | null>(null)
const formRef = ref<FormInstance>() const fileNameInput = ref<HTMLInputElement>()
const validationError = ref<string>('')
const form = reactive({ const form = reactive({
fileName: '', fileName: '',
@@ -70,6 +78,10 @@ const handleFileName = (_: IpcRendererEvent, newName: string, _originName: strin
form.fileName = newName form.fileName = newName
form.originName = _originName form.originName = _originName
id.value = _id id.value = _id
nextTick(() => {
fileNameInput.value?.focus()
fileNameInput.value?.select()
})
} }
window.electron.ipcRendererOn(RENAME_FILE_NAME, handleFileName) window.electron.ipcRendererOn(RENAME_FILE_NAME, handleFileName)
@@ -78,19 +90,49 @@ onBeforeMount(() => {
window.electron.sendToMain(GET_RENAME_FILE_NAME, '') window.electron.sendToMain(GET_RENAME_FILE_NAME, '')
}) })
function confirmName () { function validateFileName (fileName: string): string {
formRef.value?.validate(valid => { if (!fileName.trim()) {
if (valid) { return 'File name is required'
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.fileName)
} }
}) 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 () { function cancel () {
// if cancel, use origin file name
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.originName) 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(() => { onBeforeUnmount(() => {
window.electron.ipcRendererRemoveListener(RENAME_FILE_NAME, handleFileName) window.electron.ipcRendererRemoveListener(RENAME_FILE_NAME, handleFileName)
}) })
@@ -100,11 +142,228 @@ export default {
name: 'RenamePage' name: 'RenamePage'
} }
</script> </script>
<style lang="stylus"> <style scoped>
#rename-page .rename-container {
padding 0 20px padding: 2rem;
.pull-right min-height: 100vh;
float right background: var(--color-background-secondary);
.el-form-item__label display: flex;
color #ddd 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> </style>

View File

@@ -1,120 +1,148 @@
<template> <template>
<div id="shortcut-page"> <div class="shortkey-container">
<div class="view-title"> <!-- Header -->
{{ $t('SETTINGS_SET_SHORTCUT') }} <div class="shortkey-header">
<div class="header-content">
<KeyboardIcon
:size="24"
class="header-icon"
/>
<div>
<h1>{{ t('pages.shortKey.title') }}</h1>
<p>{{ ' ' }}</p>
</div> </div>
<el-row> </div>
<el-col </div>
:span="20"
:offset="2" <!-- Shortcuts Table Card -->
<div class="shortkey-card">
<div class="table-container">
<table class="shortkey-table">
<thead>
<tr>
<th>{{ t('pages.shortKey.name') }}</th>
<th>{{ t('pages.shortKey.bind') }}</th>
<th>{{ t('pages.shortKey.status') }}</th>
<th>{{ t('pages.shortKey.source') }}</th>
<th>{{ t('pages.shortKey.handle') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in list"
:key="item.name"
class="table-row"
> >
<el-table <td class="name-cell">
class="shortcut-page-table-border" <div class="shortcut-name">
:data="list" {{ item.label ? item.label : item.name }}
size="small" </div>
header-cell-class-name="shortcut-page-table-border" </td>
cell-class-name="shortcut-page-table-border" <td class="key-cell">
<div class="key-binding">
<kbd
v-if="item.key"
class="key-display"
>{{ item.key }}</kbd>
<span
v-else
class="no-binding"
>{{ t('pages.shortKey.noBinding') }}</span>
</div>
</td>
<td class="status-cell">
<span
class="status-badge"
:class="{ 'status-enabled': item.enable, 'status-disabled': !item.enable }"
> >
<el-table-column :label="$t('SHORTCUT_NAME')"> {{ item.enable ? t('pages.shortKey.enabled') : t('pages.shortKey.disabled') }}
<template #default="scope"> </span>
{{ scope.row.label ? scope.row.label : scope.row.name }} </td>
</template> <td class="source-cell">
</el-table-column> <span class="source-name">{{ calcOriginShowName(item.from || '') }}</span>
<el-table-column </td>
width="160px" <td class="actions-cell">
:label="$t('SHORTCUT_BIND')" <div class="action-buttons">
prop="key" <button
/> class="btn btn-sm"
<el-table-column :label="$t('SHORTCUT_STATUS')"> :class="item.enable ? 'btn-danger' : 'btn-success'"
<template #default="scope"> @click="toggleEnable(item)"
<el-tag
size="small"
:type="scope.row.enable ? 'success' : 'danger'"
> >
{{ scope.row.enable ? $t('SHORTCUT_ENABLED') : $t('SHORTCUT_DISABLED') }} {{ item.enable ? t('pages.shortKey.disable') : t('pages.shortKey.enable') }}
</el-tag> </button>
</template> <button
</el-table-column> class="btn btn-sm btn-secondary"
<el-table-column @click="openKeyBindingDialog(item, index)"
:label="$t('SHORTCUT_SOURCE')"
width="100px"
> >
<template #default="scope"> <Edit :size="14" />
{{ calcOriginShowName(scope.row.from) }} {{ t('pages.shortKey.edit') }}
</template> </button>
</el-table-column> </div>
<el-table-column </td>
:label="$t('SHORTCUT_HANDLE')" </tr>
width="100px" </tbody>
</table>
</div>
</div>
<!-- Key Binding Modal -->
<transition name="modal">
<div
v-if="keyBindingVisible"
class="modal-overlay"
@click.self="cancelKeyBinding"
> >
<template #default="scope"> <div class="modal-content">
<el-row> <div class="modal-header">
<el-button <h3 class="modal-title">
size="small" {{ t('pages.shortKey.changeUpload') }}
:class="{ </h3>
disabled: scope.row.enable <button
}" class="modal-close"
type="info" @click="cancelKeyBinding"
:link="true"
@click="toggleEnable(scope.row)"
> >
{{ scope.row.enable ? $t('SHORTCUT_DISABLE') : $t('SHORTCUT_ENABLE') }} <XIcon :size="20" />
</el-button> </button>
<el-button </div>
class="edit" <div class="modal-body">
size="small" <div class="form-group">
type="info" <label>{{ t('pages.shortKey.keyBinding') }}</label>
:link="true" <input
@click="openKeyBindingDialog(scope.row, scope.$index)"
>
{{ $t('SHORTCUT_EDIT') }}
</el-button>
</el-row>
</template>
</el-table-column>
</el-table>
</el-col>
</el-row>
<el-dialog
v-model="keyBindingVisible"
:title="$t('SHORTCUT_CHANGE_UPLOAD')"
:modal-append-to-body="false"
append-to-body
>
<el-form
label-position="top"
label-width="80px"
>
<el-form-item>
<el-input
v-model="shortKey" v-model="shortKey"
class="align-center" class="form-input key-input"
:autofocus="true" :placeholder="t('pages.shortKey.pressKeys')"
readonly
@keydown.prevent="keyDetect($event as KeyboardEvent)" @keydown.prevent="keyDetect($event as KeyboardEvent)"
/> >
</el-form-item> <div class="input-hint">
</el-form> {{ t('pages.shortKey.pressHint') }}
<template #footer> </div>
<el-button </div>
round </div>
<div class="modal-footer">
<button
class="btn btn-secondary"
@click="cancelKeyBinding" @click="cancelKeyBinding"
> >
{{ $t('CANCEL') }} {{ $t('CANCEL') }}
</el-button> </button>
<el-button <button
type="primary" class="btn btn-primary"
round
@click="confirmKeyBinding" @click="confirmKeyBinding"
> >
{{ $t('CONFIRM') }} {{ $t('common.confirm') }}
</el-button> </button>
</template> </div>
</el-dialog> </div>
</div>
</transition>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Edit, KeyboardIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue' import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { configPaths } from '@/utils/configPaths' import { configPaths } from '@/utils/configPaths'
import { getConfig } from '@/utils/dataSender' import { getConfig } from '@/utils/dataSender'
@@ -122,6 +150,7 @@ import { IRPCActionType } from '@/utils/enum'
import keyBinding from '@/utils/key-binding' import keyBinding from '@/utils/key-binding'
import type { IShortKeyConfig, IShortKeyConfigs } from '#/types/types' import type { IShortKeyConfig, IShortKeyConfigs } from '#/types/types'
const { t } = useI18n()
const list = ref<IShortKeyConfig[]>([]) const list = ref<IShortKeyConfig[]>([])
const keyBindingVisible = ref(false) const keyBindingVisible = ref(false)
const command = ref('') const command = ref('')
@@ -195,40 +224,457 @@ export default {
} }
</script> </script>
<style lang="stylus"> <style scoped>
#shortcut-page .shortkey-container {
.shortcut-page-table-border padding: 1.5rem;
border-color darken(#eee, 50%) min-height: 100vh;
.el-dialog__body background: var(--color-background-secondary);
padding 10px 20px color: var(--color-text-primary);
.el-form-item overflow-y: auto;
margin-bottom 0 scrollbar-width: none;
.el-button -ms-overflow-style: none;
&.disabled }
color: #F56C6C
&.edit .shortkey-container::-webkit-scrollbar {
color: #67C23A display: none;
&--text }
padding-left 4px
padding-right 4px /* Header */
.el-table .shortkey-header {
background-color: transparent display: flex;
color #ddd justify-content: space-between;
&::before align-items: center;
background-color darken(#eee, 50%) background: var(--color-surface);
thead border-radius: 12px;
color #bbb padding: 1.5rem;
th,tr margin-bottom: 1.5rem;
background-color: transparent box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__body border: 1px solid var(--color-border);
tr.el-table__row--striped }
td
background transparent .header-content {
&--enable-row-hover display: flex;
.el-table__body align-items: center;
tr:hover gap: 1rem;
&>td }
background #333
.el-button+.el-button .header-icon {
margin-left 4px color: var(--color-accent);
}
.shortkey-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
}
.shortkey-header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
/* Card */
.shortkey-card {
background: var(--color-background-primary);
border-radius: 12px;
box-shadow: 0 2px 8px var(--color-border);
border: 1px solid var(--color-border);
overflow: hidden;
}
/* Table */
.table-container {
overflow-x: auto;
}
.shortkey-table {
width: 100%;
border-collapse: collapse;
background: transparent;
}
.shortkey-table th {
background: var(--color-background-tertiary);
color: var(--color-text-primary);
font-weight: 600;
font-size: 0.875rem;
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.shortkey-table td {
padding: 1rem;
border-bottom: 1px solid var(--color-border-secondary);
vertical-align: middle;
}
.table-row:hover {
background: var(--color-background-tertiary);
}
.table-row:last-child td {
border-bottom: none;
}
/* Table Cells */
.name-cell {
font-weight: 500;
color: var(--color-text-primary);
width: 25%;
}
.key-cell {
width: 20%;
}
.key-binding {
display: flex;
align-items: center;
}
.key-display {
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.25rem 0.5rem;
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.no-binding {
color: var(--color-text-secondary);
font-style: italic;
font-size: 0.875rem;
}
.status-cell {
width: 15%;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.status-enabled {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
border: 1px solid rgba(103, 194, 58, 0.2);
}
.status-disabled {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
border: 1px solid rgba(245, 108, 108, 0.2);
}
.source-cell {
width: 15%;
}
.source-name {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.actions-cell {
width: 25%;
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 6px;
border: none;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: fit-content;
text-decoration: none;
}
.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-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.btn-primary {
background: #409eff;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #66b1ff;
}
.btn-secondary {
background: var(--color-background-tertiary);
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);
}
.btn-success {
background: #67c23a;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #85ce61;
}
.btn-danger {
background: #f56c6c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #f78989;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
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);
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border-secondary);
}
.modal-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: none;
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border-secondary);
background: var(--color-background-tertiary);
}
/* Form Elements */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.form-input {
width: 100%;
padding: 0.75rem;
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);
}
.key-input {
font-family: monospace;
text-align: center;
font-weight: 600;
letter-spacing: 0.5px;
}
.input-hint {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: center;
}
/* Transitions */
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
transform: scale(0.9);
}
.modal-enter-active .modal-content,
.modal-leave-active .modal-content {
transition: all 0.3s ease;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: translateY(-20px);
}
/* Responsive Design */
@media (max-width: 768px) {
.shortkey-container {
padding: 1rem;
}
.shortkey-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.table-container {
overflow-x: auto;
}
.shortkey-table th,
.shortkey-table td {
padding: 0.75rem 0.5rem;
}
.action-buttons {
flex-direction: column;
gap: 0.25rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
}
.modal-content {
margin: 1rem;
}
.modal-header,
.modal-body,
.modal-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.shortkey-container {
padding: 0.75rem;
}
.shortkey-header h1 {
font-size: 1.25rem;
}
.shortkey-table {
font-size: 0.875rem;
}
.shortkey-table th,
.shortkey-table td {
padding: 0.5rem 0.375rem;
}
}
/* Focus styles for accessibility */
.btn:focus-visible,
.modal-close:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.form-input:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style> </style>

View File

@@ -200,13 +200,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IpcRendererEvent } from 'electron' import type { IpcRendererEvent } from 'electron'
import { ElMessage as $message } from 'element-plus'
import { ChevronDownIcon, ClipboardIcon, DatabaseIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next' import { ChevronDownIcon, ClipboardIcon, DatabaseIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue' import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImageProcessSetting from '@/components/ImageProcessSetting.vue' import ImageProcessSetting from '@/components/ImageProcessSetting.vue'
import useMessage from '@/hooks/useMessage'
import { PICBEDS_PAGE } from '@/router/config' import { PICBEDS_PAGE } from '@/router/config'
import $bus from '@/utils/bus' import $bus from '@/utils/bus'
import { isUrl } from '@/utils/common' import { isUrl } from '@/utils/common'
@@ -221,6 +221,7 @@ import type { IFileWithPath, IUploaderConfigItem } from '#/types/types'
useDragEventListeners() useDragEventListeners()
const $router = useRouter() const $router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const message = useMessage()
const imageProcessDialogVisible = ref(false) const imageProcessDialogVisible = ref(false)
const useShortUrl = ref(false) const useShortUrl = ref(false)
@@ -327,7 +328,7 @@ function onDrop (e: DragEvent) {
if (isUrl(str)) { if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }]) window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
} else { } else {
$message.error(t('pages.upload.dragValidPictureOrUrl')) message.error(t('pages.upload.dragValidPictureOrUrl'))
} }
} }
} }
@@ -345,7 +346,7 @@ function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer)
} }
]) ])
} else { } else {
$message.error(t('pages.upload.dragValidPictureOrUrl')) message.error(t('pages.upload.dragValidPictureOrUrl'))
} }
} }
@@ -415,7 +416,7 @@ function handleInputBoxValue (val: string) {
} }
]) ])
} else { } else {
$message.error(t('pages.upload.inputValidUrl')) message.error(t('pages.upload.inputValidUrl'))
} }
} }

View File

@@ -1,29 +1,24 @@
<template> <template>
<div id="config-list-view"> <div class="config-container">
<div class="view-title"> <!-- Header Card -->
{{ $t('SETTINGS') }} <div class="config-card header-card">
<div class="card-header">
<h1 class="page-title">
{{ t('pages.uploaderConfig.title') }}
</h1>
</div> </div>
<el-row </div>
:gutter="15"
justify="space-between" <!-- Config Items Card -->
align="middle" <div class="config-card main-card">
type="flex" <div class="config-grid">
class="config-list" <div
>
<el-col
v-for="item in curConfigList" v-for="item in curConfigList"
:key="item._id" :key="item._id"
class="config-item-col"
:xs="24"
:sm="curConfigList.length === 1 ? 24 : 12"
:md="curConfigList.length === 1 ? 24 : 12"
:lg="curConfigList.length === 1 ? 12 : 6"
:xl="curConfigList.length === 1 ? 12 : 3"
>
<div
:class="`config-item ${defaultConfigId === item._id ? 'selected' : ''}`" :class="`config-item ${defaultConfigId === item._id ? 'selected' : ''}`"
@click="() => selectItem(item._id)" @click="() => selectItem(item._id)"
> >
<div class="config-content">
<div class="config-name"> <div class="config-name">
{{ item._configName }} {{ item._configName }}
</div> </div>
@@ -32,71 +27,69 @@
</div> </div>
<div <div
v-if="defaultConfigId === item._id" v-if="defaultConfigId === item._id"
class="default-text" class="default-badge"
> >
{{ $t('SELECTED_SETTING_HINT') }} {{ t('pages.uploaderConfig.selected') }}
</div> </div>
<div class="operation-container"> </div>
<el-icon <div class="config-actions">
class="el-icon-edit" <button
@click="openEditPage(item._id)" class="action-btn edit-btn"
:title="t('pages.uploaderConfig.edit')"
@click.stop="openEditPage(item._id)"
> >
<Edit /> <Edit :size="16" />
</el-icon> </button>
<el-icon <button
class="el-icon-delete" class="action-btn delete-btn"
:class="curConfigList.length <= 1 ? 'disabled' : ''" :class="curConfigList.length <= 1 ? 'disabled' : ''"
:title="t('pages.uploaderConfig.delete')"
:disabled="curConfigList.length <= 1"
@click.stop="() => deleteConfig(item._id)" @click.stop="() => deleteConfig(item._id)"
> >
<Delete /> <Trash2 :size="16" />
</el-icon> </button>
</div> </div>
</div> </div>
</el-col>
<el-col <!-- Add New Config Button -->
class="config-item-col"
:xs="24"
:sm="curConfigList.length === 1 ? 24 : 12"
:md="curConfigList.length === 1 ? 24 : 12"
:lg="curConfigList.length === 1 ? 12 : 6"
:xl="curConfigList.length === 1 ? 12 : 3"
>
<div <div
class="config-item config-item-add" class="config-item config-item-add"
@click="addNewConfig" @click="addNewConfig"
> >
<el-icon class="el-icon-plus"> <div class="add-content">
<Plus /> <Plus :size="32" />
</el-icon> <span class="add-text">{{ t('pages.uploaderConfig.addNew') }}</span>
</div> </div>
</el-col> </div>
</el-row> </div>
<el-row </div>
type="flex"
justify="center" <!-- Actions Card -->
:span="24" <div class="config-card actions-card">
class="set-default-container" <div class="card-actions">
> <button
<el-button class="primary-button"
class="set-default-btn"
type="success"
round
:disabled="store?.state.defaultPicBed === type" :disabled="store?.state.defaultPicBed === type"
@click="setDefaultPicBed(type)" @click="setDefaultPicBed(type)"
> >
{{ $t('SETTINGS_SET_DEFAULT_PICBED') }} <DatabaseIcon :size="16" />
</el-button> <span>{{ t('pages.uploaderConfig.setAsDefault') }}</span>
</el-row> </button>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Delete, Edit, Plus } from '@element-plus/icons-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { DatabaseIcon, Edit, Plus, Trash2 } from 'lucide-vue-next'
import { onBeforeMount, ref } from 'vue' import { onBeforeMount, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import useConfirm from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import { useStore } from '@/hooks/useStore' import { useStore } from '@/hooks/useStore'
import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config' import { PICBEDS_PAGE, UPLOADER_CONFIG_PAGE } from '@/router/config'
import { configPaths } from '@/utils/configPaths' import { configPaths } from '@/utils/configPaths'
@@ -105,6 +98,8 @@ import { IRPCActionType } from '@/utils/enum'
import type { IStringKeyMap, IUploaderConfigItem } from '#/types/types' import type { IStringKeyMap, IUploaderConfigItem } from '#/types/types'
const { t } = useI18n() const { t } = useI18n()
const message = useMessage()
const { confirm } = useConfirm()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -157,14 +152,25 @@ function openEditPage (configId: string) {
} }
function formatTime (time: number): string { function formatTime (time: number): string {
return dayjs(time).format('YY-MM-DD HH:mm') return dayjs(time).format('YYYY-MM-DD HH:mm')
} }
async function deleteConfig (id: string) { 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) const res = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_DELETE_CONFIG, type.value, id)
if (!res) return if (!res) return
curConfigList.value = res.configList curConfigList.value = res.configList
defaultConfigId.value = res.defaultId defaultConfigId.value = res.defaultId
message.success(t('pages.uploaderConfig.deleteSuccess'))
})
} }
function addNewConfig () { function addNewConfig () {
@@ -186,12 +192,7 @@ function setDefaultPicBed (type: string) {
store?.setDefaultPicBed(type) store?.setDefaultPicBed(type)
const currentConfigName = curConfigList.value.find(item => item._id === defaultConfigId.value)?._configName const currentConfigName = curConfigList.value.find(item => item._id === defaultConfigId.value)?._configName
window.electron.sendRPC(IRPCActionType.TRAY_SET_TOOL_TIP, `${type} ${currentConfigName || ''}`) window.electron.sendRPC(IRPCActionType.TRAY_SET_TOOL_TIP, `${type} ${currentConfigName || ''}`)
const successNotification = new Notification(t('SETTINGS_DEFAULT_PICBED'), { message.success(t('pages.uploaderConfig.setSuccess'))
body: t('TIPS_SET_SUCCEED')
})
successNotification.onclick = () => {
return true
}
} }
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -199,80 +200,301 @@ export default {
name: 'UploaderConfigPage' name: 'UploaderConfigPage'
} }
</script> </script>
<style lang="stylus"> <style scoped>
#config-list-view /* Container */
position absolute .config-container {
min-height 100% padding: 1rem;
left 162px width: 100%;
right 0 margin: 0;
overflow-x hidden display: flex;
overflow-y auto flex-direction: column;
padding-bottom 50px gap: 1.25rem;
box-sizing border-box min-height: 100vh;
.config-list box-sizing: border-box;
flex-wrap wrap overflow-y: auto;
width: 98% }
.config-item
height 85px /* Card Base */
margin-bottom 20px .config-card {
border-radius 4px background: var(--color-surface);
cursor pointer border: 1px solid var(--color-border-secondary);
box-sizing border-box border-radius: var(--radius-xl);
padding 8px overflow: hidden;
background rgba(130, 130, 130, .2) transition: var(--transition-medium);
border 1px solid transparent box-shadow: var(--shadow-sm);
box-shadow 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04) }
position relative
.config-name .config-card:hover {
color #eee box-shadow: var(--shadow-md);
font-size 16px border-color: var(--color-border);
.config-update-time }
color #aaa
font-size 14px /* Header Card */
margin-top 10px .header-card .card-header {
.default-text padding: 1.5rem 2rem;
color #67C23A border-bottom: 1px solid var(--color-border-secondary);
font-size 12px }
margin-top 5px
.operation-container .page-title {
position absolute font-size: 1.5rem;
right 5px font-weight: 600;
top 8px color: var(--color-text-primary);
left 0 margin: 0;
font-size 18pxc letter-spacing: -0.025em;
word-break break-all }
display flex
align-items center /* Main Card */
color #eee .main-card {
.el-icon-edit background: var( --color-background-tertiary);
right 20px padding: 1.5rem;
position absolute }
top 2px
margin-right 10px .config-grid {
cursor pointer display: grid;
.el-icon-delete grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
position absolute gap: 1rem;
top 2px width: 100%;
margin-right 10px }
right 0
cursor pointer @media (max-width: 768px) {
.el-icon-edit .config-grid {
margin-right 10px grid-template-columns: 1fr;
.disabled gap: 0.75rem;
cursor not-allowed }
color #aaa }
.config-item-add
display: flex @media (min-width: 1200px) {
justify-content: center .config-grid {
align-items: center grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
color: #eee gap: 1.25rem;
font-size: 28px }
.selected }
border 1px solid #409EFF
.set-default-container /* Config Items */
position absolute .config-item {
bottom 10px background: var(--color-surface-elevated);
width 100% border: 1px solid var(--color-border);
.set-default-btn border-radius: var(--radius-lg);
width 250px padding: 1.25rem;
cursor: pointer;
transition: var(--transition-medium);
position: relative;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.config-item:hover {
background: var(--color-surface);
border-color: var(--color-accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.config-item.selected {
border-color: var(--color-accent);
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
.config-content {
flex: 1;
margin-right: 2rem;
}
.config-name {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
line-height: 1.4;
}
.config-update-time {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.default-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: var(--color-accent);
color: white;
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-md);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.config-actions {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-fast);
color: var(--color-text-secondary);
}
.action-btn:hover {
background: var(--color-surface);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.edit-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.delete-btn:hover:not(.disabled) {
border-color: var(--color-danger);
color: var(--color-danger);
}
.delete-btn.disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Add New Config Item */
.config-item-add {
border: 2px dashed var(--color-border);
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
.config-item-add:hover {
border-color: var(--color-accent);
background: var(--color-surface-elevated);
transform: translateY(-2px);
}
.add-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: var(--color-text-secondary);
transition: var(--transition-fast);
}
.config-item-add:hover .add-content {
color: var(--color-accent);
}
.add-text {
font-size: 0.875rem;
font-weight: 500;
}
/* Actions Card */
.actions-card {
border-radius: var(--radius-lg);
}
.card-actions {
padding: 1.25rem 1.5rem;
display: flex;
justify-content: center;
}
.primary-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 2rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
min-width: 200px;
justify-content: center;
}
.primary-button:hover:not(:disabled) {
background: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.primary-button:disabled {
background: var(--color-border);
color: var(--color-text-secondary);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Responsive Design */
@media (max-width: 768px) {
.config-container {
padding: 0.75rem;
gap: 1rem;
}
.header-card .card-header {
padding: 1rem 1.25rem;
}
.page-title {
font-size: 1.25rem;
}
.main-card {
padding: 1rem;
}
.config-item {
padding: 1rem;
min-height: 100px;
}
.config-actions {
top: 0.75rem;
right: 0.75rem;
}
.action-btn {
width: 28px;
height: 28px;
}
.primary-button {
padding: 0.75rem 1.5rem;
min-width: 180px;
}
}
@media (min-width: 1024px) {
.config-container {
padding: 1.5rem 2rem;
max-width: 1200px;
margin: 0 auto;
}
}
</style> </style>

View File

@@ -740,11 +740,11 @@ small {
} }
.placeholder-item code { .placeholder-item code {
background: linear-gradient(135deg, var(--color-accent) 0%, #667eea 100%); background: var(--color-blue-common);
color: white; color: white;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
border-radius: 8px; border-radius: 8px;
font-size: 0.75rem; font-size: 1rem;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'SF Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
margin-right: 0.875rem; margin-right: 0.875rem;
min-width: 80px; min-width: 80px;
@@ -808,6 +808,6 @@ small {
:root.dark .placeholder-item code, :root.dark .placeholder-item code,
:root.auto.dark .placeholder-item code { :root.auto.dark .placeholder-item code {
background: linear-gradient(135deg, var(--color-accent) 0%, #764ba2 100%); background: var(--color-blue-common);
border-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.15);
} }

View File

@@ -0,0 +1,772 @@
/* Global scrolling behavior */
html, body {
overflow-x: hidden;
}
/* Container */
.plugin-container {
padding: 1rem;
width: 100%;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
min-height: 100vh;
box-sizing: border-box;
overflow-y: auto;
}
/* Card Base */
.plugin-card {
background: var(--color-surface);
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-xl);
overflow: hidden;
transition: var(--transition-medium);
box-shadow: var(--shadow-sm);
}
.plugin-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border);
}
/* Header Card */
.header-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border-secondary);
flex-wrap: wrap;
gap: 1rem;
}
.header-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.header-icon {
color: var(--color-accent);
display: flex;
align-items: center;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
letter-spacing: -0.025em;
}
.header-content p {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
}
.action-button:hover {
background: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.action-button.secondary {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.action-button.secondary:hover {
background: var(--color-surface);
border-color: var(--color-accent);
color: var(--color-accent);
}
.action-button.small {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
}
/* Search Card */
.search-card {
border-radius: var(--radius-lg);
}
.search-container {
padding: 1rem 1.5rem;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 1rem;
color: var(--color-text-secondary);
z-index: 1;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 3rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-elevated);
color: var(--color-text-primary);
font-size: 0.875rem;
transition: var(--transition-fast);
font-family: inherit;
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}
.search-input::placeholder {
color: var(--color-text-secondary);
}
.clear-button {
position: absolute;
right: 0.5rem;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: var(--radius-md);
transition: var(--transition-fast);
display: flex;
align-items: center;
}
.clear-button:hover {
background: var(--color-surface);
color: var(--color-text-primary);
}
/* Notice Card */
.notice-card {
border-radius: var(--radius-lg);
border-color: var(--color-warning);
background: linear-gradient(135deg, rgba(255, 193, 7, 0.1) 0%, var(--color-surface) 100%);
}
.notice-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
}
.notice-icon {
color: var(--color-warning);
flex-shrink: 0;
}
.notice-text {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
/* Loading */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 100;
border-radius: var(--radius-xl);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: white;
font-size: 0.875rem;
font-weight: 500;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Plugin Grid */
.plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
align-items: start;
}
/* Plugin Item Card */
.plugin-item-card {
position: relative;
border-radius: var(--radius-lg);
padding: 1.5rem;
transition: var(--transition-medium);
display: flex;
flex-direction: column;
min-height: 200px;
height: auto;
}
.plugin-item-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.plugin-item-card.disabled {
opacity: 0.7;
}
/* Plugin Badges */
.cli-badge,
.update-badge {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: var(--radius-sm);
z-index: 1;
}
.cli-badge {
background: var(--color-info);
color: var(--color-blue-common);
}
.update-badge {
background: var(--color-success);
color: white;
right: 3.5rem;
}
/* Plugin Header */
.plugin-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.plugin-logo {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--color-border-secondary);
}
.plugin-info {
flex: 1;
min-width: 0;
}
.plugin-name {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 0.25rem 0;
cursor: pointer;
transition: var(--transition-fast);
display: flex;
align-items: center;
gap: 0.5rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.plugin-name:hover {
color: var(--color-accent);
}
.plugin-version {
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-secondary);
background: var(--color-surface-elevated);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
}
.plugin-author {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
/* Plugin Description */
.plugin-description {
margin-bottom: 1.5rem;
flex: 1;
display: flex;
align-items: flex-start;
}
.plugin-description p {
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.5;
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.6rem;
}
/* Plugin Actions */
.plugin-actions {
margin-top: auto;
padding-top: 1rem;
}
.plugin-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
}
.plugin-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.install-button {
background: var(--color-success);
color: white;
}
.install-button:hover:not(:disabled) {
transform: translateY(-1px);
}
.installing-button,
.processing-button {
background: var(--color-surface-elevated);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.installed-button {
background: var(--color-success-light);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.settings-button {
background: var(--color-accent);
color: white;
}
.settings-button:hover:not(:disabled) {
background: var(--color-accent-hover);
transform: translateY(-1px);
}
.disabled-button {
background: var(--color-surface-elevated);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.disabled-button:hover:not(:disabled) {
background: var(--color-surface);
border-color: var(--color-warning);
color: var(--color-warning);
}
/* Button Spinner */
.button-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Empty State */
.empty-state {
border-radius: var(--radius-lg);
padding: 3rem 2rem;
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.empty-icon {
color: var(--color-text-secondary);
opacity: 0.5;
}
.empty-content h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.empty-content p {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0;
max-width: 400px;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 70vw;
max-height: 80vh;
width: 100%;
margin: 2rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 1.5rem 0 1.5rem;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: var(--transition-fast);
}
.modal-close:hover {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
}
.modal-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.modal-content::-webkit-scrollbar {
width: 6px;
}
.modal-content::-webkit-scrollbar-track {
background: var(--color-surface-elevated);
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid var(--color-border-secondary);
}
/* Buttons */
.btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-primary {
background: var(--color-accent);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-surface);
border-color: var(--color-accent);
}
/* Transitions */
.notice-enter-active,
.notice-leave-active {
transition: all var(--transition-medium);
}
.notice-enter-from,
.notice-leave-to {
opacity: 0;
transform: translateY(-1rem);
}
.modal-enter-active,
.modal-leave-active {
transition: all var(--transition-medium);
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
transform: scale(0.95);
}
/* Responsive Design */
@media (max-width: 768px) {
.plugin-container {
padding: 0.75rem;
gap: 1rem;
}
.header-card .card-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.header-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.plugin-grid {
grid-template-columns: 1fr;
}
.modal-container {
max-width: 95vw;
margin: 1rem;
}
.action-button {
min-width: 120px;
justify-content: center;
}
}
@media (max-width: 480px) {
.plugin-container {
padding: 0.5rem;
}
.plugin-item-card {
padding: 1rem;
}
.header-actions {
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.action-button {
width: 100%;
justify-content: center;
}
}
/* Dark mode adjustments */
:root.dark .plugin-container,
:root.auto.dark .plugin-container {
background: var(--color-background-secondary);
}
:root.dark .plugin-card,
:root.auto.dark .plugin-card {
background: var(--color-surface);
border-color: var(--color-border-secondary);
}
:root.dark .search-input,
:root.auto.dark .search-input {
background: var(--color-surface-elevated);
border-color: var(--color-border);
color: var(--color-text-primary);
}
:root.dark .search-input:focus,
:root.auto.dark .search-input:focus {
border-color: var(--color-accent);
}
:root.dark .modal-container,
:root.auto.dark .modal-container {
background: var(--color-surface);
border-color: var(--color-border);
}
:root.dark .btn-secondary,
:root.auto.dark .btn-secondary {
background: var(--color-surface-elevated);
border-color: var(--color-border);
}
:root.dark .btn-secondary:hover,
:root.auto.dark .btn-secondary:hover {
background: var(--color-surface);
border-color: var(--color-accent);
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Focus styles for keyboard navigation */
.action-button:focus-visible,
.plugin-button:focus-visible,
.btn:focus-visible,
.search-input:focus-visible,
.clear-button:focus-visible,
.modal-close:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.plugin-name:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

View File

@@ -1,104 +1,153 @@
<template> <template>
<div id="picbeds-page"> <div id="picbeds-page">
<el-row <div class="page-container">
:gutter="20" <div class="page-content">
class="setting-list" <!-- Header Section -->
> <div class="page-header">
<el-col <div class="header-title-section">
:span="22" <h1
:offset="1" class="page-title"
>
<div class="view-title">
<span
class="view-title-text"
@click="handleNameClick" @click="handleNameClick"
> {{ picBedName }} {{ $t('SETTINGS') }}</span> >
<el-icon> {{ picBedName }} {{ t('pages.picBedConfigs.title') }}
<Link /> </h1>
</el-icon> <button
<el-button class="link-button"
type="primary" :title="t('pages.picBedConfigs.viewDoc')"
round @click="handleNameClick"
size="small" >
style="margin-left: 6px" <ExternalLink :size="20" />
</button>
</div>
<button
class="action-button primary"
@click="handleCopyApi" @click="handleCopyApi"
> >
{{ $t('UPLOAD_PAGE_COPY_UPLOAD_API') }} <Copy :size="20" />
</el-button> {{ t('pages.picBedConfigs.copyAPI') }}
</button>
</div> </div>
<config-form
<!-- Config Form Section -->
<div
v-if="config.length > 0" v-if="config.length > 0"
class="form-section"
>
<config-form
:id="type" :id="type"
ref="$configForm" ref="$configForm"
:config="config" :config="config"
type="uploader" type="uploader"
color-mode="white"
> >
<el-form-item> <!-- Action Buttons -->
<el-button-group> <div class="action-buttons">
<el-button <button
type="info" class="action-button secondary"
round type="button"
@click="handleReset" @click="handleReset"
> >
{{ $t('RESET_PICBED_CONFIG') }} <RotateCcw :size="18" />
</el-button> {{ t('common.reset') }}
<el-button </button>
type="success"
round <button
class="action-button success"
type="button"
@click="handleConfirm" @click="handleConfirm"
> >
{{ $t('CONFIRM') }} <Check :size="18" />
</el-button> {{ t('common.confirm') }}
<el-button </button>
round
type="warning" <div
@mouseenter="handleMouseEnter" v-if="picBedConfigList.length > 0"
@mouseleave="handleMouseLeave" class="dropdown-wrapper"
> >
<el-dropdown <button
ref="$dropdown" class="action-button warning dropdown-trigger"
placement="top" type="button"
style="color: #fff; font-size: 12px; width: 100%" @click="toggleDropdown"
:disabled="picBedConfigList.length === 0" @blur="handleDropdownBlur"
teleported
> >
{{ $t('MANAGE_LOGIN_PAGE_PANE_IMPORT') }} <Import :size="18" />
<template #dropdown> {{ t('common.import') }}
<el-dropdown-item <svg
v-for="i in picBedConfigList" class="dropdown-arrow"
:key="i._id" :class="{ 'dropdown-arrow-up': true }"
@click="handleConfigImport(i)" width="12"
height="8"
viewBox="0 0 12 8"
fill="none"
> >
{{ i._configName }} <path
</el-dropdown-item> d="M1 1.5L6 6.5L11 1.5"
</template> stroke="currentColor"
</el-dropdown> stroke-width="1.5"
</el-button> stroke-linecap="round"
</el-button-group> stroke-linejoin="round"
</el-form-item> />
</svg>
</button>
<transition name="dropdown">
<div
v-show="dropdownVisible"
class="dropdown-menu"
:class="{ 'dropdown-up': true }"
>
<button
v-for="item in picBedConfigList"
:key="item._id"
class="dropdown-item"
@click="handleConfigImport(item)"
>
{{ item._configName }}
</button>
</div>
</transition>
</div>
</div>
</config-form> </config-form>
</div>
<!-- Empty State -->
<div <div
v-else v-else
class="single" class="empty-state"
> >
<div class="notice"> <div class="empty-content">
{{ $t('SETTINGS_NOT_CONFIG_OPTIONS') }} <FolderOpen
class="empty-icon"
:size="48"
/>
<h3>{{ t('pages.picBedConfigs.noConfigOptions') }}</h3>
</div> </div>
</div> </div>
</el-col> </div>
</el-row> </div>
<!-- Loading Overlay -->
<div
v-if="loading"
class="loading-overlay"
>
<div class="loading-spinner" />
<span class="loading-text">Loading...</span>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Link } from '@element-plus/icons-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ElDropdown, ElMessage } from 'element-plus' import { Check, Copy, ExternalLink, FolderOpen, Import, RotateCcw } from 'lucide-vue-next'
import { onBeforeMount, ref } from 'vue' import { onBeforeMount, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ConfigForm from '@/components/ConfigForm.vue' import ConfigForm from '@/components/UnifiedConfigForm.vue'
import useMessage from '@/hooks/useMessage'
import { getRawData } from '@/utils/common'
import { configPaths } from '@/utils/configPaths' import { configPaths } from '@/utils/configPaths'
import { getConfig } from '@/utils/dataSender' import { getConfig } from '@/utils/dataSender'
import { II18nLanguage, IRPCActionType } from '@/utils/enum' import { II18nLanguage, IRPCActionType } from '@/utils/enum'
@@ -106,56 +155,84 @@ import { picBedManualUrlList } from '@/utils/static'
import type { IPicGoPluginConfig, IStringKeyMap, IUploaderConfigItem, IUploaderConfigListItem } from '#/types/types' import type { IPicGoPluginConfig, IStringKeyMap, IUploaderConfigItem, IUploaderConfigListItem } from '#/types/types'
const { t } = useI18n() const { t } = useI18n()
const message = useMessage()
const type = ref('') const type = ref('')
const config = ref<IPicGoPluginConfig[]>([]) const config = ref<IPicGoPluginConfig[]>([])
const picBedConfigList = ref<IUploaderConfigListItem[]>([]) const picBedConfigList = ref<IUploaderConfigListItem[]>([])
const picBedName = ref('') const picBedName = ref('')
const loading = ref(false)
const dropdownVisible = ref(false)
const $route = useRoute() const $route = useRoute()
const $router = useRouter() const $router = useRouter()
const $configForm = ref<InstanceType<typeof ConfigForm> | null>(null) const $configForm = ref<InstanceType<typeof ConfigForm> | null>(null)
const $dropdown = ref<InstanceType<typeof ElDropdown> | null>(null)
type.value = $route.params.type as string type.value = $route.params.type as string
onBeforeMount(async () => { onBeforeMount(async () => {
loading.value = true
try {
await getPicBeds() await getPicBeds()
await getPicBedConfigList() await getPicBedConfigList()
} finally {
loading.value = false
}
}) })
function toggleDropdown (_: Event) {
dropdownVisible.value = !dropdownVisible.value
}
function handleDropdownBlur () {
setTimeout(() => {
dropdownVisible.value = false
}, 200)
}
const handleConfirm = async () => { const handleConfirm = async () => {
loading.value = true
try {
const result = (await $configForm.value?.validate()) || false const result = (await $configForm.value?.validate()) || false
if (result !== false) { if (result !== false) {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_UPDATE_CONFIG, type.value, result?._id, result) const rawResult = getRawData(result)
const successNotification = new Notification(t('SETTINGS_RESULT'), { await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_UPDATE_CONFIG, type.value, rawResult?._id, rawResult)
body: t('TIPS_SET_SUCCEED') message.success(t('pages.picBedConfigs.setSuccess'))
})
successNotification.onclick = () => {
return true
}
$router.back() $router.back()
} else {
message.error(t('pages.picBedConfigs.setFailedInfo'))
}
} catch (error) {
console.error('Failed to save configuration:', error)
message.error(t('pages.picBedConfigs.setFailedInfo'))
} finally {
loading.value = false
} }
}
function handleMouseEnter () {
$dropdown.value?.handleOpen()
}
function handleMouseLeave () {
$dropdown.value?.handleClose()
} }
async function getPicBeds () { async function getPicBeds () {
try {
const result = await window.electron.triggerRPC<any>(IRPCActionType.PICBED_GET_PICBED_CONFIG, $route.params.type) const result = await window.electron.triggerRPC<any>(IRPCActionType.PICBED_GET_PICBED_CONFIG, $route.params.type)
config.value = result.config config.value = result.config
picBedName.value = result.name picBedName.value = result.name
} catch (error) {
console.error('Failed to get picbed config:', error)
message.error(t('pages.picBedConfigs.loadConfigFailed'))
}
} }
async function getPicBedConfigList () { async function getPicBedConfigList () {
try {
const res = (await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)) || undefined const res = (await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)) || undefined
const configList = res?.configList || [] const configList = res?.configList || []
picBedConfigList.value = configList.filter(item => item._id !== $route.params.configId) picBedConfigList.value = configList.filter(item => item._id !== $route.params.configId)
} catch (error) {
console.error('Failed to get config list:', error)
message.error(t('pages.picBedConfigs.loadPicBedListFailed'))
}
} }
async function handleConfigImport (configItem: IUploaderConfigListItem) { async function handleConfigImport (configItem: IUploaderConfigListItem) {
try {
const { _id, _configName, _updatedAt, _createdAt, ...rest } = configItem const { _id, _configName, _updatedAt, _createdAt, ...rest } = configItem
for (const key in rest) { for (const key in rest) {
if (Object.prototype.hasOwnProperty.call(rest, key)) { if (Object.prototype.hasOwnProperty.call(rest, key)) {
@@ -164,25 +241,41 @@ async function handleConfigImport (configItem: IUploaderConfigListItem) {
} }
} }
$configForm.value?.updateRuleForm('_configName', dayjs().format('YYYYMMDDHHmmss')) $configForm.value?.updateRuleForm('_configName', dayjs().format('YYYYMMDDHHmmss'))
dropdownVisible.value = false
message.success(t('pages.picBedConfigs.importConfigSuccess'))
} catch (error) {
console.error('Failed to import configuration:', error)
message.error(t('pages.picBedConfigs.importConfigFailed'))
}
} }
const handleReset = async () => { const handleReset = async () => {
loading.value = true
try {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_RESET_CONFIG, type.value, $route.params.configId) await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_RESET_CONFIG, type.value, $route.params.configId)
const successNotification = new Notification(t('SETTINGS_RESULT'), { message.success(t('pages.picBedConfigs.resetSuccess'))
body: t('TIPS_RESET_SUCCEED') setTimeout(() => {
})
successNotification.onclick = () => {
return true
}
$router.back() $router.back()
}, 1000)
} catch (error) {
console.error('Failed to reset configuration:', error)
message.error(t('pages.picBedConfigs.resetFailed'))
} finally {
loading.value = false
}
} }
async function handleNameClick () { async function handleNameClick () {
try {
const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN
const url = picBedManualUrlList[lang === II18nLanguage.EN ? 'en' : 'zh_cn'][$route.params.type as string] const url = picBedManualUrlList[lang === II18nLanguage.EN ? 'en' : 'zh_cn'][$route.params.type as string]
if (url) { if (url) {
window.electron.sendRPC(IRPCActionType.OPEN_URL, url) window.electron.sendRPC(IRPCActionType.OPEN_URL, url)
} }
} catch (error) {
console.error('Failed to open documentation:', error)
message.error(t('pages.picBedConfigs.viewDocFailed'))
}
} }
async function handleCopyApi () { async function handleCopyApi () {
@@ -192,16 +285,24 @@ async function handleCopyApi () {
const uploader = ((await getConfig(configPaths.uploader)) as IStringKeyMap) || {} const uploader = ((await getConfig(configPaths.uploader)) as IStringKeyMap) || {}
const picBedConfigList = uploader[$route.params.type as string].configList || [] const picBedConfigList = uploader[$route.params.type as string].configList || []
const picBedConfig = picBedConfigList.find((item: IUploaderConfigListItem) => item._id === $route.params.configId) const picBedConfig = picBedConfigList.find((item: IUploaderConfigListItem) => item._id === $route.params.configId)
if (!picBedConfig) { if (!picBedConfig) {
ElMessage.error('No config found') message.error(t('pages.picBedConfigs.noConfigs'))
return return
} }
const apiUrl = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}/upload?picbed=${$route.params.type}&configName=${picBedConfig._configName}${serverKey ? `&key=${serverKey}` : ''}` const apiUrl = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}/upload?picbed=${$route.params.type}&configName=${picBedConfig._configName}${serverKey ? `&key=${serverKey}` : ''}`
try {
window.electron.clipboard.writeText(apiUrl) window.electron.clipboard.writeText(apiUrl)
ElMessage.success(`${t('MANAGE_BUCKET_COPY_SUCCESS')} ${apiUrl}`) message.success(`${t('pages.picBedConfigs.copyAPISucceed')} ${apiUrl}`)
} catch (clipboardError) {
console.error('Failed to copy to clipboard:', clipboardError)
message.error(t('pages.picBedConfigs.copyAPIFailed'))
}
} catch (error) { } catch (error) {
console.log(error) console.error('Failed to generate API URL:', error)
ElMessage.error('Copy failed') message.error(t('pages.picBedConfigs.copyAPIFailed'))
} }
} }
</script> </script>
@@ -212,43 +313,428 @@ export default {
} }
</script> </script>
<style lang="stylus"> <style scoped>
#picbeds-page #picbeds-page {
height 100% min-height: 100vh;
overflow-y auto background: var(--color-background-tertiar);
overflow-x hidden font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
position absolute position: relative;
left 142px }
right 0
.setting-list .page-container {
height 100% max-width: 1000px;
overflow-y auto margin: 0 auto;
overflow-x hidden padding: 2rem;
.view-title-text }
&:hover
cursor pointer .page-content {
color #409EFF background: rgba(255, 255, 255, 0.95);
.el-form border-radius: 16px;
label backdrop-filter: blur(20px);
line-height 22px box-shadow:
padding-bottom 0 0 8px 16px rgba(0, 0, 0, 0.12),
color #eee 0 2px 12px rgba(0, 0, 0, 0.08);
&-item overflow: auto;
margin-bottom 16px border: 1px solid rgba(255, 255, 255, 0.2);
.el-button-group }
width 100%
.el-button /* Header Section */
width calc(33.3333% - 10px) .page-header {
.el-radio-group display: flex;
margin-left 25px align-items: center;
.el-switch__label justify-content: space-between;
color #eee padding: 2rem 2rem 1.5rem;
&.is-active border-bottom: 1px solid rgba(229, 231, 235, 0.8);
color #409EFF background: linear-gradient(135deg, rgba(255, 255, 255, 0.8) 0%, rgba(248, 250, 252, 0.8) 100%);
.notice }
color #eee
text-align center .header-title-section {
margin-bottom 10px display: flex;
.single align-items: center;
text-align center gap: 1rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #1f2937 0%, #4b5563 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.page-title:hover {
transform: translateY(-1px);
filter: brightness(1.1);
}
.link-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: 8px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.link-button:hover {
background: rgba(59, 130, 246, 0.2);
transform: translateY(-1px);
}
/* Action Buttons */
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
position: relative;
overflow: hidden;
}
.action-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.6s;
}
.action-button:hover::before {
left: 100%;
}
.action-button.primary {
background: var(--color-blue-common);
color: white;
box-shadow: 0 2px 2px rgba(0, 122, 255, 0.3);
}
.action-button.primary:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0, 122, 255, 0.4);
}
.action-button.secondary {
background: var(--color-surface-elevated);
color: #475569;
border: 1px solid #e2e8f0;
}
.action-button.secondary:hover {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.action-button.success {
background: var(--color-success);
color: white;
box-shadow: 0 4px 4px rgba(16, 185, 129, 0.3);
}
.action-button.success:hover {
transform: translateY(-2px);
box-shadow: 0 8px 6px rgba(16, 185, 129, 0.4);
}
.action-button.warning {
background: var(--color-warning);
color: white;
position: relative;
}
.action-button.warning:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3);
}
.dropdown-arrow {
margin-left: 0.5rem;
transition: transform 0.2s ease;
}
.dropdown-arrow-up {
transform: rotate(180deg);
}
.dropdown-trigger:hover .dropdown-arrow:not(.dropdown-arrow-up) {
transform: rotate(180deg);
}
.dropdown-trigger:hover .dropdown-arrow.dropdown-arrow-up {
transform: rotate(0deg);
}
/* Form Section */
.form-section {
padding: 2rem;
}
.action-buttons {
display: flex;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
/* Dropdown */
.dropdown-wrapper {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.5rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 1000;
max-height: 300px;
overflow-y: auto;
}
.dropdown-menu.dropdown-up {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.5rem;
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.dropdown-item:hover {
background: #f3f4f6;
color: #007aff;
}
.dropdown-item:last-child {
border-bottom: none;
}
/* Empty State */
.empty-state {
padding: 4rem 2rem;
text-align: center;
}
.empty-content {
max-width: 400px;
margin: 0 auto;
}
.empty-icon {
color: #9ca3af;
margin-bottom: 1.5rem;
}
.empty-content h3 {
font-size: 1.25rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.5rem;
}
.empty-content p {
color: #6b7280;
margin: 0;
line-height: 1.6;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 2000;
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: white;
font-size: 0.875rem;
font-weight: 500;
}
/* Transitions */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-0.5rem);
}
.dropdown-up.dropdown-enter-from,
.dropdown-up.dropdown-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
/* Animations */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dark mode adjustments */
:root.dark #picbeds-page,
:root.auto.dark #picbeds-page {
background: var(--color-background-tertiar);
}
:root.dark .page-content,
:root.auto.dark .page-content {
background: var(--color-surface-elevated);
border-color: rgba(75, 85, 99, 0.3);
}
:root.dark .page-header,
:root.auto.dark .page-header {
background: var(--color-surface-elevated);
border-color: rgba(75, 85, 99, 0.3);
}
:root.dark .page-title,
:root.auto.dark .page-title {
background: linear-gradient(135deg, #f9fafb 0%, #d1d5db 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
:root.dark .dropdown-menu,
:root.auto.dark .dropdown-menu {
background: #374151;
border-color: #4b5563;
}
:root.dark .dropdown-menu.dropdown-up,
:root.auto.dark .dropdown-menu.dropdown-up {
background: #374151;
border-color: #4b5563;
}
:root.dark .dropdown-item,
:root.auto.dark .dropdown-item {
color: #f9fafb;
}
:root.dark .dropdown-item:hover,
:root.auto.dark .dropdown-item:hover {
background: #4b5563;
color: #60a5fa;
}
/* Responsive Design */
@media (max-width: 768px) {
.page-container {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 1.5rem;
}
.header-title-section {
width: 100%;
}
.action-buttons {
flex-direction: column;
gap: 0.75rem;
}
.action-button {
width: 100%;
justify-content: center;
}
.toast {
right: 1rem;
left: 1rem;
top: 1rem;
min-width: auto;
}
}
@media (max-width: 480px) {
.page-container {
padding: 0.5rem;
}
.form-section {
padding: 1.5rem;
}
.page-header {
padding: 1rem;
}
}
/* Focus styles for accessibility */
.action-button:focus-visible,
.link-button:focus-visible,
.dropdown-item:focus-visible {
outline: 2px solid #007aff;
outline-offset: 2px;
}
</style> </style>

View File

@@ -5809,6 +5809,13 @@ electron-builder@^26.0.12:
simple-update-notifier "2.0.0" simple-update-notifier "2.0.0"
yargs "^17.6.2" yargs "^17.6.2"
electron-devtools-installer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-4.0.0.tgz#6556a6a326cddea18194cb6d97d85c8ae329dedf"
integrity sha512-9Tntu/jtfSn0n6N/ZI6IdvRqXpDyLQiDuuIbsBI+dL+1Ef7C8J2JwByw58P3TJiNeuqyV3ZkphpNWuZK5iSY2w==
dependencies:
unzip-crx-3 "^0.2.0"
electron-publish@26.0.11: electron-publish@26.0.11:
version "26.0.11" version "26.0.11"
resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-26.0.11.tgz#92c9329a101af2836d9d228c82966eca1eee9a7b" resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-26.0.11.tgz#92c9329a101af2836d9d228c82966eca1eee9a7b"
@@ -7504,6 +7511,11 @@ image-size@^2.0.2:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-2.0.2.tgz#84a7b43704db5736f364bf0d1b029821299b4bdc" resolved "https://registry.yarnpkg.com/image-size/-/image-size-2.0.2.tgz#84a7b43704db5736f364bf0d1b029821299b4bdc"
integrity sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w== integrity sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0: import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -8058,6 +8070,16 @@ jstoxml@^2.0.0:
resolved "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz#2eebd5e55383fe66a375022ca0aa88f77bc4fb84" resolved "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz#2eebd5e55383fe66a375022ca0aa88f77bc4fb84"
integrity sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw== integrity sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==
jszip@^3.1.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==
dependencies:
lie "~3.3.0"
pako "~1.0.2"
readable-stream "~2.3.6"
setimmediate "^1.0.5"
keycode@2.2.0: keycode@2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04" resolved "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
@@ -8107,6 +8129,13 @@ libheif-js@^1.19.8:
resolved "https://registry.yarnpkg.com/libheif-js/-/libheif-js-1.19.8.tgz#fcbf3571ef28b6199dd052bc4d2cb7cce56ddf06" resolved "https://registry.yarnpkg.com/libheif-js/-/libheif-js-1.19.8.tgz#fcbf3571ef28b6199dd052bc4d2cb7cce56ddf06"
integrity sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ== integrity sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
dependencies:
immediate "~3.0.5"
lines-and-columns@^1.1.6: lines-and-columns@^1.1.6:
version "1.2.4" version "1.2.4"
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
@@ -9257,6 +9286,11 @@ package-json-from-dist@^1.0.0:
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
pako@~1.0.2:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
parent-module@^1.0.0: parent-module@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -9723,7 +9757,7 @@ read-binary-file-arch@^1.0.6:
dependencies: dependencies:
debug "^4.3.4" debug "^4.3.4"
readable-stream@^2.0.0, readable-stream@^2.3.0, readable-stream@^2.3.5: readable-stream@^2.0.0, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@~2.3.6:
version "2.3.8" version "2.3.8"
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b"
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
@@ -10172,6 +10206,11 @@ serialize-error@^7.0.1:
dependencies: dependencies:
type-fest "^0.13.1" type-fest "^0.13.1"
setimmediate@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
sharp@^0.34.3: sharp@^0.34.3:
version "0.34.3" version "0.34.3"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.3.tgz#10a03bcd15fb72f16355461af0b9245ccb8a5da3" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.3.tgz#10a03bcd15fb72f16355461af0b9245ccb8a5da3"
@@ -11156,6 +11195,15 @@ unrs-resolver@^1.9.2:
"@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1"
"@unrs/resolver-binding-win32-x64-msvc" "1.11.1" "@unrs/resolver-binding-win32-x64-msvc" "1.11.1"
unzip-crx-3@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/unzip-crx-3/-/unzip-crx-3-0.2.0.tgz#d5324147b104a8aed9ae8639c95521f6f7cda292"
integrity sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ==
dependencies:
jszip "^3.1.0"
mkdirp "^0.5.1"
yaku "^0.16.6"
update-browserslist-db@^1.1.3: update-browserslist-db@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
@@ -11657,6 +11705,11 @@ y18n@^5.0.5:
resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yaku@^0.16.6:
version "0.16.7"
resolved "https://registry.yarnpkg.com/yaku/-/yaku-0.16.7.tgz#1d195c78aa9b5bf8479c895b9504fd4f0847984e"
integrity sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw==
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"