Files
PicList/src/renderer/pages/PicGoSetting.vue
2026-01-24 15:12:05 +08:00

2104 lines
73 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="relative flex h-full w-full items-center justify-center">
<div class="relative z-1 flex h-full w-full flex-col items-center justify-start gap-4 rounded-xl border-none p-4">
<!-- Header -->
<div
class="flex w-full items-center justify-between gap-4 rounded-2xl border border-border-secondary px-6 py-2 shadow-md max-md:items-stretch max-md:p-5"
>
<div class="flex flex-1 flex-wrap items-center gap-4 p-2">
<Settings :size="24" class="text-accent" />
<div>
<h1 class="m-0 text-2xl font-semibold tracking-tight text-main">{{ t('pages.settings.title') }}</h1>
</div>
</div>
<div class="flex gap-3">
<CustomButton :text="t('pages.settings.docs')" type="secondary" :icon="BookOpen" @click="goConfigPage" />
</div>
</div>
<!-- Tab Navigation -->
<div
class="flex w-full items-center justify-between gap-4 rounded-2xl border border-border-secondary px-6 py-2 shadow-md max-md:items-stretch max-md:p-5"
>
<CustomButton
v-for="tab in tabs"
:key="tab.id"
:text="tab.label"
:icon="tab.icon"
:active="currentTab === tab.id"
:icon-size="18"
type="tab"
@click="tabClick(tab.id)"
/>
</div>
<!-- Settings Content -->
<div
class="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden rounded-2xl border border-border-secondary p-1 shadow-md"
>
<!-- System Settings Tab -->
<div
v-if="currentTab === 'system'"
class="border4 no-scrollbar flex h-full w-full flex-1 flex-col gap-6 overflow-auto p-4"
>
<SettingSection :title="t('pages.settings.system.languageAndAppearance')" :icon="Globe">
<SettingCard>
<CustomSelect
v-model="currentLanguage"
:select-list="languageList"
:title="t('pages.settings.system.chooseLanguage')"
:icon="Globe"
/>
</SettingCard>
<SettingCard>
<CustomSelect v-model="currentStartMode" :title="t('pages.settings.system.startMode')" :icon="Monitor">
<template #extra>
<option value="quiet">{{ t('pages.settings.system.quietMode') }}</option>
<option v-if="osGlobal !== 'darwin'" value="mini">{{ t('pages.settings.system.miniMode') }}</option>
<option v-if="osGlobal === 'darwin'" value="no-tray">
{{ t('pages.settings.system.noTrayMode') }}
</option>
<option value="main">{{ t('pages.settings.system.mainMode') }}</option>
</template>
</CustomSelect>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="isDisableGPU"
no-border
small
:title="t('pages.settings.system.isDisableGPU')"
:description="t('pages.settings.system.isDisableGPUDesc')"
@update:model-value="handleIsDisableGPUChange"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.enableAdvancedAnimation"
no-border
small
:title="t('pages.settings.system.enableAdvancedAnimation')"
:description="t('pages.settings.system.enableAdvancedAnimationDesc')"
/>
</SettingCard>
<SettingCard>
<CustomSelect
v-model="currentTheme"
:select-list="themeList"
:title="t('pages.settings.system.chooseTheme')"
:icon="ImageIcon"
/>
</SettingCard>
<template #extra>
<div class="mt-3 flex gap-4">
<CustomButton
:disabled="downloadingThemes"
:text="
downloadingThemes
? t('pages.settings.system.downloadingThemes')
: t('pages.settings.system.downloadThemes')
"
:icon-size="14"
:icon="Download"
type="secondary"
@click="handleDownloadThemes"
/>
<CustomButton
:icon="Import"
:text="t('pages.settings.system.importThemes')"
type="secondary"
:icon-size="14"
@click="handleImportThemes"
/>
</div>
</template>
</SettingSection>
<!-- Window Behavior Section -->
<SettingSection :icon="Monitor" :title="t('pages.settings.system.windowBehavior')">
<!-- Main Window Size Card -->
<CustomNavCard
:title="t('pages.settings.system.mainWindowSize')"
:icon="Monitor"
@click="mainWindowSizeVisible = true"
/>
<!-- Window Behavior Toggles -->
<SettingCard v-if="osGlobal === 'darwin'" p1>
<CustomSwitch
v-model="formOfSetting.isHideDock"
small
no-border
:title="t('pages.settings.system.isHideDock')"
@change="handleHideDockChange(formOfSetting.isHideDock)"
/>
</SettingCard>
<SettingCard v-if="osGlobal !== 'darwin'" p1>
<CustomSwitch
v-model="formOfSetting.autoCloseMiniWindow"
small
no-border
:title="t('pages.settings.system.autoCloseMiniWindow')"
/>
</SettingCard>
<SettingCard v-if="osGlobal !== 'darwin'" p1>
<CustomSwitch
v-model="formOfSetting.autoCloseMainWindow"
small
no-border
:title="t('pages.settings.system.autoCloseMainWindow')"
/>
</SettingCard>
<SettingCard v-if="osGlobal !== 'darwin'" p1>
<CustomSwitch
v-model="formOfSetting.miniWindowOntop"
small
no-border
:title="t('pages.settings.system.miniWindowOnTop')"
@click="handleMiniWindowOntop(formOfSetting.miniWindowOntop)"
/>
</SettingCard>
<SettingCard v-if="osGlobal !== 'darwin'" p1>
<CustomSwitch
v-model="formOfSetting.isCustomMiniIcon"
small
no-border
:title="t('pages.settings.system.isCustomMiniIcon')"
/>
</SettingCard>
<CustomNavCard
v-if="osGlobal !== 'darwin' && formOfSetting.isCustomMiniIcon"
:icon="ImageIcon"
noarrow
:title="t('pages.settings.system.customMiniIconPath')"
>
<template #extra>
<CustomButton type="secondary" :text="t('pages.settings.clickToSet')" @click="handleMiniIconPath" />
</template>
</CustomNavCard>
</SettingSection>
<!-- Startup & Shortcuts Section -->
<SettingSection :icon="Keyboard" :title="t('pages.settings.system.startupAndShortcuts')">
<!-- Auto Launch Toggle -->
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.autoStart"
small
no-border
:title="t('pages.settings.system.autoLaunch')"
:description="t('pages.settings.system.autoLaunchDesc')"
@change="handleAutoStartChange(formOfSetting.autoStart)"
/>
</SettingCard>
<CustomNavCard
:title="t('pages.settings.system.setShortCuts')"
:description="t('pages.settings.system.setShortCutsDesc')"
:icon="Keyboard"
@click="goShortCutPage"
/>
</SettingSection>
</div>
<!-- Sync & Configure Tab -->
<div
v-if="currentTab === 'sync'"
class="border4 no-scrollbar flex h-full w-full flex-1 flex-col gap-6 overflow-auto p-4"
>
<!-- Sync Status Overview -->
<CustomNavCard noarrow :icon="RotateCcw" :title="t('pages.settings.sync.syncConfiguration')">
<template #description>
<p class="flex items-center gap-2 text-sm text-secondary">
<span
class="inline-flex items-center rounded-sm bg-bg-tertiary px-2 py-1 text-xs font-semibold tracking-wide text-accent"
>{{ sync.type?.toUpperCase() || 'N/A' }}</span
>
<span v-if="sync.type !== 'webdav' && sync.username" class="m-0 text-sm text-secondary"
>{{ sync.username }}/{{ sync.repo || '...' }}</span
>
<span v-else-if="sync.type === 'webdav' && sync.webdavEndpoint" class="m-0 text-sm text-secondary">{{
sync.webdavEndpoint
}}</span>
<span v-else class="text-sm font-semibold text-danger/70 italic">{{
t('pages.settings.sync.notConfigured')
}}</span>
</p>
</template>
<template #extra>
<CustomButton
:icon="Settings"
:text="t('pages.settings.sync.configureSync')"
type="secondary"
@click="syncVisible = true"
/>
</template>
</CustomNavCard>
<!-- Sync Actions Section -->
<SettingSection :icon="CloudUpload" :title="t('pages.settings.sync.syncActions')">
<CustomNavCard
:title="t('pages.settings.sync.upDownloadSettings')"
:icon="CloudUpload"
:description="t('pages.settings.sync.upDownloadDesc')"
@click="() => (upDownConfigVisible = true)"
/>
<CustomNavCard
:title="t('pages.settings.sync.migrateFromPicGo')"
:icon="Import"
:description="t('pages.settings.sync.migrateDesc')"
@click="handleMigrateFromPicGo"
/>
<CustomNavCard
v-if="isPortable"
:title="t('pages.settings.sync.migrateFromPicListInstallation')"
:icon="Import"
:description="t('pages.settings.sync.migrateDescPicList')"
@click="handleMigrateFromPicListInstallation"
/>
</SettingSection>
<!-- File Management Section -->
<SettingSection :icon="FolderOpen" :title="t('pages.settings.sync.fileManagement')">
<CustomNavCard
:title="t('pages.settings.sync.openConfigFile')"
:icon="FileText"
@click="openFile('data.json')"
/>
<CustomNavCard
:title="t('pages.settings.sync.openConfigFileDir')"
:icon="FolderOpen"
@click="openDirectory"
/>
</SettingSection>
</div>
<!-- Upload Settings Tab -->
<div
v-if="currentTab === 'upload'"
class="border4 no-scrollbar flex h-full w-full flex-1 flex-col gap-6 overflow-auto p-4"
>
<!-- Upload Behavior Section -->
<SettingSection :icon="Server" :title="t('pages.settings.upload.controlShow')">
<SettingCard>
<MultiSelect
v-model:choosed="showPicBedList"
:icon="Server"
:tight="false"
:title="t('pages.settings.upload.chooseShowedPicBed')"
:zero-placeholder="t('pages.gallery.chooseShowedPicBed')"
:all-list="picBedG"
/>
</SettingCard>
<SettingCard>
<MultiSelect
v-model:choosed="galleryPicBedFilterList"
:icon="ImageIcon"
:tight="false"
:title="t('pages.settings.upload.galleryPicBedFilter')"
:zero-placeholder="t('pages.gallery.chooseShowedPicBed')"
:all-list="picBedG"
/>
</SettingCard>
</SettingSection>
<SettingSection :icon="CloudUpload" :title="t('pages.settings.upload.uploadBehavior')">
<!-- Auto Import Card -->
<CustomSwitch
v-model="formOfSetting.autoImport"
small
:title="t('pages.settings.upload.autoImportInManage')"
:description="t('pages.settings.upload.autoImportInManageHint')"
/>
<!-- Auto Import PicBed Selection -->
<SettingCard v-if="formOfSetting.autoImport">
<MultiSelect
v-model:choosed="formOfSetting.autoImportPicBed"
:icon="ImageIcon"
:tight="false"
:title="t('pages.settings.upload.autoImportPicBed')"
:zero-placeholder="t('pages.settings.upload.autoImportPicBed')"
:all-list="picBedG"
/>
</SettingCard>
<!-- Second PicBed Card -->
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.enableSecondUploader"
small
no-border
:title="t('pages.settings.upload.enableSecondPicBed')"
:description="t('pages.settings.upload.enableSecondPicBedHint')"
/>
</SettingCard>
<CustomNavCard
:title="t('pages.settings.upload.setSecondPicBed')"
:icon="CloudUpload"
:description="t('pages.settings.upload.setSecondPicBedDesc')"
@click="handleChangeSecondPicBed"
/>
<SettingCard>
<CustomSelect
v-model="currentSecondMode"
:select-list="secondModeList"
:title="t('pages.settings.upload.chooseSecondPicBedMode')"
:icon="Settings2Icon"
/>
</SettingCard>
</SettingSection>
<!-- Upload Processing Section -->
<SettingSection :icon="ImageIcon" :title="t('pages.settings.upload.uploadProcessing')">
<CustomNavCard
:title="t('pages.settings.upload.advancedRname')"
:icon="Edit"
:description="t('pages.settings.upload.advancedRnameDesc')"
@click="advancedRenameVisible = true"
/>
<CustomNavCard
:title="t('pages.settings.upload.imageProcessing')"
:icon="ImageIcon"
:description="t('pages.settings.upload.imageProcessingDesc')"
@click="imageProcessDialogVisible = true"
/>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.deleteCloudFile"
small
no-border
:title="t('pages.settings.upload.deleteCloud')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.rename"
small
no-border
:title="t('pages.settings.upload.manualRename')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.autoRename"
small
no-border
:title="t('pages.settings.upload.timestampRename')"
description="YYYYMMDDHHmmssSSS"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.deleteLocalFile"
small
no-border
:title="t('pages.settings.upload.deleteLocalFileAfterUpload')"
/>
</SettingCard>
</SettingSection>
<!-- Clipboard & Notification Section -->
<SettingSection :icon="Edit" :title="t('pages.settings.upload.clipboardAndNotification')">
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.uploadNotification"
small
no-border
:title="t('pages.settings.upload.enableUploadNotification')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.uploadResultNotification"
small
no-border
:title="t('pages.settings.upload.enableUploadResultNotification')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.autoCopy"
small
no-border
:title="t('pages.settings.upload.autoCopyUrlAfterUpload')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.useBuiltinClipboard"
small
no-border
:title="t('pages.settings.upload.useBuiltInClipboardUpload')"
:description="t('pages.settings.upload.useBuiltInClipboardUploadHint')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.isAutoListenClipboard"
small
no-border
:title="t('pages.settings.upload.isAutoListenClipboard')"
/>
</SettingCard>
</SettingSection>
<!-- URL Format & Link Type Section -->
<SettingSection :icon="Link" :title="t('pages.settings.upload.urlFormatAndLinkType')">
<!-- Custom Link Format Action -->
<CustomNavCard
:title="t('pages.settings.upload.customLinkFormat')"
:icon="Link"
:description="t('pages.settings.upload.customLinkFormatDesc')"
@click="customLinkVisible = true"
/>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.useShortUrl"
small
no-border
:title="t('pages.settings.upload.enableShortUrl')"
:description="t('pages.settings.upload.enableShortUrlDesc')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl">
<CustomSelect
v-model="currentShortUrlServer"
:select-list="shortUrlServerList"
:title="t('pages.settings.upload.shortUrlServer')"
:icon="Link"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'c1n'">
<CustomInput
v-model="formOfSetting.c1nToken"
:title="t('pages.settings.upload.c1nToken')"
:icon="Link"
:placeholder="t('pages.settings.upload.c1nToken')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'yourls'">
<CustomInput
v-model="formOfSetting.yourlsDomain"
:title="t('pages.settings.upload.yourlsDomain')"
:icon="Link"
:placeholder="t('pages.settings.upload.yourlsDomain')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'yourls'">
<CustomInput
v-model="formOfSetting.yourlsSignature"
:title="t('pages.settings.upload.yourlsSignature')"
:icon="Link"
:placeholder="t('pages.settings.upload.yourlsSignature')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'cf_worker'">
<CustomInput
v-model="formOfSetting.cfWorkerHost"
:title="t('pages.settings.upload.cfWorkerHost')"
:icon="Link"
:placeholder="t('pages.settings.upload.cfWorkerHost')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'sink'">
<CustomInput
v-model="formOfSetting.sinkDomain"
:title="t('pages.settings.upload.sinkDomain')"
:icon="Link"
:placeholder="t('pages.settings.upload.sinkDomain')"
/>
</SettingCard>
<SettingCard v-if="formOfSetting.useShortUrl && formOfSetting.shortUrlServer === 'sink'">
<CustomInput
v-model="formOfSetting.sinkToken"
:title="t('pages.settings.upload.sinkToken')"
:icon="Link"
:placeholder="t('pages.settings.upload.sinkToken')"
/>
</SettingCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.encodeOutputURL"
small
no-border
:title="t('pages.settings.upload.encodeOutputUrl')"
/>
</SettingCard>
</SettingSection>
</div>
<div
v-if="currentTab === 'advanced'"
class="border4 no-scrollbar flex h-full w-full flex-1 flex-col gap-6 overflow-auto p-4"
>
<SettingSection :icon="FileText" :title="t('pages.settings.advanced.logging')">
<CustomNavCard
:title="t('pages.settings.advanced.logFilePath')"
:description="t('pages.settings.advanced.logFilePathDesc')"
:icon="FolderOpen"
@click="openDirectory"
/>
<CustomNavCard
:title="t('pages.settings.advanced.setLog')"
:description="t('pages.settings.advanced.setLogDesc')"
:icon="Settings"
@click="openLogSetting"
/>
</SettingSection>
<SettingSection :icon="Globe" :title="t('pages.settings.advanced.networkAndProxy')">
<CustomNavCard
:title="t('pages.settings.advanced.setProxyAndMirror')"
:description="t('pages.settings.advanced.setProxyAndMirrorDesc')"
:icon="Globe"
@click="proxyVisible = true"
/>
</SettingSection>
<!-- Server Settings Section -->
<SettingSection :icon="Server" :title="t('pages.settings.advanced.serverSettings')">
<CustomNavCard
:title="t('pages.settings.advanced.webServerSettings')"
:description="t('pages.settings.advanced.webServerSettingsDesc')"
:icon="Globe"
@click="webServerVisible = true"
/>
<CustomNavCard
:title="t('pages.settings.advanced.uploadServer')"
:description="t('pages.settings.advanced.uploadServerDesc')"
:icon="Globe"
@click="serverVisible = true"
/>
<SettingCard>
<CustomInput
v-model="formOfSetting.aesPassword"
:is-password="true"
:title="t('pages.settings.advanced.serverEncryptionKey')"
:placeholder="t('pages.settings.advanced.serverEncryptionKey')"
/>
</SettingCard>
</SettingSection>
</div>
<div
v-if="currentTab === 'update'"
class="border4 no-scrollbar flex h-full w-full flex-1 flex-col gap-6 overflow-auto p-4"
>
<SettingSection :icon="RefreshCw" :title="t('pages.settings.update.applicationUpdates')">
<CustomNavCard noarrow :icon="RotateCcw" :title="t('pages.settings.update.currentVersion')">
<template #description>
<div class="flex items-center gap-2">
<span class="rounded-md bg-accent/30 px-2 py-1 text-sm font-semibold text-secondary"
>v{{ version }}</span
>
</div>
</template>
<template #extra>
<CustomButton
:icon="RefreshCw"
:text="t('pages.settings.update.clickToCheck')"
type="secondary"
@click="checkUpdate"
/>
</template>
</CustomNavCard>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.showUpdateTip"
small
no-border
:title="t('pages.settings.update.openUpdateHelper')"
:description="t('pages.settings.update.openUpdateHelperDesc')"
/>
</SettingCard>
</SettingSection>
<!-- Release Notes Section -->
<SettingSection
:only-one-row="true"
:icon="BookOpen"
:title="t('pages.settings.update.latestReleaseNotes')"
class="relative"
>
<div class="absolute top-4 right-4 flex items-center gap-2">
<CustomButton
:icon="RefreshCw"
:text="t('pages.settings.update.refresh')"
type="secondary"
:disabled="fetchingReleaseNotes"
@click="fetchReleaseNotesManually"
/>
</div>
<div class="relative w-full rounded-lg border border-border bg-bg-secondary shadow-sm">
<div class="max-h-[400px] overflow-y-auto bg-bg-secondary">
<div
v-if="fetchingReleaseNotes"
class="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm font-semibold text-secondary"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-accent/20 text-accent">
<RefreshCw :size="24" class="animate-ping" />
</div>
<span>{{ t('pages.settings.update.loadingReleaseNotes') }}</span>
</div>
<div v-else-if="releaseNotes" class="notes-body" v-html="renderedReleaseNotes"></div>
<div
v-else-if="releaseNotesError"
class="flex flex-col items-center justify-center gap-6 bg-error/10 p-4 text-sm text-danger"
>
<div class="text-[4rem]"></div>
<span>{{ releaseNotesError }}</span>
<CustomButton
:icon="RefreshCw"
:text="t('pages.settings.update.retry')"
type="secondary"
@click="fetchReleaseNotesManually"
/>
</div>
</div>
<div v-if="releaseNotesLastFetch" class="border-t border-border-secondary bg-bg-secondary p-3 text-right">
<small class="flex flex-row justify-end gap-1 text-xs text-secondary">
<RefreshCw :size="12" />
<div>
{{ t('pages.settings.update.lastUpdated') }}: {{ formatLastFetchTime(releaseNotesLastFetch) }}
</div>
</small>
</div>
</div>
</SettingSection>
</div>
</div>
</div>
<!-- Dialogs -->
<!-- Custom Link Format Dialog -->
<CustomModal
v-if="customLinkVisible"
v-model:visible="customLinkVisible"
height="auto"
width="auto"
:title="t('pages.settings.upload.customLinkFormat')"
>
<div class="flex flex-col gap-4 p-4">
<div class="rounded-lg border border-border p-4">
<div class="mb-2 flex items-center gap-2 p-1">
<FileText :size="16" class="text-accent" />
<span class="text-sm font-semibold text-main">{{
t('pages.settings.upload.availablePlaceholdersTitle')
}}</span>
</div>
<div class="flex flex-col gap-2">
<template v-for="item in placeholderList" :key="item.code">
<div class="flex items-center gap-3">
<code
class="min-w-[80px] rounded-sm border border-border bg-bg-secondary px-2 py-1 text-center font-['SF_Mono,Monaco,Menlo,monospace'] text-sm font-semibold text-main"
>{{ item.code }}</code
>
<span class="text-sm font-semibold text-secondary">{{
t(`pages.settings.upload.${item.description}`)
}}</span>
</div>
</template>
</div>
</div>
<div>
<SettingCard>
<CustomInput
v-model="customLink"
:title="t('pages.settings.upload.customLinkFormatInput')"
:placeholder="'![$fileName]($url)'"
/>
</SettingCard>
</div>
</div>
</CustomModal>
<!-- Proxy Settings Dialog -->
<CustomModal
v-if="proxyVisible"
v-model:visible="proxyVisible"
height="auto"
width="600px"
:title="t('pages.settings.advanced.setProxyAndMirror')"
>
<SettingSection>
<SettingCard>
<CustomInput
v-model="proxy"
:title="t('pages.settings.advanced.uploadProxy')"
placeholder="http://127.0.0.1:1080"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.proxy"
:title="t('pages.settings.advanced.pluginInstallProxy')"
placeholder="http://127.0.0.1:1080"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.registry"
:title="t('pages.settings.advanced.pluginInstallMirror')"
placeholder="https://registry.npmmirror.com"
/>
</SettingCard>
</SettingSection>
</CustomModal>
<!-- Main Window Size Dialog -->
<CustomModal
v-if="mainWindowSizeVisible"
v-model:visible="mainWindowSizeVisible"
height="auto"
width="600px"
:title="t('pages.settings.system.setMainWindowSize')"
>
<SettingSection>
<SettingCard>
<CustomInput
v-model="formOfSetting.mainWindowWidth"
type="number"
min="1"
max="10000"
:title="t('pages.settings.system.mainWindowWidth')"
placeholder="1200"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.mainWindowHeight"
type="number"
min="1"
max="10000"
:title="t('pages.settings.system.mainWindowHeight')"
placeholder="800"
/>
</SettingCard>
<CustomSwitch
v-model="rawPicGoSize"
small
:title="t('pages.settings.system.rawPicGoSize')"
:description="t('pages.settings.system.rawPicGoSizeHint')"
/>
</SettingSection>
</CustomModal>
<!-- Check Update Dialog -->
<CustomModal
v-if="checkUpdateVisible"
v-model:visible="checkUpdateVisible"
height="auto"
width="500px"
:title="t('pages.settings.update.checkUpdate')"
>
<div class="mb-4 no-scrollbar overflow-y-auto p-1">
<div class="mt-5 flex items-center justify-center gap-4">
<div class="min-w-[120px] flex-1 rounded-lg border border-border bg-bg-tertiary px-5 py-4 text-center">
<div class="mb-1.5 text-sm font-semibold text-secondary">
{{ t('pages.settings.update.currentVersionLabel') }}
</div>
<div class="text-lg font-bold text-main">v{{ version }}</div>
</div>
<div class="shrink-0 text-tertiary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
<div
class="group latest min-w-[120px] flex-1 rounded-lg border border-border bg-bg-tertiary px-5 py-4 text-center"
:class="{ 'has-update': needUpdate }"
>
<div class="mb-1.5 text-sm font-semibold text-secondary group-[.has-update]:text-success">
{{ t('pages.settings.update.newestVersion') }}
</div>
<div class="text-lg font-bold text-main group-[.has-update]:text-success">
{{ latestVersion ? `${latestVersion}` : t('pages.settings.update.getting') }}
</div>
</div>
</div>
<div
v-if="!needUpdate"
class="flex items-center justify-center gap-2 rounded-lg p-4 text-sm font-semibold text-success"
>
<RefreshCw :size="18" />
<span>{{ t('pages.settings.update.hasNewVersion') }}</span>
</div>
</div>
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="cancelCheckVersion" />
<CustomButton
type="primary"
:text="needUpdate ? t('pages.settings.update.updateNow') : t('common.confirm')"
@click="confirmCheckVersion"
/>
</template>
</CustomModal>
<!-- Advanced Rename Dialog -->
<CustomModal
v-if="advancedRenameVisible"
v-model:visible="advancedRenameVisible"
height="85vh"
width="65vw"
:title="t('pages.settings.upload.advancedRname')"
>
<div class="flex h-full w-full flex-col p-2">
<SettingSection>
<CustomSwitch
v-model="advancedRename.enable"
:title="t('pages.settings.upload.enableAdvancedRname')"
:description="t('pages.settings.upload.enableAdvancedRnameDesc')"
/>
<CustomInput
v-model="advancedRename.format"
:title="t('pages.settings.upload.advancedRnameFormat')"
placeholder="Ex. {Y}-{m}-{uuid}"
/>
</SettingSection>
<div class="flex w-full flex-1 flex-col overflow-hidden p-2">
<label class="text-xl font-bold text-secondary">{{ t('pages.settings.upload.availablePlaceholders') }}</label>
<placeholderTable :list="advancedRenameList" :title-list="advancedRenameTitleList" />
</div>
</div>
</CustomModal>
<!-- Log Settings Dialog -->
<CustomModal
v-if="logFileVisible"
v-model:visible="logFileVisible"
height="auto"
width="800px"
:title="t('pages.settings.advanced.setLog')"
>
<div class="flex h-full w-full flex-col p-4">
<SettingSection>
<CustomNavCard
:title="t('pages.settings.advanced.logFile')"
description="piclist.log"
:icon="FileText"
@click="openFile('piclist.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.guiLogFile')"
description="piclist-gui-local.log"
:icon="FileText"
@click="openFile('piclist-gui-local.log')"
/>
<CustomNavCard
:title="t('pages.settings.advanced.manageLogFile')"
description="manage.log"
:icon="FileText"
@click="openFile('manage.log')"
/>
<CustomInput
v-model="formOfSetting.logFileSizeLimit"
:title="t('pages.settings.advanced.logFileSize')"
placeholder="10"
type="number"
min="1"
max="1024"
step="1"
/>
<SettingCard>
<MultiSelect
v-model:choosed="formOfSetting.logLevel"
:icon="FileText"
:tight="false"
:title="t('pages.settings.advanced.logLevel')"
:zero-placeholder="t('pages.settings.advanced.logLevel')"
:all-list="logLevel"
/></SettingCard>
</SettingSection>
</div>
</CustomModal>
<!-- Server Settings Dialog -->
<CustomModal
v-if="serverVisible"
v-model:visible="serverVisible"
height="auto"
width="600px"
:title="t('pages.settings.advanced.uploadServer')"
>
<div class="flex w-full flex-col gap-4 p-4">
<div
class="mb-4 flex items-start gap-3 rounded-lg border border-border bg-success/10 px-4 py-3 text-sm leading-1.5 font-semibold text-secondary"
>
<span>{{ t('pages.settings.advanced.serverSettingsNotice') }}</span>
</div>
<SettingCard p1>
<CustomSwitch v-model="server.enable" :title="t('pages.settings.advanced.enableServer')" no-border small />
</SettingCard>
<SettingSection v-if="server.enable">
<SettingCard>
<CustomInput
v-model="server.host"
type="text"
:title="t('pages.settings.advanced.serverHost')"
placeholder="127.0.0.1"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="server.port"
type="number"
:min="1"
:max="65535"
:step="1"
:title="t('pages.settings.advanced.serverPort')"
placeholder="36677"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.serverKey"
:is-password="true"
:title="t('pages.settings.advanced.serverKey')"
:placeholder="t('pages.settings.advanced.serverKeyPlaceholder')"
/>
</SettingCard>
</SettingSection>
</div>
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="cancelServerSetting" />
<CustomButton type="primary" :text="t('common.confirm')" @click="confirmServerSetting" />
</template>
</CustomModal>
<!-- Web Server Settings Dialog -->
<CustomModal
v-if="webServerVisible"
v-model:visible="webServerVisible"
height="auto"
width="600px"
:title="t('pages.settings.advanced.webServerSettings')"
>
<div class="flex w-full flex-col gap-4 p-4">
<div
class="mb-4 flex items-start gap-3 rounded-lg border border-border bg-success/10 px-4 py-3 text-sm leading-1.5 font-semibold text-secondary"
>
<span>{{ t('pages.settings.advanced.webServerNotice') }}</span>
</div>
<SettingCard p1>
<CustomSwitch
v-model="formOfSetting.enableWebServer"
:title="t('pages.settings.advanced.enableWebServer')"
no-border
small
/>
</SettingCard>
<SettingSection
v-if="formOfSetting.enableWebServer"
:icon="Settings"
:title="t('pages.settings.advanced.webServerConfig')"
>
<SettingCard>
<CustomInput
v-model="formOfSetting.webServerHost"
type="text"
:title="t('pages.settings.advanced.webServerHost')"
placeholder="127.0.0.1"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.webServerPort"
type="number"
:min="1"
:max="65535"
:step="1"
:title="t('pages.settings.advanced.webServerPort')"
placeholder="37777"
/>
</SettingCard>
<SettingCard>
<CustomInput
v-model="formOfSetting.webServerPath"
:title="t('pages.settings.advanced.webServerPath')"
:placeholder="t('pages.settings.advanced.webServerPathPlaceholder')"
/>
</SettingCard>
</SettingSection>
</div>
<template #footer>
<CustomButton type="primary" :text="t('common.confirm')" @click="confirmWebServerSetting" />
</template>
</CustomModal>
<!-- Sync Configuration Dialog -->
<CustomModal
v-if="syncVisible"
v-model:visible="syncVisible"
height="auto"
width="700px"
:title="t('pages.settings.sync.syncEndpointConfig')"
>
<div class="flex w-full flex-col gap-4 p-4">
<div class="p-2">
<div class="grid grid-cols-4 gap-3">
<button
v-for="typeitem of syncType"
:key="typeitem"
class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-border bg-bg-tertiary px-2 py-4 hover:border-accent/50 hover:bg-accent/10 [.active]:border-accent [.active]:bg-accent/10 [.active]:text-accent [.active]:shadow-md"
:class="{ active: sync.type === typeitem }"
@click="sync.type = typeitem"
>
<GitBranch v-if="typeitem.includes('git')" class="text-secondary" :size="20" />
<Store v-else-if="typeitem === 'webdav'" class="text-secondary" :size="20" />
<span class="text-sm font-semibold text-secondary">{{
typeitem.slice(0, 1).toUpperCase() + typeitem.slice(1)
}}</span>
</button>
</div>
</div>
<!-- Configuration Fields -->
<div class="flex w-full flex-col gap-4">
<SettingSection :icon="Settings" :title="sync.type">
<SettingCard v-if="sync.type === 'gitea'">
<CustomInput
v-model.trim="sync.endpoint"
:title="t('pages.settings.sync.giteaHost')"
:placeholder="t('pages.settings.sync.giteaHost')"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'webdav'">
<CustomInput
v-model.trim="sync.webdavEndpoint"
:title="t('pages.settings.sync.webdavEndpoint')"
:placeholder="t('pages.settings.sync.webdavEndpoint')"
/>
</SettingCard>
<template v-if="sync.type !== 'webdav'">
<SettingCard v-for="inputItem in ['username', 'repo', 'branch', 'token']" :key="inputItem">
<CustomInput
v-model.trim="sync[inputItem as any]"
:is-password="inputItem === 'token'"
:title="t(`pages.settings.sync.${sync.type.toLowerCase()}.${inputItem.toLowerCase()}`)"
:placeholder="t(`pages.settings.sync.${sync.type.toLowerCase()}.${inputItem.toLowerCase()}`)"
/>
</SettingCard>
</template>
<SettingCard v-if="sync.type === 'webdav'">
<CustomInput
v-model.trim="sync.webdavUsername"
:title="t('pages.settings.sync.webdav.username')"
:placeholder="t('pages.settings.sync.webdav.username')"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'webdav'">
<CustomInput
v-model.trim="sync.webdavPassword"
:is-password="true"
:title="t('pages.settings.sync.webdav.password')"
:placeholder="t('pages.settings.sync.webdav.password')"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'webdav'">
<CustomInput
v-model.trim="sync.webdavSavePath"
:title="t('pages.settings.sync.webdav.savePath')"
:placeholder="t('pages.settings.sync.webdav.savePath')"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'webdav'">
<CustomSelect
v-model="sync.webdavAuthType"
:select-list="[
{ label: 'Basic', value: 'basic' },
{ label: 'Digest', value: 'digest' },
]"
:title="t('pages.settings.sync.webdav.authType')"
:icon="Settings"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'webdav'">
<CustomSwitch
v-model="sync.webdavSslEnabled"
small
no-border
:title="t('pages.settings.sync.webdav.enableSSL')"
:description="t('pages.settings.sync.webdav.enableSSLDesc')"
/>
</SettingCard>
<SettingCard v-if="sync.type === 'github'">
<CustomInput
v-model.trim="sync.proxy"
:title="t('pages.settings.sync.syncConfigProxy')"
:placeholder="t('pages.settings.sync.syncConfigProxy')"
/>
</SettingCard>
</SettingSection>
</div>
</div>
<template #footer>
<CustomButton type="secondary" :text="t('common.cancel')" @click="cancelSyncSetting" />
<CustomButton type="primary" :text="t('common.confirm')" @click="confirmSyncSetting" />
</template>
</CustomModal>
<!-- Upload/Download Config Dialog -->
<CustomModal
v-if="upDownConfigVisible"
v-model:visible="upDownConfigVisible"
height="auto"
width="700px"
:title="t('pages.settings.sync.upDownloadSettings')"
>
<div class="flex flex-col gap-6 p-4">
<SettingSection :icon="CloudUpload" :title="t('pages.settings.sync.uploadSettings')">
<CustomButton
v-for="item in syncTaskList.slice(0, 3)"
:key="item.task"
type="secondary"
:icon="CloudUpload"
:text="item.label"
@click="syncTaskFn(item.task, item.number)"
/>
</SettingSection>
<SettingSection :icon="Download" :title="t('pages.settings.sync.downloadSettings')">
<CustomButton
v-for="item in syncTaskList.slice(3, 6)"
:key="item.task"
type="secondary"
:icon="Download"
:text="item.label"
@click="syncTaskFn(item.task, item.number)"
/>
</SettingSection>
<SettingSection :icon="ImageIcon" :title="t('pages.settings.sync.galleryDB')">
<CustomButton
v-for="item in syncTaskList.slice(6, 7)"
:key="item.task"
type="secondary"
:icon="ImageIcon"
:text="item.label"
@click="syncTaskFn(item.task, item.number)"
/>
</SettingSection>
</div>
</CustomModal>
<!-- Image Process Dialog -->
<CustomModal
v-if="imageProcessDialogVisible"
v-model:visible="imageProcessDialogVisible"
:title="t('pages.imageProcess.title')"
:description="t('pages.imageProcess.subtitle-Global')"
>
<ImageProcessSetting :config-id="''" :current-picbed-name="''" />
</CustomModal>
</div>
</template>
<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
import { compare } from 'compare-versions'
import {
BookOpen,
CloudUpload,
Download,
Edit,
FileText,
FolderOpen,
GitBranch,
Globe,
Image as ImageIcon,
Import,
Keyboard,
Link,
Monitor,
RefreshCw,
RotateCcw,
Server,
Settings,
Settings2Icon,
Store,
} from 'lucide-vue-next'
import { marked } from 'marked'
import type { IConfig } from 'piclist'
import pkg from 'root/package.json'
import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import CustomButton from '@/components/common/CustomButton.vue'
import CustomInput from '@/components/common/CustomInput.vue'
import CustomModal from '@/components/common/CustomModal.vue'
import CustomNavCard from '@/components/common/CustomNavCard.vue'
import CustomSelect from '@/components/common/CustomSelect.vue'
import CustomSwitch from '@/components/common/CustomSwitch.vue'
import MultiSelect from '@/components/common/MultiSelect.vue'
import placeholderTable from '@/components/common/PlaceholderTable.vue'
import SettingCard from '@/components/common/SettingCard.vue'
import SettingSection from '@/components/common/SettingSection.vue'
import ImageProcessSetting from '@/components/ImageProcessSetting.vue'
import useConfirm from '@/hooks/useConfirm'
import { osGlobal, usePicBed } from '@/hooks/useGlobal'
import useMessage from '@/hooks/useMessage'
import { setCurrentLanguage } from '@/i18n'
import { SHORTKEY_PAGE } from '@/router/config'
import { enforceNumber } from '@/utils/common'
import { configPaths } from '@/utils/configPaths'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { II18nLanguage, IRPCActionType, ISartMode } from '@/utils/enum'
import { getLatestVersion } from '@/utils/getLatestVersion'
/* reactive data and refs */
const { t, locale } = useI18n()
const $router = useRouter()
const { confirm } = useConfirm()
const message = useMessage()
const { picBedG, updatePicBeds } = usePicBed()
const showPicBedList = ref<string[]>([])
const galleryPicBedFilterList = ref<string[]>([])
const themeList = ref<{ value: string; label: string }[]>([{ value: 'default.css', label: '默认' }])
const currentTheme = ref('default.css')
const proxy = ref('')
const downloadingThemes = ref(false)
const isDisableGPU = ref(false)
const isPortable = ref(false)
const currentLanguage = ref()
const currentSecondMode = ref()
const currentStartMode = ref()
const currentShortUrlServer = ref()
/* dialog visibility refs */
const logFileVisible = ref(false)
const customLinkVisible = ref(false)
const checkUpdateVisible = ref(false)
const serverVisible = ref(false)
const webServerVisible = ref(false)
const syncVisible = ref(false)
const upDownConfigVisible = ref(false)
const proxyVisible = ref(false)
const latestVersion = ref('')
const releaseNotes = ref('')
const releaseNotesError = ref('')
const releaseNotesLastFetch = ref<Date | null>(null)
const fetchingReleaseNotes = ref(false)
const mainWindowSizeVisible = ref(false)
const advancedRenameVisible = ref(false)
const imageProcessDialogVisible = ref(false)
const rawPicGoSize = ref(false)
const customLink = ref('![$fileName]($url)')
const currentTab = useStorage<'system' | 'sync' | 'upload' | 'advanced' | 'update'>('settings-current-tab', 'system')
const server = ref({ port: 36677, host: '0.0.0.0', enable: true })
const advancedRename = ref({ enable: false, format: '{filename}' })
const sync = ref<any>({
type: 'github',
username: '',
repo: '',
branch: '',
token: '',
endpoint: '',
proxy: '',
interval: 60,
// WebDAV-specific fields
password: '',
authType: 'basic',
sslEnabled: true,
webdavSavePath: '',
})
const formOfSetting = ref<ISettingForm>({
showUpdateTip: true,
autoStart: false,
rename: false,
autoRename: false,
uploadNotification: false,
uploadResultNotification: true,
miniWindowOntop: false,
autoCloseMiniWindow: false,
autoCloseMainWindow: false,
logLevel: ['all'],
autoCopy: true,
useBuiltinClipboard: true,
logFileSizeLimit: 10,
deleteCloudFile: false,
isCustomMiniIcon: false,
customMiniIcon: '',
isHideDock: false,
autoImport: false,
autoImportPicBed: [],
encodeOutputURL: false,
isAutoListenClipboard: false,
useShortUrl: false,
shortUrlServer: 'c1n',
c1nToken: '',
yourlsDomain: '',
yourlsSignature: '',
cfWorkerHost: '',
sinkDomain: '',
sinkToken: '',
deleteLocalFile: false,
serverKey: '',
aesPassword: 'PicList-aesPassword',
enableWebServer: false,
webServerHost: '0.0.0.0',
webServerPort: 37777,
webServerPath: '',
registry: '',
proxy: '',
mainWindowWidth: 1200,
mainWindowHeight: 800,
enableSecondUploader: false,
enableAdvancedAnimation: false,
theme: 'default.css',
})
/* computed properties */
const tabs = computed(() => [
{ id: 'system', label: t('pages.settings.system.title'), icon: Settings },
{ id: 'sync', label: t('pages.settings.sync.title'), icon: RotateCcw },
{ id: 'upload', label: t('pages.settings.upload.title'), icon: CloudUpload },
{ id: 'advanced', label: t('pages.settings.advanced.title'), icon: Server },
{ id: 'update', label: t('pages.settings.update.title'), icon: RefreshCw },
])
const needUpdate = computed(() => {
if (latestVersion.value) {
return compareVersion2Update(version, latestVersion.value)
}
return false
})
const renderedReleaseNotes = computed(() => {
return marked(releaseNotes.value, { breaks: true, gfm: true })
})
/* constants and enums */
const syncTaskList = [
{ task: IRPCActionType.CONFIGURE_UPLOAD_COMMON_CONFIG, label: t('pages.settings.sync.commonConfig'), number: 2 },
{ task: IRPCActionType.CONFIGURE_UPLOAD_MANAGE_CONFIG, label: t('pages.settings.sync.manageConfig'), number: 2 },
{ task: IRPCActionType.CONFIGURE_UPLOAD_ALL_CONFIG, label: t('pages.settings.sync.allConfig'), number: 4 },
{ task: IRPCActionType.CONFIGURE_DOWNLOAD_COMMON_CONFIG, label: t('pages.settings.sync.commonConfig'), number: 2 },
{ task: IRPCActionType.CONFIGURE_DOWNLOAD_MANAGE_CONFIG, label: t('pages.settings.sync.manageConfig'), number: 2 },
{ task: IRPCActionType.CONFIGURE_DOWNLOAD_ALL_CONFIG, label: t('pages.settings.sync.allConfig'), number: 4 },
{ task: IRPCActionType.CONFIGURE_SYNC_GALLERY_DB, label: t('pages.settings.sync.galleryDB'), number: 2 },
]
const logLevel = [
{ type: 'all', name: t('pages.settings.advanced.logLevelList.all') },
{ type: 'success', name: t('pages.settings.advanced.logLevelList.success') },
{ type: 'error', name: t('pages.settings.advanced.logLevelList.error') },
{ type: 'info', name: t('pages.settings.advanced.logLevelList.info') },
{ type: 'warn', name: t('pages.settings.advanced.logLevelList.warn') },
{ type: 'none', name: t('pages.settings.advanced.logLevelList.none') },
]
const syncType = ['github', 'gitee', 'gitea', 'webdav']
const version = pkg.version
const RELEASE_NOTES_CACHE_DURATION = 30 * 60 * 1000
const shortUrlServerList = [
{ label: 'c1n', value: 'c1n' },
{ label: 'yourls', value: 'yourls' },
{ label: 'xyTom/Url-Shorten-Worker', value: 'cf_worker' },
{ label: 'ccbikai/Sink', value: 'sink' },
]
const languageList = [
{ label: '简体中文', value: 'zh-CN' },
{ label: '繁體中文', value: 'zh-TW' },
{ label: 'English', value: 'en' },
]
const secondModeList = [
{ label: t('pages.settings.upload.secondPicBedMode.backup'), value: 'backup' },
{ label: t('pages.settings.upload.secondPicBedMode.seperate'), value: 'seperate' },
]
const formKeys = Object.keys(formOfSetting.value) as (keyof ISettingForm)[]
const autoWatchKeys = [
'showUpdateTip',
'autoImport',
'autoImportPicBed',
'useBuiltinClipboard',
'isAutoListenClipboard',
'deleteCloudFile',
'deleteLocalFile',
'rename',
'autoRename',
'enableWebServer',
'webServerHost',
'webServerPort',
'webServerPath',
'serverKey',
'uploadNotification',
'uploadResultNotification',
'autoCloseMainWindow',
'autoCloseMiniWindow',
'isCustomMiniIcon',
'c1nToken',
'yourlsDomain',
'yourlsSignature',
'cfWorkerHost',
'sinkDomain',
'sinkToken',
'registry',
'proxy',
'autoCopy',
'encodeOutputURL',
'useShortUrl',
'enableSecondUploader',
'enableAdvancedAnimation',
]
const advancedRenameList = {
categoryTime: [
{ label: t('pages.settings.upload.placeholder.year4'), value: '{Y}' },
{ label: t('pages.settings.upload.placeholder.year2'), value: '{y}' },
{ label: t('pages.settings.upload.placeholder.month'), value: '{m}' },
{ label: t('pages.settings.upload.placeholder.date'), value: '{d}' },
{ label: t('pages.settings.upload.placeholder.hour'), value: '{h}' },
{ label: t('pages.settings.upload.placeholder.minute'), value: '{i}' },
{ label: t('pages.settings.upload.placeholder.second'), value: '{s}' },
{ label: t('pages.settings.upload.placeholder.millisecond'), value: '{ms}' },
{ label: t('pages.settings.upload.placeholder.timestamp'), value: '{timestamp}' },
],
categoryHash: [
{ label: t('pages.settings.upload.placeholder.md5'), value: '{md5}' },
{ label: t('pages.settings.upload.placeholder.md5-16'), value: '{md5-16}' },
{ label: t('pages.settings.upload.placeholder.uuid'), value: '{uuid}' },
{ label: t('pages.settings.upload.placeholder.sha256'), value: '{sha256}' },
{ label: t('pages.settings.upload.placeholder.sha256-n'), value: '{sha256-n}' },
],
categoryFile: [
{ label: t('pages.settings.upload.placeholder.filename'), value: '{filename}' },
{ label: t('pages.settings.upload.placeholder.localFolder'), value: '{localFolder:n}' },
{ label: t('pages.settings.upload.placeholder.randomString'), value: '{str-n}' },
],
}
const advancedRenameTitleList = computed(() => ({
categoryTime: t('pages.settings.upload.placeholder.categoryTime'),
categoryHash: t('pages.settings.upload.placeholder.categoryHash'),
categoryFile: t('pages.settings.upload.placeholder.categoryFile'),
}))
const placeholderList = [
{
code: '$url',
description: 'urlPlaceholder',
},
{
code: '$fileName',
description: 'fileNamePlaceholder',
},
{
code: '$extName',
description: 'extNamePlaceholder',
},
]
/* watchers and effects */
const addWatch = () => {
autoWatchKeys.forEach(key => {
watch(
() => formOfSetting.value[key as keyof ISettingForm],
value => {
saveConfig({ [`settings.${key}`]: value })
},
)
})
watch(showPicBedList, val => {
handleShowPicBedListChange(val)
})
watch(galleryPicBedFilterList, val => {
handleGalleryPicBedFilterChange(val)
})
watch(
() => formOfSetting.value.aesPassword,
val => {
handleAesPasswordChange(val)
},
)
watch(currentSecondMode, newVal => {
if (newVal) {
saveConfig({ [configPaths.settings.secondPicBedMode]: newVal })
}
})
watch(currentLanguage, newVal => {
if (newVal) {
handleLanguageChange(newVal)
// Fetch release notes when language changes
fetchReleaseNotes(true)
}
})
watch(currentStartMode, newVal => {
if (newVal) {
handleStartModeChange(newVal)
}
})
watch(currentShortUrlServer, newVal => {
if (newVal) {
handleShortUrlServerChange(newVal)
}
})
watch(currentTheme, newVal => {
if (newVal) {
handleThemeChange(newVal)
}
})
watch(
advancedRename,
newVal => {
saveConfig(configPaths.buildIn.rename, toRaw(newVal))
if (newVal.enable) {
formOfSetting.value.autoRename = false
saveConfig(configPaths.settings.autoRename, false)
}
},
{ deep: true },
)
watch(
() => formOfSetting.value.mainWindowWidth,
newVal => {
console.log('Main window width changed:', newVal)
const width = enforceNumber(newVal)
saveConfig({ [configPaths.settings.mainWindowWidth]: rawPicGoSize.value ? 800 : Math.max(width, 100) })
},
)
watch(
() => formOfSetting.value.mainWindowHeight,
newVal => {
console.log('Main window height changed:', newVal)
const height = enforceNumber(newVal)
saveConfig({ [configPaths.settings.mainWindowHeight]: rawPicGoSize.value ? 450 : Math.max(height, 100) })
},
)
watch(rawPicGoSize, newVal => {
if (newVal) {
formOfSetting.value.mainWindowWidth = 800
formOfSetting.value.mainWindowHeight = 450
}
})
watch(customLink, newVal => {
saveConfig(configPaths.settings.customLink, newVal)
})
watch(proxy, value => {
saveConfig({ 'picBed.proxy': value })
})
watch(
() => formOfSetting.value.logFileSizeLimit,
newVal => {
const size = enforceNumber(newVal)
if (size < 1) {
formOfSetting.value.logFileSizeLimit = 1
saveConfig({ [configPaths.settings.logFileSizeLimit]: 1 })
} else {
saveConfig({ [configPaths.settings.logFileSizeLimit]: size })
}
},
)
watch(
() => formOfSetting.value.logLevel,
newVal => {
if (newVal.length === 0) {
message.error(t('pages.settings.advanced.chooseLogLevel'))
return
}
saveConfig({
[configPaths.settings.logLevel]: newVal,
})
},
)
}
/* methods */
async function cancelSyncSetting() {
syncVisible.value = false
sync.value = (await getConfig(configPaths.settings.sync)) || {
type: 'github',
username: '',
repo: '',
branch: '',
token: '',
endpoint: '',
proxy: '',
interval: 60,
// WebDAV-specific fields
webdavEndpoint: '',
webdavUsername: '',
webdavPassword: '',
webdavAuthType: 'basic',
webdavSslEnabled: true,
webdavSavePath: '',
}
}
function confirmSyncSetting() {
saveConfig({ [configPaths.settings.sync]: sync.value })
syncVisible.value = false
}
function tabClick(tabId: string) {
currentTab.value = tabId as 'system' | 'sync' | 'upload' | 'advanced' | 'update'
}
async function loadThemes() {
try {
const themes = await window.electron.triggerRPC<{ key: string; label: string }[]>(
IRPCActionType.THEME_RESOLVE_THEMES,
)
if (themes && themes.length > 0) {
const sortedThemes = themes.sort((a, b) => {
if (a.key === 'default.css') return -1
if (b.key === 'default.css') return 1
return a.label.localeCompare(b.label)
})
themeList.value = sortedThemes.map(theme => ({
value: theme.key,
label: theme.label,
}))
}
} catch (error) {
console.error('Failed to load themes:', error)
}
}
async function handleDownloadThemes() {
try {
downloadingThemes.value = true
const result = await window.electron.triggerRPC(IRPCActionType.THEME_FETCH_THEMES)
if (!result) {
throw new Error('No themes were downloaded.')
}
message.success(t('pages.settings.system.downloadThemesSuccess'))
await loadThemes()
} catch (error) {
console.error('Failed to download themes:', error)
message.error(t('pages.settings.system.downloadThemesFailed'))
} finally {
downloadingThemes.value = false
}
}
async function handleImportThemes() {
try {
const result = await window.electron.triggerRPC<string[]>(IRPCActionType.MANAGE_OPEN_FILE_SELECT_DIALOG, {
title: t('pages.settings.system.importThemes'),
filters: [{ name: 'CSS Files', extensions: ['css'] }],
properties: ['openFile', 'multiSelections'],
})
if (result && result.length > 0) {
await window.electron.triggerRPC(IRPCActionType.THEME_IMPORT_THEMES, result)
message.success(t('pages.settings.system.importThemesSuccess'))
await loadThemes()
}
} catch (error) {
console.error('Failed to import themes:', error)
message.error(t('pages.settings.system.importThemesFailed'))
}
}
async function handleThemeChange(theme: string) {
try {
await window.electron.triggerRPC(IRPCActionType.THEME_APPLY_THEME, theme)
saveConfig({ [configPaths.settings.theme]: theme })
} catch (error) {
console.error('Failed to apply theme:', error)
message.error(t('pages.settings.system.applyThemeFailed'))
}
}
function handleIsDisableGPUChange(value: boolean | undefined) {
if (value === undefined) return
message.info(t('pages.settings.system.needRestart'))
saveConfig({ [configPaths.settings.isDisableGPU]: value })
}
async function initData() {
const config = (await getConfig<IConfig>()) || ({} as IConfig)
const settings = config.settings || {}
const picBed = config.picBed
isDisableGPU.value = settings.isDisableGPU || false
isPortable.value = (await window.electron.triggerRPC<boolean>(IRPCActionType.GET_IS_PORTABLE)) || false
showPicBedList.value = picBedG.value.filter(item => item.visible).map(item => item.type)
galleryPicBedFilterList.value = settings.galleryPicBedFilter || []
currentTheme.value = settings.theme || 'default.css'
loadThemes()
formKeys.forEach(key => {
;(formOfSetting.value as any)[key] = settings[key] ?? formOfSetting.value[key]
})
try {
const actualAutoStartStatus = await window.electron.triggerRPC<boolean>(IRPCActionType.PICLIST_AUTO_START_STATUS)
if (typeof actualAutoStartStatus === 'boolean') {
formOfSetting.value.autoStart = actualAutoStartStatus
if (actualAutoStartStatus !== settings.autoStart) {
saveConfig({ [configPaths.settings.autoStart]: actualAutoStartStatus })
}
}
} catch (error) {
formOfSetting.value.autoStart = settings.autoStart ?? false
}
formOfSetting.value.logLevel = initArray(settings.logLevel || [], ['all'])
formOfSetting.value.autoImportPicBed = initArray(settings.autoImportPicBed || [], [])
currentLanguage.value = settings.language || 'zh-CN'
currentStartMode.value = settings.startMode || ISartMode.QUIET
currentSecondMode.value = settings.secondPicBedMode || 'backup'
if (osGlobal.value === 'darwin' && currentStartMode.value === ISartMode.MINI) {
currentStartMode.value = ISartMode.QUIET
saveConfig(configPaths.settings.startMode, ISartMode.QUIET)
}
currentShortUrlServer.value = settings.shortUrlServer || 'c1n'
customLink.value = settings.customLink || '![$fileName]($url)'
proxy.value = picBed.proxy || ''
server.value = settings.server || { port: 36677, host: '0.0.0.0', enable: true }
advancedRename.value = config.buildIn?.rename || { enable: false, format: '{filename}' }
if (advancedRename.value.enable) {
formOfSetting.value.autoRename = false
saveConfig({ [configPaths.settings.autoRename]: false })
}
sync.value = settings.sync || {
type: 'github',
username: '',
repo: '',
branch: '',
token: '',
endpoint: '',
proxy: '',
interval: 60,
// WebDAV-specific fields
webdavEndpoint: '',
webdavUsername: '',
webdavPassword: '',
webdavAuthType: 'basic',
webdavSslEnabled: true,
webdavSavePath: '',
}
formOfSetting.value.logFileSizeLimit = enforceNumber(settings.logFileSizeLimit) || 10
addWatch()
fetchReleaseNotes()
}
function initArray(arrayT: string | string[], defaultValue: string[]) {
if (!Array.isArray(arrayT)) {
if (arrayT && arrayT.length > 0) {
arrayT = [arrayT]
} else {
arrayT = defaultValue
}
}
return arrayT
}
async function handleChangeSecondPicBed() {
window.electron.sendRPC(IRPCActionType.SHOW_SECOND_UPLOADER_MENU)
}
function openFile(file: string) {
window.electron.sendRPC(IRPCActionType.PICLIST_OPEN_FILE, file)
}
function openDirectory(directory?: string, inStorePath = true) {
window.electron.sendRPC(IRPCActionType.PICLIST_OPEN_DIRECTORY, directory, inStorePath)
}
function openLogSetting() {
logFileVisible.value = true
}
function handleMigrateFromPicGo() {
confirm({
title: t('pages.settings.sync.mirgrateTitle'),
message: t('pages.settings.sync.mirgrateContent'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true,
}).then(result => {
if (result) {
window.electron
.triggerRPC<boolean>(IRPCActionType.CONFIGURE_MIGRATE_FROM_PICGO)
.then(() => {
message.success(t('pages.settings.sync.mirgrateSuccess'))
})
.catch(() => {
message.error(t('pages.settings.sync.mirgrateFailed'))
})
}
})
}
function handleMigrateFromPicListInstallation() {
confirm({
title: t('pages.settings.sync.mirgrateTitle'),
message: t('pages.settings.sync.migrateFromPicListInstallationContent'),
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
center: true,
}).then(result => {
if (result) {
window.electron
.triggerRPC<boolean>(IRPCActionType.CONFIGURE_MIGRATE_FROM_PICLIST_INSTALLATION)
.then(() => {
message.success(t('pages.settings.sync.mirgrateSuccess'))
})
.catch(() => {
message.error(t('pages.settings.sync.mirgrateFailed'))
})
}
})
}
function handleHideDockChange(val: ICheckBoxValueType) {
if (val && currentStartMode.value === ISartMode.NO_TRAY) {
message.warning(t('pages.settings.system.hideDockHint'))
formOfSetting.value.isHideDock = false
return
}
saveConfig(configPaths.settings.isHideDock, val)
window.electron.sendRPC(IRPCActionType.HIDE_DOCK, val)
}
function handleShowPicBedListChange(val: ICheckBoxValueType[]) {
try {
const list = picBedG.value.map(item => ({ ...item, visible: val.includes(item.type) }))
saveConfig({ [configPaths.picBed.list]: list })
nextTick(() => {
updatePicBeds()
})
} catch (error) {
console.error('Error updating PicBed visibility:', error)
}
}
function handleGalleryPicBedFilterChange(val: ICheckBoxValueType[]) {
saveConfig({ [configPaths.settings.galleryPicBedFilter]: val })
}
function handleAutoStartChange(val: ICheckBoxValueType) {
saveConfig(configPaths.settings.autoStart, val)
window.electron.sendRPC(IRPCActionType.PICLIST_AUTO_START, val)
}
function compareVersion2Update(current: string, latest: string): boolean {
return compare(current, latest, '<')
}
function formatLastFetchTime(date: Date): string {
const now = new Date()
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60))
if (diffInMinutes < 1) {
return t('pages.settings.update.justNow')
} else if (diffInMinutes < 60) {
return t('pages.settings.update.minutesAgo', { minutes: diffInMinutes })
} else {
const hours = Math.floor(diffInMinutes / 60)
if (hours < 24) {
return t('pages.settings.update.hoursAgo', { hours })
} else {
const days = Math.floor(hours / 24)
return t('pages.settings.update.daysAgo', { days })
}
}
}
async function fetchReleaseNotes(forceRefresh = false): Promise<void> {
if (!forceRefresh && releaseNotesLastFetch.value) {
const timeSinceLastFetch = Date.now() - releaseNotesLastFetch.value.getTime()
if (timeSinceLastFetch < RELEASE_NOTES_CACHE_DURATION) {
return
}
}
try {
fetchingReleaseNotes.value = true
releaseNotesError.value = ''
const isEnglish = currentLanguage.value === 'en'
const fileName = isEnglish ? 'currentVersion_en.md' : 'currentVersion.md'
const url = `https://raw.githubusercontent.com/Kuingsmile/piclist/dev/${fileName}`
const response = await fetch(url)
if (response.ok) {
const content = await response.text()
releaseNotes.value = content
releaseNotesLastFetch.value = new Date()
releaseNotesError.value = ''
} else {
throw new Error(`HTTP ${response.status}`)
}
} catch (error) {
console.error('Failed to fetch release notes:', error)
releaseNotesError.value = t('pages.settings.update.releaseNotesError')
} finally {
fetchingReleaseNotes.value = false
}
}
async function fetchReleaseNotesManually(): Promise<void> {
await fetchReleaseNotes(true)
}
async function checkUpdate() {
checkUpdateVisible.value = true
latestVersion.value = (await getLatestVersion()) || t('pages.settings.update.networkError')
}
function confirmCheckVersion() {
if (needUpdate.value) {
window.electron.sendRPC(IRPCActionType.RELOAD_APP)
}
checkUpdateVisible.value = false
}
function cancelCheckVersion() {
checkUpdateVisible.value = false
}
function confirmWebServerSetting() {
if (formOfSetting.value.enableWebServer) {
window.electron.sendRPC(IRPCActionType.ADVANCED_RESTART_WEB_SERVER)
} else {
window.electron.sendRPC(IRPCActionType.ADVANCED_STOP_WEB_SERVER)
}
webServerVisible.value = false
}
function handleMiniWindowOntop(val: ICheckBoxValueType) {
saveConfig(configPaths.settings.miniWindowOntop, val)
window.electron.sendRPC(IRPCActionType.MINI_WINDOW_ON_TOP, val)
}
async function handleMiniIconPath() {
const result = await window.electron.triggerRPC<string[]>(IRPCActionType.MANAGE_OPEN_FILE_SELECT_DIALOG)
if (result && result[0]) {
formOfSetting.value.customMiniIcon = result[0]
saveConfig(configPaths.settings.customMiniIcon, formOfSetting.value.customMiniIcon)
window.electron.sendRPC(IRPCActionType.UPDATE_MINI_WINDOW_ICON, formOfSetting.value.customMiniIcon)
}
}
function handleShortUrlServerChange(val: string) {
formOfSetting.value.shortUrlServer = val
saveConfig(configPaths.settings.shortUrlServer, val)
}
function handleAesPasswordChange(val: string) {
saveConfig(configPaths.settings.aesPassword, val || 'PicList-aesPassword')
}
function syncMessage(failed: number) {
if (failed) {
message.error(t('pages.settings.sync.syncResult.failed'))
} else {
message.success(t('pages.settings.sync.syncResult.success'))
}
}
async function syncTaskFn(task: string, number: number) {
const failed = number - ((await window.electron.triggerRPC<number>(task)) || 0)
syncMessage(failed)
}
function confirmServerSetting() {
server.value.port = parseInt(server.value.port as unknown as string, 10)
saveConfig({ [configPaths.settings.server]: server.value })
serverVisible.value = false
window.electron.sendRPC(IRPCActionType.ADVANCED_UPDATE_SERVER)
}
async function cancelServerSetting() {
serverVisible.value = false
server.value = (await getConfig(configPaths.settings.server)) || { port: 36677, host: '0.0.0.0', enable: true }
}
function handleLanguageChange(val: string) {
locale.value = val
setCurrentLanguage(val)
saveConfig({ [configPaths.settings.language]: val })
localStorage.setItem('currentLanguage', val)
updatePicBeds()
}
function handleStartModeChange(val: string) {
if (val === ISartMode.NO_TRAY) {
if (formOfSetting.value.isHideDock) {
message.warning(t('pages.settings.system.hideDockHint'))
currentStartMode.value = ISartMode.QUIET
return
}
message.info(t('pages.settings.system.needRestart'))
}
saveConfig({ [configPaths.settings.startMode]: val })
}
async function goConfigPage() {
const lang = (await getConfig(configPaths.settings.language)) || II18nLanguage.ZH_CN
const url = `https://piclist.cn/${lang === II18nLanguage.EN ? 'en/' : ''}configure.html`
window.electron.sendRPC(IRPCActionType.OPEN_URL, url)
}
function goShortCutPage() {
$router.push({ name: SHORTKEY_PAGE })
}
/* lifecycle hooks */
onBeforeMount(() => {
initData()
})
let unbindTheme: () => void
onMounted(() => {
unbindTheme = window.electron.onThemeUpdate((_: string) => {
console.log('Applying theme CSS update:')
})
})
onBeforeUnmount(() => {
if (unbindTheme) {
unbindTheme()
}
})
</script>
<script lang="ts">
export default { name: 'SettingPage' }
</script>
<style scoped src="./css/PicgoSetting.css"></style>