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 () => {
if (process.env.NODE_ENV !== 'production') {
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)
setwin?.webContents?.openDevTools({ mode: 'detach' })
}
windowManager.create(IWindowList.TRAY_WINDOW) windowManager.create(IWindowList.TRAY_WINDOW)
windowManager.create(IWindowList.SETTING_WINDOW) windowManager.create(IWindowList.SETTING_WINDOW)
const setwin = windowManager.get(IWindowList.SETTING_WINDOW)
setwin?.webContents?.openDevTools({ mode: 'detach' })
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 @click="handleInputBoxCancel"
> >
<el-input <div
v-model="inputBoxValue" class="inputbox-container"
:placeholder="inputBoxOptions.placeholder" @click.stop
/>
<template #footer>
<el-button
round
@click="handleInputBoxCancel"
> >
{{ $t('CANCEL') }} <div class="inputbox-header">
</el-button> <h3 class="inputbox-title">
<el-button {{ inputBoxOptions.title || t('pages.inputBox.title') }}
type="primary" </h3>
round <button
@click="handleInputBoxConfirm" class="inputbox-close"
> @click="handleInputBoxCancel"
{{ $t('CONFIRM') }} >
</el-button> <X :size="20" />
</template> </button>
</el-dialog> </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"
>
{{ t('common.confirm') }}
</button>
</div>
</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,8 +6,11 @@ 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) {
e.preventDefault() // avoid opening localhost development URLs in external browser
window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href) if (!e.target.href.startsWith('http://localhost:3000')) {
e.preventDefault()
window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href)
}
} }
} }
} }

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
>
<el-icon
class="el-icon-goods"
@click="goAwesomeList"
>
<Goods />
</el-icon>
</el-tooltip>
<el-tooltip
: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"
>
<Download />
</el-icon>
</el-tooltip>
</div>
<el-row
class="handle-bar"
:class="{ 'cut-width': pluginList.length > 6 }"
>
<el-input
v-model="searchText"
:placeholder="$t('PLUGIN_SEARCH_PLACEHOLDER')"
size="small"
>
<template #suffix>
<el-icon
class="el-input__icon"
style="cursor: pointer"
@click="cleanSearch"
>
<close />
</el-icon>
</template>
</el-input>
</el-row>
<el-row
id="pluginList"
v-loading="loading"
:gutter="10"
class="plugin-list"
>
<el-col
v-for="item in pluginList"
:key="item.fullName"
class="plugin-item__container"
:xs="24"
: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' }"
>
<div
v-if="!item.gui"
class="cli-only-badge"
title="CLI only"
>
CLI
</div> </div>
<img <div>
class="plugin-item__logo" <h1>{{ t('pages.plugin.title') }}</h1>
:src="item.logo" <p>{{ t('pages.plugin.description') }}</p>
:onerror="defaultLogo"
>
<div
class="plugin-item__content"
:class="{ disabled: !item.enabled }"
>
<div
class="plugin-item__name"
@click="openHomepage(item.homepage)"
>
{{ item.name }} <small>{{ ' ' + item.version }}</small> &nbsp;
<!-- 升级提示 -->
<el-tag
v-if="latestVersionMap[item.fullName] && latestVersionMap[item.fullName] !== item.version"
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(/<.*>/, '') }}
</span>
<span class="plugin-item__config">
<template v-if="searchText">
<template v-if="!item.hasInstall">
<span
v-if="!item.ing"
class="config-button install"
@click="installPlugin(item)"
>
{{ $t('PLUGIN_INSTALL') }}
</span>
<span
v-else-if="item.ing"
class="config-button ing"
>
{{ $t('PLUGIN_INSTALLING') }}
</span>
</template>
<span
v-else
class="config-button ing"
>
{{ $t('PLUGIN_INSTALLED') }}
</span>
</template>
<template v-else>
<span
v-if="item.ing"
class="config-button ing"
>
{{ $t('PLUGIN_DOING_SOMETHING') }}
</span>
<template v-else>
<el-icon
v-if="item.enabled"
class="el-icon-setting"
@click="buildContextMenu(item)"
>
<Tools />
</el-icon>
<el-icon
v-else
class="el-icon-remove-outline"
@click="buildContextMenu(item)"
>
<Remove />
</el-icon>
</template>
</template>
</span>
</div>
</div> </div>
</div> </div>
</el-col> <div class="header-actions">
</el-row> <button
<el-row class="action-button secondary"
v-show="needReload" :title="t('pages.plugin.importLocal')"
class="reload-mask" @click="handleImportLocalPlugin"
:class="{ 'cut-width': pluginList.length > 6 }" >
justify="center" <DownloadIcon :size="16" />
> {{ t('pages.plugin.importLocal') }}
<el-button </button>
type="primary" <button
size="small" class="action-button secondary"
round :title="t('pages.plugin.updateAll')"
@click="reloadApp" @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>
</div>
<!-- Search Card -->
<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"
type="text"
class="search-input"
:placeholder="t('pages.plugin.searchPlaceholder')"
>
<button
v-if="searchText"
class="clear-button"
@click="cleanSearch"
>
<XIcon :size="16" />
</button>
</div>
</div>
</div>
<!-- Reload Notice -->
<transition name="notice">
<div
v-if="needReload"
class="plugin-card notice-card"
> >
{{ $t('TIPS_NEED_RELOAD') }} <div class="notice-content">
</el-button> <AlertCircleIcon
</el-row> class="notice-icon"
<el-dialog :size="20"
v-model="dialogVisible" />
:modal-append-to-body="false" <span class="notice-text">{{ t('pages.plugin.needRestart') }}</span>
:title=" <button
$t('CONFIG_THING', { class="action-button small"
c: configName @click="reloadApp"
}) >
" {{ t('pages.plugin.restartApp') }}
width="70%" </button>
append-to-body </div>
</div>
</transition>
<!-- Loading Overlay -->
<div
v-if="loading"
class="loading-overlay"
> >
<config-form <div class="loading-spinner" />
:id="configName" <span class="loading-text">{{ t('pages.plugin.loading') }}</span>
ref="$configForm" </div>
:config="config"
:type="currentType" <!-- Plugin Grid -->
color-mode="white" <div class="plugin-grid">
/> <div
<template #footer> v-for="item in pluginList"
<el-button :key="item.fullName"
round class="plugin-card plugin-item-card"
@click="dialogVisible = false" :class="{ disabled: !item.enabled && !searchText }"
>
<!-- Plugin Badge -->
<div
v-if="!item.gui"
class="cli-badge"
> >
{{ $t('CANCEL') }} CLI
</el-button> </div>
<el-button
type="primary" <!-- Update Badge -->
round <div
@click="handleConfirmConfig" v-if="latestVersionMap[item.fullName] && latestVersionMap[item.fullName] !== item.version"
class="update-badge"
> >
{{ $t('CONFIRM') }} NEW
</el-button> </div>
</template>
</el-dialog> <!-- Plugin Header -->
<div class="plugin-header">
<img
class="plugin-logo"
:src="item.logo"
:onerror="defaultLogo"
alt=""
>
<div class="plugin-info">
<h3
class="plugin-name"
@click="openHomepage(item.homepage)"
>
{{ item.name }}
<span class="plugin-version">v{{ item.version }}</span>
</h3>
<p class="plugin-author">
{{ item.author.replace(/<.*>/, '') }}
</p>
</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="!item.hasInstall">
<button
v-if="!item.ing"
class="plugin-button install-button"
@click="installPlugin(item)"
>
<DownloadIcon :size="16" />
{{ t('pages.plugin.install') }}
</button>
<button
v-else
class="plugin-button installing-button"
disabled
>
<div class="button-spinner" />
{{ 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 v-else>
<button
v-if="item.ing"
class="plugin-button processing-button"
disabled
>
<div class="button-spinner" />
{{ t('pages.plugin.doingSomething') }}
</button>
<template v-else>
<button
v-if="item.enabled"
class="plugin-button settings-button"
@click="buildContextMenu(item)"
>
<SettingsIcon :size="16" />
{{ t('pages.plugin.settings') }}
</button>
<button
v-else
class="plugin-button disabled-button"
@click="buildContextMenu(item)"
>
<XCircleIcon :size="16" />
{{ t('pages.plugin.disabled') }}
</button>
</template>
</template>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="!loading && pluginList.length === 0"
class="plugin-card empty-state"
>
<div class="empty-content">
<PackageIcon
class="empty-icon"
:size="48"
/>
<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"
>
<ExternalLinkIcon :size="16" />
{{ t('pages.plugin.browsePlugins') }}
</button>
</div>
</div>
<!-- Config Modal -->
<transition name="modal">
<div
v-if="dialogVisible"
class="modal-overlay"
@click="dialogVisible = false"
>
<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
:id="configName"
ref="$configForm"
:config="config"
:type="currentType"
color-mode="white"
mode="plugin"
:show-tooltips="false"
/>
</div>
<div class="modal-footer">
<button
class="btn btn-secondary"
@click="dialogVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
class="btn btn-primary"
@click="handleConfirmConfig"
>
{{ t('common.confirm') }}
</button>
</div>
</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'), item.ing = true
cancelButtonText: t('CANCEL'), window.electron.sendRPC(IRPCActionType.PLUGIN_INSTALL, item.fullName)
type: 'warning' }
})
.then(() => {
item.ing = true
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,11 +520,13 @@ 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 = () => { })
reloadApp() successNotification.onclick = () => {
reloadApp()
}
} }
} }
@@ -500,11 +554,13 @@ async function handleConfirmConfig () {
}) })
break break
} }
const successNotification = new Notification(t('SETTINGS_RESULT'), { if ('Notification' in window) {
body: t('TIPS_SET_SUCCEED') const successNotification = new Notification(t('SETTINGS_RESULT'), {
}) body: t('pages.plugin.setSuccess')
successNotification.onclick = () => { })
return true successNotification.onclick = () => {
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" v-model="form.fileName"
:rules="[{ required: true, message: 'file name is required', trigger: 'blur' }]" type="text"
> class="form-input"
<el-input :class="{ 'input-error': validationError }"
v-model="form.fileName" :placeholder="t('pages.rename.placeholder')"
size="small" 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"
>
<XIcon :size="16" />
</button>
</div>
<div
v-if="validationError"
class="validation-error"
> >
<close /> {{ validationError }}
</el-icon> </div>
</template> </div>
</el-input> </div>
</el-form-item>
</el-form> <!-- Actions -->
<el-row> <div class="form-actions">
<div class="pull-right"> <button
<el-button type="button"
round class="btn btn-secondary"
size="small" @click="cancel"
@click="cancel" >
> {{ $t('common.cancel') }}
{{ $t('CANCEL') }} </button>
</el-button> <button
<el-button type="submit"
type="primary" class="btn btn-primary"
round :disabled="!form.fileName.trim()"
size="small" >
@click="confirmName" {{ $t('common.confirm') }}
> </button>
{{ $t('CONFIRM') }} </div>
</el-button> </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 validateFileName (fileName: string): string {
if (!fileName.trim()) {
return 'File name is required'
}
const invalidChars = /[<>:"/\\|?*]/g
if (invalidChars.test(fileName)) {
return 'File name contains invalid characters'
}
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
if (reservedNames.test(fileName.trim())) {
return 'This is a reserved file name'
}
return ''
}
function confirmName () { function confirmName () {
formRef.value?.validate(valid => { const error = validateFileName(form.fileName)
if (valid) { if (error) {
window.electron.sendToMain(`${RENAME_FILE_NAME}${id.value}`, form.fileName) 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>
</div> </div>
<el-row>
<el-col <!-- Shortcuts Table Card -->
:span="20" <div class="shortkey-card">
:offset="2" <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"
>
<td class="name-cell">
<div class="shortcut-name">
{{ item.label ? item.label : item.name }}
</div>
</td>
<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 }"
>
{{ item.enable ? t('pages.shortKey.enabled') : t('pages.shortKey.disabled') }}
</span>
</td>
<td class="source-cell">
<span class="source-name">{{ calcOriginShowName(item.from || '') }}</span>
</td>
<td class="actions-cell">
<div class="action-buttons">
<button
class="btn btn-sm"
:class="item.enable ? 'btn-danger' : 'btn-success'"
@click="toggleEnable(item)"
>
{{ item.enable ? t('pages.shortKey.disable') : t('pages.shortKey.enable') }}
</button>
<button
class="btn btn-sm btn-secondary"
@click="openKeyBindingDialog(item, index)"
>
<Edit :size="14" />
{{ t('pages.shortKey.edit') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Key Binding Modal -->
<transition name="modal">
<div
v-if="keyBindingVisible"
class="modal-overlay"
@click.self="cancelKeyBinding"
> >
<el-table <div class="modal-content">
class="shortcut-page-table-border" <div class="modal-header">
:data="list" <h3 class="modal-title">
size="small" {{ t('pages.shortKey.changeUpload') }}
header-cell-class-name="shortcut-page-table-border" </h3>
cell-class-name="shortcut-page-table-border" <button
> class="modal-close"
<el-table-column :label="$t('SHORTCUT_NAME')"> @click="cancelKeyBinding"
<template #default="scope"> >
{{ scope.row.label ? scope.row.label : scope.row.name }} <XIcon :size="20" />
</template> </button>
</el-table-column> </div>
<el-table-column <div class="modal-body">
width="160px" <div class="form-group">
:label="$t('SHORTCUT_BIND')" <label>{{ t('pages.shortKey.keyBinding') }}</label>
prop="key" <input
/> v-model="shortKey"
<el-table-column :label="$t('SHORTCUT_STATUS')"> class="form-input key-input"
<template #default="scope"> :placeholder="t('pages.shortKey.pressKeys')"
<el-tag readonly
size="small" @keydown.prevent="keyDetect($event as KeyboardEvent)"
:type="scope.row.enable ? 'success' : 'danger'"
> >
{{ scope.row.enable ? $t('SHORTCUT_ENABLED') : $t('SHORTCUT_DISABLED') }} <div class="input-hint">
</el-tag> {{ t('pages.shortKey.pressHint') }}
</template> </div>
</el-table-column> </div>
<el-table-column </div>
:label="$t('SHORTCUT_SOURCE')" <div class="modal-footer">
width="100px" <button
> class="btn btn-secondary"
<template #default="scope"> @click="cancelKeyBinding"
{{ calcOriginShowName(scope.row.from) }} >
</template> {{ $t('CANCEL') }}
</el-table-column> </button>
<el-table-column <button
:label="$t('SHORTCUT_HANDLE')" class="btn btn-primary"
width="100px" @click="confirmKeyBinding"
> >
<template #default="scope"> {{ $t('common.confirm') }}
<el-row> </button>
<el-button </div>
size="small" </div>
:class="{ </div>
disabled: scope.row.enable </transition>
}"
type="info"
:link="true"
@click="toggleEnable(scope.row)"
>
{{ scope.row.enable ? $t('SHORTCUT_DISABLE') : $t('SHORTCUT_ENABLE') }}
</el-button>
<el-button
class="edit"
size="small"
type="info"
:link="true"
@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"
class="align-center"
:autofocus="true"
@keydown.prevent="keyDetect($event as KeyboardEvent)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button
round
@click="cancelKeyBinding"
>
{{ $t('CANCEL') }}
</el-button>
<el-button
type="primary"
round
@click="confirmKeyBinding"
>
{{ $t('CONFIRM') }}
</el-button>
</template>
</el-dialog>
</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,102 +1,95 @@
<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> </div>
<el-row
:gutter="15" <!-- Config Items Card -->
justify="space-between" <div class="config-card main-card">
align="middle" <div class="config-grid">
type="flex"
class="config-list"
>
<el-col
v-for="item in curConfigList"
: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 <div
v-for="item in curConfigList"
:key="item._id"
: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-name"> <div class="config-content">
{{ item._configName }} <div class="config-name">
</div> {{ item._configName }}
<div class="config-update-time"> </div>
{{ formatTime(item._updatedAt) }} <div class="config-update-time">
</div> {{ formatTime(item._updatedAt) }}
<div </div>
v-if="defaultConfigId === item._id" <div
class="default-text" v-if="defaultConfigId === item._id"
> class="default-badge"
{{ $t('SELECTED_SETTING_HINT') }}
</div>
<div class="operation-container">
<el-icon
class="el-icon-edit"
@click="openEditPage(item._id)"
> >
<Edit /> {{ t('pages.uploaderConfig.selected') }}
</el-icon> </div>
<el-icon </div>
class="el-icon-delete" <div class="config-actions">
<button
class="action-btn edit-btn"
:title="t('pages.uploaderConfig.edit')"
@click.stop="openEditPage(item._id)"
>
<Edit :size="16" />
</button>
<button
class="action-btn delete-btn"
:class="curConfigList.length <= 1 ? 'disabled' : ''" :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> </div>
</el-col> </div>
</el-row> </div>
<el-row
type="flex" <!-- Actions Card -->
justify="center" <div class="config-card actions-card">
:span="24" <div class="card-actions">
class="set-default-container" <button
> class="primary-button"
<el-button :disabled="store?.state.defaultPicBed === type"
class="set-default-btn" @click="setDefaultPicBed(type)"
type="success" >
round <DatabaseIcon :size="16" />
:disabled="store?.state.defaultPicBed === type" <span>{{ t('pages.uploaderConfig.setAsDefault') }}</span>
@click="setDefaultPicBed(type)" </button>
> </div>
{{ $t('SETTINGS_SET_DEFAULT_PICBED') }} </div>
</el-button>
</el-row>
</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) {
const res = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_DELETE_CONFIG, type.value, id) confirm({
if (!res) return title: t('pages.uploaderConfig.deleteTitle'),
curConfigList.value = res.configList message: t('pages.uploaderConfig.deleteConfirm'),
defaultConfigId.value = res.defaultId type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true
}).then(async result => {
if (!result) return
const res = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_DELETE_CONFIG, type.value, id)
if (!res) return
curConfigList.value = res.configList
defaultConfigId.value = res.defaultId
message.success(t('pages.uploaderConfig.deleteSuccess'))
})
} }
function addNewConfig () { 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"
> @click="handleNameClick"
<div class="view-title"> >
<span {{ picBedName }} {{ t('pages.picBedConfigs.title') }}
class="view-title-text" </h1>
@click="handleNameClick" <button
> {{ picBedName }} {{ $t('SETTINGS') }}</span> class="link-button"
<el-icon> :title="t('pages.picBedConfigs.viewDoc')"
<Link /> @click="handleNameClick"
</el-icon> >
<el-button <ExternalLink :size="20" />
type="primary" </button>
round </div>
size="small" <button
style="margin-left: 6px" 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"
:id="type" class="form-section"
ref="$configForm"
:config="config"
type="uploader"
> >
<el-form-item> <config-form
<el-button-group> :id="type"
<el-button ref="$configForm"
type="info" :config="config"
round type="uploader"
color-mode="white"
>
<!-- Action Buttons -->
<div class="action-buttons">
<button
class="action-button secondary"
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"
>
<path
d="M1 1.5L6 6.5L11 1.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</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)"
> >
{{ i._configName }} {{ item._configName }}
</el-dropdown-item> </button>
</template> </div>
</el-dropdown> </transition>
</el-button> </div>
</el-button-group> </div>
</el-form-item> </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,82 +155,126 @@ 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 () => {
await getPicBeds() loading.value = true
await getPicBedConfigList() try {
await getPicBeds()
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 () => {
const result = (await $configForm.value?.validate()) || false loading.value = true
if (result !== false) { try {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_UPDATE_CONFIG, type.value, result?._id, result) const result = (await $configForm.value?.validate()) || false
const successNotification = new Notification(t('SETTINGS_RESULT'), { if (result !== false) {
body: t('TIPS_SET_SUCCEED') const rawResult = getRawData(result)
}) await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_UPDATE_CONFIG, type.value, rawResult?._id, rawResult)
successNotification.onclick = () => { message.success(t('pages.picBedConfigs.setSuccess'))
return true $router.back()
} else {
message.error(t('pages.picBedConfigs.setFailedInfo'))
} }
$router.back() } 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 () {
const result = await window.electron.triggerRPC<any>(IRPCActionType.PICBED_GET_PICBED_CONFIG, $route.params.type) try {
config.value = result.config const result = await window.electron.triggerRPC<any>(IRPCActionType.PICBED_GET_PICBED_CONFIG, $route.params.type)
picBedName.value = result.name config.value = result.config
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 () {
const res = (await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)) || undefined try {
const configList = res?.configList || [] const res = (await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, type.value)) || undefined
picBedConfigList.value = configList.filter(item => item._id !== $route.params.configId) const configList = res?.configList || []
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) {
const { _id, _configName, _updatedAt, _createdAt, ...rest } = configItem try {
for (const key in rest) { const { _id, _configName, _updatedAt, _createdAt, ...rest } = configItem
if (Object.prototype.hasOwnProperty.call(rest, key)) { for (const key in rest) {
const value = rest[key] if (Object.prototype.hasOwnProperty.call(rest, key)) {
$configForm.value?.updateRuleForm(key, value) const value = rest[key]
$configForm.value?.updateRuleForm(key, value)
}
} }
$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'))
} }
$configForm.value?.updateRuleForm('_configName', dayjs().format('YYYYMMDDHHmmss'))
} }
const handleReset = async () => { const handleReset = async () => {
await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_RESET_CONFIG, type.value, $route.params.configId) loading.value = true
const successNotification = new Notification(t('SETTINGS_RESULT'), { try {
body: t('TIPS_RESET_SUCCEED') await window.electron.triggerRPC<void>(IRPCActionType.UPLOADER_RESET_CONFIG, type.value, $route.params.configId)
}) message.success(t('pages.picBedConfigs.resetSuccess'))
successNotification.onclick = () => { setTimeout(() => {
return true $router.back()
}, 1000)
} catch (error) {
console.error('Failed to reset configuration:', error)
message.error(t('pages.picBedConfigs.resetFailed'))
} finally {
loading.value = false
} }
$router.back()
} }
async function handleNameClick () { async function handleNameClick () {
const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN try {
const url = picBedManualUrlList[lang === II18nLanguage.EN ? 'en' : 'zh_cn'][$route.params.type as string] const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN
if (url) { const url = picBedManualUrlList[lang === II18nLanguage.EN ? 'en' : 'zh_cn'][$route.params.type as string]
window.electron.sendRPC(IRPCActionType.OPEN_URL, url) if (url) {
window.electron.sendRPC(IRPCActionType.OPEN_URL, url)
}
} catch (error) {
console.error('Failed to open documentation:', error)
message.error(t('pages.picBedConfigs.viewDocFailed'))
} }
} }
@@ -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}` : ''}`
window.electron.clipboard.writeText(apiUrl)
ElMessage.success(`${t('MANAGE_BUCKET_COPY_SUCCESS')} ${apiUrl}`) try {
window.electron.clipboard.writeText(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"