mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 14:50:50 +08:00
1223 lines
41 KiB
Vue
1223 lines
41 KiB
Vue
<template>
|
||
<div class="settings-container">
|
||
<!-- 左侧导航栏 -->
|
||
<div v-if="!isMobile" class="settings-nav">
|
||
<div
|
||
v-for="section in settingSections"
|
||
:key="section.id"
|
||
class="nav-item"
|
||
:class="{ active: currentSection === section.id }"
|
||
@click="scrollToSection(section.id)"
|
||
>
|
||
{{ t(`settings.sections.${section.id}`) }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧内容区 -->
|
||
<n-scrollbar ref="scrollbarRef" class="settings-content" @scroll="handleScroll">
|
||
<div class="set-page">
|
||
<!-- 基础设置 -->
|
||
<div id="basic" ref="basicRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.basic') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.basic.themeMode') }}</div>
|
||
<div class="set-item-content">{{ t('settings.basic.themeModeDesc') }}</div>
|
||
</div>
|
||
<n-switch v-model:value="isDarkTheme">
|
||
<template #checked><i class="ri-moon-line"></i></template>
|
||
<template #unchecked><i class="ri-sun-line"></i></template>
|
||
</n-switch>
|
||
</div>
|
||
|
||
<!-- 语言设置 -->
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.basic.language') }}</div>
|
||
<div class="set-item-content">{{ t('settings.basic.languageDesc') }}</div>
|
||
</div>
|
||
<language-switcher />
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.basic.font') }}</div>
|
||
<div class="set-item-content">{{ t('settings.basic.fontDesc') }}</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<n-radio-group v-model:value="setData.fontScope" class="mt-2">
|
||
<n-radio key="global" value="global">{{
|
||
t('settings.basic.fontScope.global')
|
||
}}</n-radio>
|
||
<n-radio key="lyric" value="lyric">{{
|
||
t('settings.basic.fontScope.lyric')
|
||
}}</n-radio>
|
||
</n-radio-group>
|
||
<n-select
|
||
v-model:value="selectedFonts"
|
||
:options="systemFonts"
|
||
filterable
|
||
multiple
|
||
placeholder="选择字体"
|
||
style="width: 300px"
|
||
:render-label="renderFontLabel"
|
||
>
|
||
</n-select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedFonts.length > 0" class="font-preview-container">
|
||
<div class="font-preview-title">{{ t('settings.basic.fontPreview.title') }}</div>
|
||
<div class="font-preview" :style="{ fontFamily: setData.fontFamily }">
|
||
<div class="preview-item">
|
||
<div class="preview-label">{{ t('settings.basic.fontPreview.chinese') }}</div>
|
||
<div class="preview-text">{{ t('settings.basic.fontPreview.chineseText') }}</div>
|
||
</div>
|
||
<div class="preview-item">
|
||
<div class="preview-label">{{ t('settings.basic.fontPreview.english') }}</div>
|
||
<div class="preview-text">{{ t('settings.basic.fontPreview.englishText') }}</div>
|
||
</div>
|
||
<div class="preview-item">
|
||
<div class="preview-label">{{ t('settings.basic.fontPreview.japanese') }}</div>
|
||
<div class="preview-text">{{ t('settings.basic.fontPreview.japaneseText') }}</div>
|
||
</div>
|
||
<div class="preview-item">
|
||
<div class="preview-label">{{ t('settings.basic.fontPreview.korean') }}</div>
|
||
<div class="preview-text">{{ t('settings.basic.fontPreview.koreanText') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.basic.animation') }}</div>
|
||
<div class="set-item-content">
|
||
<div class="flex items-center gap-2">
|
||
<n-switch v-model:value="setData.noAnimate">
|
||
<template #checked>{{ t('common.off') }}</template>
|
||
<template #unchecked>{{ t('common.on') }}</template>
|
||
</n-switch>
|
||
<span>{{ t('settings.basic.animationDesc') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||
<div class="w-60">
|
||
<n-slider
|
||
v-model:value="setData.animationSpeed"
|
||
:min="0.1"
|
||
:max="3"
|
||
:step="0.1"
|
||
:marks="{
|
||
0.1: t('settings.basic.animationSpeed.slow'),
|
||
1: t('settings.basic.animationSpeed.normal'),
|
||
3: t('settings.basic.animationSpeed.fast')
|
||
}"
|
||
:disabled="setData.noAnimate"
|
||
class="w-40"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 播放设置 -->
|
||
<div id="playback" ref="playbackRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.playback') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.playback.quality') }}</div>
|
||
<div class="set-item-content">{{ t('settings.playback.qualityDesc') }}</div>
|
||
</div>
|
||
<n-select
|
||
v-model:value="setData.musicQuality"
|
||
:options="[
|
||
{ label: t('settings.playback.qualityOptions.standard'), value: 'standard' },
|
||
{ label: t('settings.playback.qualityOptions.higher'), value: 'higher' },
|
||
{ label: t('settings.playback.qualityOptions.exhigh'), value: 'exhigh' },
|
||
{ label: t('settings.playback.qualityOptions.lossless'), value: 'lossless' },
|
||
{ label: t('settings.playback.qualityOptions.hires'), value: 'hires' },
|
||
{ label: t('settings.playback.qualityOptions.jyeffect'), value: 'jyeffect' },
|
||
{ label: t('settings.playback.qualityOptions.sky'), value: 'sky' },
|
||
{ label: t('settings.playback.qualityOptions.dolby'), value: 'dolby' },
|
||
{ label: t('settings.playback.qualityOptions.jymaster'), value: 'jymaster' }
|
||
]"
|
||
style="width: 160px"
|
||
/>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.playback.musicSources') }}</div>
|
||
<div class="set-item-content">
|
||
<div class="flex items-center gap-2">
|
||
<n-switch v-model:value="setData.enableMusicUnblock">
|
||
<template #checked>{{ t('common.on') }}</template>
|
||
<template #unchecked>{{ t('common.off') }}</template>
|
||
</n-switch>
|
||
<span>{{ t('settings.playback.musicUnblockEnableDesc') }}</span>
|
||
</div>
|
||
<div v-if="setData.enableMusicUnblock" class="mt-2">
|
||
<div class="text-sm">
|
||
<span class="text-gray-500">{{ t('settings.playback.selectedMusicSources') }}</span>
|
||
<span v-if="musicSources.length > 0" class="text-gray-400">
|
||
{{ musicSources.map((source) => getSourceLabel(source)).join(', ') }}
|
||
</span>
|
||
<span v-else class="text-red-500 text-xs">
|
||
{{ t('settings.playback.noMusicSources') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<n-button
|
||
size="small"
|
||
:disabled="!setData.enableMusicUnblock"
|
||
@click="showMusicSourcesModal = true"
|
||
>
|
||
{{ t('settings.playback.configureMusicSources') }}
|
||
</n-button>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.playback.autoPlay') }}</div>
|
||
<div class="set-item-content">{{ t('settings.playback.autoPlayDesc') }}</div>
|
||
</div>
|
||
<n-switch v-model:value="setData.autoPlay">
|
||
<template #checked>{{ t('common.on') }}</template>
|
||
<template #unchecked>{{ t('common.off') }}</template>
|
||
</n-switch>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 应用设置 -->
|
||
<div v-if="isElectron" id="application" ref="applicationRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.application') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.closeAction') }}</div>
|
||
<div class="set-item-content">{{ t('settings.application.closeActionDesc') }}</div>
|
||
</div>
|
||
<n-select
|
||
v-model:value="setData.closeAction"
|
||
:options="[
|
||
{ label: t('settings.application.closeOptions.ask'), value: 'ask' },
|
||
{ label: t('settings.application.closeOptions.minimize'), value: 'minimize' },
|
||
{ label: t('settings.application.closeOptions.close'), value: 'close' }
|
||
]"
|
||
style="width: 160px"
|
||
/>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.shortcut') }}</div>
|
||
<div class="set-item-content">{{ t('settings.application.shortcutDesc') }}</div>
|
||
</div>
|
||
<n-button size="small" @click="showShortcutModal = true">{{
|
||
t('common.configure')
|
||
}}</n-button>
|
||
</div>
|
||
|
||
<div v-if="isElectron" class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.download') }}</div>
|
||
<div class="set-item-content">
|
||
<n-switch v-model:value="setData.alwaysShowDownloadButton" class="mr-2">
|
||
<template #checked>{{ t('common.show') }}</template>
|
||
<template #unchecked>{{ t('common.hide') }}</template>
|
||
</n-switch>
|
||
{{ t('settings.application.downloadDesc') }}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<n-button size="small" @click="settingsStore.showDownloadDrawer = true">
|
||
{{ t('settings.application.download') }}
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.unlimitedDownload') }}</div>
|
||
<div class="set-item-content">
|
||
<n-switch v-model:value="setData.unlimitedDownload" class="mr-2">
|
||
<template #checked>{{ t('common.on') }}</template>
|
||
<template #unchecked>{{ t('common.off') }}</template>
|
||
</n-switch>
|
||
{{ t('settings.application.unlimitedDownloadDesc') }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.downloadPath') }}</div>
|
||
<div class="set-item-content">
|
||
{{ setData.downloadPath || t('settings.application.downloadPathDesc') }}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<n-button size="small" @click="openDownloadPath">{{ t('common.open') }}</n-button>
|
||
<n-button size="small" @click="selectDownloadPath">{{
|
||
t('common.modify')
|
||
}}</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.application.remoteControl') }}</div>
|
||
<div class="set-item-content">{{ t('settings.application.remoteControlDesc') }}</div>
|
||
</div>
|
||
<n-button size="small" @click="showRemoteControlModal = true">{{
|
||
t('common.configure')
|
||
}}</n-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 网络设置 -->
|
||
<div v-if="isElectron" id="network" ref="networkRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.network') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.network.apiPort') }}</div>
|
||
<div class="set-item-content">{{ t('settings.network.apiPortDesc') }}</div>
|
||
</div>
|
||
<n-input-number v-model:value="setData.musicApiPort" />
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.network.proxy') }}</div>
|
||
<div class="set-item-content">{{ t('settings.network.proxyDesc') }}</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<n-switch v-model:value="setData.proxyConfig.enable">
|
||
<template #checked>{{ t('common.on') }}</template>
|
||
<template #unchecked>{{ t('common.off') }}</template>
|
||
</n-switch>
|
||
<n-button size="small" @click="showProxyModal = true">{{
|
||
t('common.configure')
|
||
}}</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.network.realIP') }}</div>
|
||
<div class="set-item-content">{{ t('settings.network.realIPDesc') }}</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<n-switch v-model:value="setData.enableRealIP">
|
||
<template #checked>{{ t('common.on') }}</template>
|
||
<template #unchecked>{{ t('common.off') }}</template>
|
||
</n-switch>
|
||
<n-input
|
||
v-if="setData.enableRealIP"
|
||
v-model:value="setData.realIP"
|
||
placeholder="realIP"
|
||
style="width: 200px"
|
||
@blur="validateAndSaveRealIP"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统管理 -->
|
||
<div v-if="isElectron" id="system" ref="systemRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.system') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.system.cache') }}</div>
|
||
<div class="set-item-content">{{ t('settings.system.cacheDesc') }}</div>
|
||
</div>
|
||
<n-button size="small" @click="showClearCacheModal = true">
|
||
{{ t('settings.system.cacheDesc') }}
|
||
</n-button>
|
||
</div>
|
||
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.system.restart') }}</div>
|
||
<div class="set-item-content">{{ t('settings.system.restartDesc') }}</div>
|
||
</div>
|
||
<n-button size="small" @click="restartApp">{{
|
||
t('settings.system.restart')
|
||
}}</n-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 关于 -->
|
||
<div id="about" ref="aboutRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.regard') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.about.version') }}</div>
|
||
<div class="set-item-content">
|
||
{{ updateInfo.currentVersion }}
|
||
<template v-if="updateInfo.hasUpdate">
|
||
<n-tag type="success" class="ml-2">
|
||
{{ t('settings.about.hasUpdate') }} {{ updateInfo.latestVersion }}
|
||
</n-tag>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<n-button size="small" :loading="checking" @click="checkForUpdates(true)">
|
||
{{ checking ? t('settings.about.checking') : t('settings.about.checkUpdate') }}
|
||
</n-button>
|
||
<n-button v-if="updateInfo.hasUpdate" size="small" @click="openReleasePage">
|
||
{{ t('settings.about.gotoUpdate') }}
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all"
|
||
@click="openAuthor"
|
||
>
|
||
<coffee>
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.about.author') }}</div>
|
||
<div class="set-item-content">{{ t('settings.about.authorDesc') }}</div>
|
||
</div>
|
||
</coffee>
|
||
<div>
|
||
<n-button size="small" @click="openAuthor">
|
||
<i class="ri-github-line"></i>{{ t('settings.about.gotoGithub') }}
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 捐赠支持 -->
|
||
<div id="donation" ref="donationRef" class="settings-section">
|
||
<div class="settings-section-title">{{ t('settings.sections.donation') }}</div>
|
||
<div class="settings-section-content">
|
||
<div class="set-item">
|
||
<div>
|
||
<div class="set-item-title">{{ t('settings.sections.donation') }}</div>
|
||
<div class="set-item-content">{{ t('donation.message') }}</div>
|
||
</div>
|
||
<n-button text @click="toggleDonationList">
|
||
<template #icon>
|
||
<i :class="isDonationListVisible ? 'ri-eye-line' : 'ri-eye-off-line'" />
|
||
</template>
|
||
{{ isDonationListVisible ? t('common.hide') : t('common.show') }}
|
||
</n-button>
|
||
</div>
|
||
<donation-list v-if="isDonationListVisible" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<play-bottom />
|
||
</n-scrollbar>
|
||
|
||
<!-- 快捷键设置弹窗 -->
|
||
<shortcut-settings v-model:show="showShortcutModal" @change="handleShortcutsChange" />
|
||
|
||
<!-- 代理设置弹窗 -->
|
||
<n-modal
|
||
v-model:show="showProxyModal"
|
||
preset="dialog"
|
||
:title="t('settings.network.proxy')"
|
||
:positive-text="t('common.confirm')"
|
||
:negative-text="t('common.cancel')"
|
||
:show-icon="false"
|
||
@positive-click="handleProxyConfirm"
|
||
@negative-click="showProxyModal = false"
|
||
>
|
||
<n-form
|
||
ref="formRef"
|
||
:model="proxyForm"
|
||
:rules="proxyRules"
|
||
label-placement="left"
|
||
label-width="80"
|
||
require-mark-placement="right-hanging"
|
||
>
|
||
<n-form-item :label="t('settings.network.proxy')" path="protocol">
|
||
<n-select
|
||
v-model:value="proxyForm.protocol"
|
||
:options="[
|
||
{ label: 'HTTP', value: 'http' },
|
||
{ label: 'HTTPS', value: 'https' },
|
||
{ label: 'SOCKS5', value: 'socks5' }
|
||
]"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="t('settings.network.proxyHost')" path="host">
|
||
<n-input
|
||
v-model:value="proxyForm.host"
|
||
:placeholder="t('settings.network.proxyHostPlaceholder')"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item :label="t('settings.network.proxyPort')" path="port">
|
||
<n-input-number
|
||
v-model:value="proxyForm.port"
|
||
:placeholder="t('settings.network.proxyPortPlaceholder')"
|
||
:min="1"
|
||
:max="65535"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-modal>
|
||
<!-- 清除缓存弹窗 -->
|
||
<n-modal
|
||
v-model:show="showClearCacheModal"
|
||
preset="dialog"
|
||
:title="t('settings.system.cache')"
|
||
:positive-text="t('common.confirm')"
|
||
:negative-text="t('common.cancel')"
|
||
@positive-click="clearCache"
|
||
@negative-click="
|
||
() => {
|
||
selectedCacheTypes = [];
|
||
}
|
||
"
|
||
>
|
||
<n-space vertical>
|
||
<p>{{ t('settings.system.cacheClearTitle') }}</p>
|
||
<n-checkbox-group v-model:value="selectedCacheTypes">
|
||
<n-space vertical>
|
||
<n-checkbox
|
||
v-for="option in clearCacheOptions"
|
||
:key="option.key"
|
||
:value="option.key"
|
||
:label="option.label"
|
||
>
|
||
<template #default>
|
||
<div>
|
||
<div>{{ t(`settings.system.cacheTypes.${option.key}.label`) }}</div>
|
||
<div class="text-gray-400 text-sm">
|
||
{{ t(`settings.system.cacheTypes.${option.key}.description`) }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</n-checkbox>
|
||
</n-space>
|
||
</n-checkbox-group>
|
||
</n-space>
|
||
</n-modal>
|
||
|
||
<!-- 音源设置弹窗 -->
|
||
<n-modal
|
||
v-model:show="showMusicSourcesModal"
|
||
preset="dialog"
|
||
:title="t('settings.playback.musicSources')"
|
||
:positive-text="t('common.confirm')"
|
||
:negative-text="t('common.cancel')"
|
||
@positive-click="showMusicSourcesModal = false"
|
||
@negative-click="showMusicSourcesModal = false"
|
||
>
|
||
<n-space vertical>
|
||
<p>{{ t('settings.playback.musicSourcesDesc') }}</p>
|
||
<n-checkbox-group v-model:value="musicSources">
|
||
<n-grid :cols="2" :x-gap="12" :y-gap="8">
|
||
<n-grid-item v-for="source in musicSourceOptions" :key="source.value">
|
||
<n-checkbox :value="source.value">
|
||
{{ source.label }}
|
||
<template v-if="source.value === 'gdmusic'">
|
||
<n-tooltip>
|
||
<template #trigger>
|
||
<n-icon size="16" class="ml-1 text-blue-500 cursor-help">
|
||
<i class="ri-information-line"></i>
|
||
</n-icon>
|
||
</template>
|
||
{{ t('settings.playback.gdmusicInfo') }}
|
||
</n-tooltip>
|
||
</template>
|
||
</n-checkbox>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
</n-checkbox-group>
|
||
<div v-if="musicSources.length === 0" class="text-red-500 text-sm">
|
||
{{ t('settings.playback.musicSourcesWarning') }}
|
||
</div>
|
||
|
||
<!-- GD音乐台设置 -->
|
||
<div v-if="musicSources.includes('gdmusic')" class="mt-4 border-t pt-4 border-gray-200 dark:border-gray-700">
|
||
<h3 class="text-base font-medium mb-2">GD音乐台(music.gdstudio.xyz)设置</h3>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||
GD音乐台将自动尝试多个音乐平台进行解析,无需额外配置。优先级高于其他解析方式,但是请求可能较慢。感谢(music.gdstudio.xyz)
|
||
</p>
|
||
</div>
|
||
</n-space>
|
||
</n-modal>
|
||
|
||
<!-- 远程控制设置弹窗 -->
|
||
<remote-control-setting v-model:visible="showRemoteControlModal" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useDebounceFn } from '@vueuse/core';
|
||
import type { FormRules } from 'naive-ui';
|
||
import { useMessage } from 'naive-ui';
|
||
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
|
||
import localData from '@/../main/set.json';
|
||
import Coffee from '@/components/Coffee.vue';
|
||
import DonationList from '@/components/common/DonationList.vue';
|
||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue';
|
||
import ShortcutSettings from '@/components/settings/ShortcutSettings.vue';
|
||
import { useSettingsStore } from '@/store/modules/settings';
|
||
import { useUserStore } from '@/store/modules/user';
|
||
import { isElectron, isMobile } from '@/utils';
|
||
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
|
||
import { checkUpdate, UpdateResult } from '@/utils/update';
|
||
import RemoteControlSetting from '@/views/setting/ServerSetting.vue';
|
||
|
||
import config from '../../../../package.json';
|
||
|
||
// 手动定义Platform类型,避免从主进程导入的问题
|
||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube' | 'gdmusic';
|
||
// 所有平台
|
||
const ALL_PLATFORMS: Platform[] = ['migu', 'kugou', 'pyncmd', 'bilibili', 'youtube'];
|
||
|
||
const settingsStore = useSettingsStore();
|
||
const userStore = useUserStore();
|
||
|
||
// 创建一个本地缓存的setData,避免频繁更新
|
||
const localSetData = ref({ ...settingsStore.setData });
|
||
|
||
// 在组件卸载时保存设置
|
||
onUnmounted(() => {
|
||
// 确保最终设置被保存
|
||
settingsStore.setSetData(localSetData.value);
|
||
});
|
||
|
||
const checking = ref(false);
|
||
const updateInfo = ref<UpdateResult>({
|
||
hasUpdate: false,
|
||
latestVersion: '',
|
||
currentVersion: config.version,
|
||
releaseInfo: null
|
||
});
|
||
|
||
const { t } = useI18n();
|
||
|
||
// 创建一个防抖的保存函数
|
||
// const debouncedSaveSettings = debounce((newData) => {
|
||
// settingsStore.setSetData(newData);
|
||
// }, 500);
|
||
|
||
const saveSettings = useDebounceFn((data) => {
|
||
settingsStore.setSetData(data);
|
||
}, 500);
|
||
|
||
// 使用计算属性来管理设置数据
|
||
const setData = computed({
|
||
get: () => localSetData.value,
|
||
set: (newData) => {
|
||
localSetData.value = newData;
|
||
}
|
||
});
|
||
|
||
// 监听localSetData变化,保存设置
|
||
watch(
|
||
() => localSetData.value,
|
||
(newValue) => {
|
||
saveSettings(newValue);
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 监听store中setData的变化,同步到本地
|
||
watch(
|
||
() => settingsStore.setData,
|
||
(newValue) => {
|
||
// 只在初始加载时更新本地数据,避免循环更新
|
||
if (JSON.stringify(localSetData.value) !== JSON.stringify(newValue)) {
|
||
localSetData.value = { ...newValue };
|
||
}
|
||
},
|
||
{ deep: true, immediate: true }
|
||
);
|
||
|
||
const isDarkTheme = computed({
|
||
get: () => settingsStore.theme === 'dark',
|
||
set: () => settingsStore.toggleTheme()
|
||
});
|
||
|
||
const openAuthor = () => {
|
||
window.open(setData.value.authorUrl);
|
||
};
|
||
|
||
const restartApp = () => {
|
||
window.electron.ipcRenderer.send('restart');
|
||
};
|
||
const message = useMessage();
|
||
const checkForUpdates = async (isClick = false) => {
|
||
checking.value = true;
|
||
try {
|
||
const result = await checkUpdate(config.version);
|
||
if (result) {
|
||
updateInfo.value = result;
|
||
if (!result.hasUpdate && isClick) {
|
||
message.success(t('settings.about.latest'));
|
||
}
|
||
} else if (isClick) {
|
||
message.success(t('settings.about.latest'));
|
||
}
|
||
} catch (error) {
|
||
console.error('检查更新失败:', error);
|
||
if (isClick) {
|
||
message.error(t('settings.about.messages.checkError'));
|
||
}
|
||
} finally {
|
||
checking.value = false;
|
||
}
|
||
};
|
||
|
||
const openReleasePage = () => {
|
||
settingsStore.showUpdateModal = true;
|
||
};
|
||
|
||
const selectDownloadPath = async () => {
|
||
const path = await selectDirectory(message);
|
||
if (path) {
|
||
setData.value = {
|
||
...setData.value,
|
||
downloadPath: path
|
||
};
|
||
}
|
||
};
|
||
|
||
const openDownloadPath = () => {
|
||
openDirectory(setData.value.downloadPath, message);
|
||
};
|
||
|
||
const showProxyModal = ref(false);
|
||
const formRef = ref();
|
||
const proxyForm = ref({
|
||
protocol: 'http',
|
||
host: '127.0.0.1',
|
||
port: 7890
|
||
});
|
||
|
||
const proxyRules: FormRules = {
|
||
protocol: {
|
||
required: true,
|
||
message: t('settings.validation.selectProxyProtocol'),
|
||
trigger: ['blur', 'change']
|
||
},
|
||
host: {
|
||
required: true,
|
||
message: t('settings.validation.proxyHost'),
|
||
trigger: ['blur', 'change'],
|
||
validator: (_rule, value) => {
|
||
if (!value) return false;
|
||
// 简单的IP或域名验证
|
||
const ipRegex =
|
||
/^(\d{1,3}\.){3}\d{1,3}$|^localhost$|^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$/;
|
||
return ipRegex.test(value);
|
||
}
|
||
},
|
||
port: {
|
||
required: true,
|
||
message: t('settings.validation.portNumber'),
|
||
trigger: ['blur', 'change'],
|
||
validator: (_rule, value) => {
|
||
return value >= 1 && value <= 65535;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 使用 store 中的字体列表
|
||
const systemFonts = computed(() => settingsStore.systemFonts);
|
||
|
||
// 已选择的字体列表
|
||
const selectedFonts = ref<string[]>([]);
|
||
|
||
// 自定义渲染函数
|
||
const renderFontLabel = (option: { label: string; value: string }) => {
|
||
return h('span', { style: { fontFamily: option.value } }, option.label);
|
||
};
|
||
|
||
// 监听字体选择变化
|
||
watch(
|
||
selectedFonts,
|
||
(newFonts) => {
|
||
// 如果没有选择任何字体,使用系统默认字体
|
||
if (newFonts.length === 0) {
|
||
setData.value = {
|
||
...setData.value,
|
||
fontFamily: 'system-ui'
|
||
};
|
||
return;
|
||
}
|
||
// 将选择的字体组合成字体列表
|
||
setData.value = {
|
||
...setData.value,
|
||
fontFamily: newFonts.join(',')
|
||
};
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// 初始化已选择的字体
|
||
watch(
|
||
() => setData.value.fontFamily,
|
||
(newFont) => {
|
||
if (newFont) {
|
||
if (newFont === 'system-ui') {
|
||
selectedFonts.value = [];
|
||
} else {
|
||
selectedFonts.value = newFont.split(',');
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// 初始化时从store获取配置
|
||
onMounted(async () => {
|
||
checkForUpdates();
|
||
if (setData.value.proxyConfig) {
|
||
proxyForm.value = { ...setData.value.proxyConfig };
|
||
}
|
||
// 确保enableRealIP有默认值
|
||
if (setData.value.enableRealIP === undefined) {
|
||
setData.value = {
|
||
...setData.value,
|
||
enableRealIP: false
|
||
};
|
||
}
|
||
});
|
||
|
||
// 监听代理配置变化
|
||
watch(
|
||
() => setData.value.proxyConfig,
|
||
(newVal) => {
|
||
if (newVal) {
|
||
proxyForm.value = {
|
||
protocol: newVal.protocol || 'http',
|
||
host: newVal.host || '127.0.0.1',
|
||
port: newVal.port || 7890
|
||
};
|
||
}
|
||
},
|
||
{ immediate: true, deep: true }
|
||
);
|
||
|
||
const handleProxyConfirm = async () => {
|
||
try {
|
||
await formRef.value?.validate();
|
||
// 保存代理配置时保留enable状态
|
||
setData.value = {
|
||
...setData.value,
|
||
proxyConfig: {
|
||
enable: setData.value.proxyConfig?.enable || false,
|
||
protocol: proxyForm.value.protocol,
|
||
host: proxyForm.value.host,
|
||
port: proxyForm.value.port
|
||
}
|
||
};
|
||
showProxyModal.value = false;
|
||
message.success(t('settings.network.messages.proxySuccess'));
|
||
} catch (err) {
|
||
message.error(t('settings.network.messages.proxyError'));
|
||
}
|
||
};
|
||
|
||
const validateAndSaveRealIP = () => {
|
||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||
if (!setData.value.realIP || ipRegex.test(setData.value.realIP)) {
|
||
setData.value = {
|
||
...setData.value,
|
||
realIP: setData.value.realIP,
|
||
enableRealIP: true
|
||
};
|
||
if (setData.value.realIP) {
|
||
message.success(t('settings.network.messages.realIPSuccess'));
|
||
}
|
||
} else {
|
||
message.error(t('settings.network.messages.realIPError'));
|
||
setData.value = {
|
||
...setData.value,
|
||
realIP: ''
|
||
};
|
||
}
|
||
};
|
||
|
||
// 监听enableRealIP变化,当关闭时清空realIP
|
||
watch(
|
||
() => setData.value.enableRealIP,
|
||
(newVal) => {
|
||
if (!newVal) {
|
||
setData.value = {
|
||
...setData.value,
|
||
realIP: '',
|
||
enableRealIP: false
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
const isDonationListVisible = ref(localStorage.getItem('donationListVisible') !== 'false');
|
||
|
||
const toggleDonationList = () => {
|
||
isDonationListVisible.value = !isDonationListVisible.value;
|
||
localStorage.setItem('donationListVisible', isDonationListVisible.value.toString());
|
||
};
|
||
|
||
// 清除缓存相关
|
||
const showClearCacheModal = ref(false);
|
||
const clearCacheOptions = ref([
|
||
{
|
||
label: t('settings.system.cacheTypes.history.label'),
|
||
key: 'history',
|
||
description: t('settings.system.cacheTypes.history.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.favorite.label'),
|
||
key: 'favorite',
|
||
description: t('settings.system.cacheTypes.favorite.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.user.label'),
|
||
key: 'user',
|
||
description: t('settings.system.cacheTypes.user.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.settings.label'),
|
||
key: 'settings',
|
||
description: t('settings.system.cacheTypes.settings.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.downloads.label'),
|
||
key: 'downloads',
|
||
description: t('settings.system.cacheTypes.downloads.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.resources.label'),
|
||
key: 'resources',
|
||
description: t('settings.system.cacheTypes.resources.description')
|
||
},
|
||
{
|
||
label: t('settings.system.cacheTypes.lyrics.label'),
|
||
key: 'lyrics',
|
||
description: t('settings.system.cacheTypes.lyrics.description')
|
||
}
|
||
]);
|
||
|
||
const selectedCacheTypes = ref<string[]>([]);
|
||
|
||
const clearCache = async () => {
|
||
const clearTasks = selectedCacheTypes.value.map(async (type) => {
|
||
switch (type) {
|
||
case 'history':
|
||
localStorage.removeItem('musicHistory');
|
||
break;
|
||
case 'favorite':
|
||
localStorage.removeItem('favoriteList');
|
||
break;
|
||
case 'user':
|
||
userStore.handleLogout();
|
||
break;
|
||
case 'settings':
|
||
if (window.electron) {
|
||
window.electron.ipcRenderer.send('set-store-value', 'set', localData);
|
||
}
|
||
localStorage.removeItem('appSettings');
|
||
localStorage.removeItem('theme');
|
||
localStorage.removeItem('lyricData');
|
||
localStorage.removeItem('lyricFontSize');
|
||
localStorage.removeItem('playMode');
|
||
break;
|
||
case 'downloads':
|
||
if (window.electron) {
|
||
window.electron.ipcRenderer.send('clear-downloads-history');
|
||
}
|
||
break;
|
||
case 'resources':
|
||
// 清除音频资源缓存
|
||
if (window.electron) {
|
||
window.electron.ipcRenderer.send('clear-audio-cache');
|
||
}
|
||
// 清除歌词缓存
|
||
localStorage.removeItem('lyricCache');
|
||
// 清除音乐URL缓存
|
||
localStorage.removeItem('musicUrlCache');
|
||
// 清除图片缓存
|
||
if (window.caches) {
|
||
try {
|
||
const cache = await window.caches.open('music-images');
|
||
await cache.keys().then((keys) => {
|
||
keys.forEach((key) => {
|
||
cache.delete(key);
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.error('清除图片缓存失败:', error);
|
||
}
|
||
}
|
||
break;
|
||
case 'lyrics':
|
||
window.api.invoke('clear-lyrics-cache');
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
|
||
await Promise.all(clearTasks);
|
||
message.success(t('settings.system.messages.clearSuccess'));
|
||
showClearCacheModal.value = false;
|
||
selectedCacheTypes.value = [];
|
||
};
|
||
|
||
const showShortcutModal = ref(false);
|
||
|
||
const handleShortcutsChange = (shortcuts: any) => {
|
||
console.log('快捷键已更新:', shortcuts);
|
||
};
|
||
|
||
// 定义设置分类
|
||
const settingSections = [
|
||
{ id: 'basic', title: t('settings.sections.basic') },
|
||
{ id: 'playback', title: t('settings.sections.playback') },
|
||
{ id: 'application', title: t('settings.sections.application'), electron: true },
|
||
{ id: 'network', title: t('settings.sections.network'), electron: true },
|
||
{ id: 'system', title: t('settings.sections.system'), electron: true },
|
||
{ id: 'regard', title: t('settings.sections.regard') },
|
||
{ id: 'donation', title: t('settings.sections.donation') }
|
||
];
|
||
|
||
// 当前激活的分类
|
||
const currentSection = ref('basic');
|
||
const scrollbarRef = ref();
|
||
|
||
// 各个分类的ref
|
||
const basicRef = ref();
|
||
const playbackRef = ref();
|
||
const applicationRef = ref();
|
||
const networkRef = ref();
|
||
const systemRef = ref();
|
||
const aboutRef = ref();
|
||
const donationRef = ref();
|
||
|
||
// 滚动到指定分类
|
||
const scrollToSection = async (sectionId: string) => {
|
||
currentSection.value = sectionId;
|
||
const sectionRef = {
|
||
basic: basicRef,
|
||
playback: playbackRef,
|
||
application: applicationRef,
|
||
network: networkRef,
|
||
system: systemRef,
|
||
about: aboutRef,
|
||
donation: donationRef
|
||
}[sectionId];
|
||
|
||
if (sectionRef?.value) {
|
||
await nextTick();
|
||
scrollbarRef.value?.scrollTo({
|
||
top: sectionRef.value.offsetTop - 20,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
};
|
||
|
||
// 处理滚动,更新当前激活的分类
|
||
const handleScroll = (e: any) => {
|
||
const { scrollTop } = e.target;
|
||
|
||
const sections = [
|
||
{ id: 'basic', ref: basicRef },
|
||
{ id: 'playback', ref: playbackRef },
|
||
{ id: 'application', ref: applicationRef },
|
||
{ id: 'network', ref: networkRef },
|
||
{ id: 'system', ref: systemRef },
|
||
{ id: 'about', ref: aboutRef },
|
||
{ id: 'donation', ref: donationRef }
|
||
];
|
||
|
||
const activeSection = sections[0].id;
|
||
let lastValidSection = activeSection;
|
||
|
||
for (const section of sections) {
|
||
if (section.ref?.value) {
|
||
const { offsetTop } = section.ref.value;
|
||
if (scrollTop >= offsetTop - 100) {
|
||
lastValidSection = section.id;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastValidSection !== currentSection.value) {
|
||
currentSection.value = lastValidSection;
|
||
}
|
||
};
|
||
|
||
// 初始化时设置当前激活的分类
|
||
onMounted(() => {
|
||
// 延迟一帧等待 DOM 完全渲染
|
||
nextTick(() => {
|
||
handleScroll({ target: { scrollTop: 0 } });
|
||
});
|
||
});
|
||
|
||
// 音源设置相关
|
||
const musicSourceOptions = ref([
|
||
{ label: 'MiGu音乐', value: 'migu' },
|
||
{ label: '酷狗音乐', value: 'kugou' },
|
||
{ label: 'pyncmd', value: 'pyncmd' },
|
||
{ label: '酷我音乐', value: 'kuwo' },
|
||
{ label: 'Bilibili音乐', value: 'bilibili' },
|
||
{ label: 'YouTube', value: 'youtube' },
|
||
{ label: 'GD音乐台', value: 'gdmusic' }
|
||
]);
|
||
|
||
// 已选择的音源列表
|
||
const musicSources = computed({
|
||
get: () => {
|
||
if (!setData.value.enabledMusicSources) {
|
||
return ALL_PLATFORMS;
|
||
}
|
||
return setData.value.enabledMusicSources as Platform[];
|
||
},
|
||
set: (newValue: Platform[]) => {
|
||
// 确保至少选择一个音源
|
||
const valuesToSet = newValue.length > 0 ? [...new Set(newValue)] : ALL_PLATFORMS;
|
||
setData.value = {
|
||
...setData.value,
|
||
enabledMusicSources: valuesToSet
|
||
};
|
||
}
|
||
});
|
||
|
||
const showMusicSourcesModal = ref(false);
|
||
|
||
const getSourceLabel = (source: Platform) => {
|
||
const sourceLabel = musicSourceOptions.value.find(s => s.value === source)?.label;
|
||
return sourceLabel || source;
|
||
};
|
||
|
||
// 远程控制设置弹窗
|
||
const showRemoteControlModal = ref(false);
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.settings-container {
|
||
@apply flex h-full;
|
||
}
|
||
|
||
.settings-nav {
|
||
@apply w-32 h-full flex-shrink-0 border-r border-gray-200 dark:border-gray-700;
|
||
@apply bg-light dark:bg-dark;
|
||
|
||
.nav-item {
|
||
@apply px-4 py-2.5 cursor-pointer text-sm;
|
||
@apply text-gray-600 dark:text-gray-400;
|
||
@apply transition-colors duration-200;
|
||
@apply border-l-2 border-transparent;
|
||
|
||
&:hover {
|
||
@apply text-primary dark:text-white bg-gray-50 dark:bg-dark-100;
|
||
@apply border-l-2 border-gray-200 dark:border-gray-200;
|
||
}
|
||
|
||
&.active {
|
||
@apply text-primary dark:text-white bg-gray-50 dark:bg-dark-100;
|
||
@apply border-l-2 border-gray-200 dark:border-gray-200;
|
||
@apply font-medium;
|
||
}
|
||
}
|
||
}
|
||
|
||
.settings-content {
|
||
@apply flex-1 h-full;
|
||
}
|
||
|
||
.set-page {
|
||
@apply p-4 pb-20;
|
||
}
|
||
|
||
.settings-section {
|
||
@apply mb-6 scroll-mt-4;
|
||
|
||
&-title {
|
||
@apply text-base font-medium mb-4;
|
||
@apply text-gray-600 dark:text-white;
|
||
}
|
||
|
||
&-content {
|
||
@apply space-y-4;
|
||
}
|
||
}
|
||
|
||
.set-item {
|
||
@apply flex items-center justify-between p-4 rounded-lg transition-all;
|
||
@apply bg-light dark:bg-dark text-gray-900 dark:text-white;
|
||
@apply border border-gray-200 dark:border-gray-700;
|
||
|
||
&-title {
|
||
@apply text-base font-medium mb-1;
|
||
}
|
||
|
||
&-content {
|
||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||
}
|
||
|
||
&:hover {
|
||
@apply bg-gray-50 dark:bg-gray-800;
|
||
}
|
||
|
||
&.cursor-pointer:hover {
|
||
@apply text-green-500 bg-green-50 dark:bg-green-900;
|
||
}
|
||
}
|
||
|
||
.font-preview-container {
|
||
@apply mt-4 p-4 rounded-lg;
|
||
@apply bg-gray-50 dark:bg-dark-100;
|
||
@apply border border-gray-200 dark:border-gray-700;
|
||
|
||
.font-preview-title {
|
||
@apply text-sm font-medium mb-3;
|
||
@apply text-gray-600 dark:text-gray-300;
|
||
}
|
||
|
||
.font-preview {
|
||
@apply space-y-3;
|
||
|
||
.preview-item {
|
||
@apply flex flex-col gap-1;
|
||
|
||
.preview-label {
|
||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||
}
|
||
|
||
.preview-text {
|
||
@apply text-base text-gray-900 dark:text-gray-100;
|
||
@apply p-2 rounded;
|
||
@apply bg-white dark:bg-dark;
|
||
@apply border border-gray-200 dark:border-gray-700;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
:deep(.n-select) {
|
||
width: 200px;
|
||
}
|
||
</style>
|