feat: 国际化 (i18n) 功能实现

This commit is contained in:
alger
2025-02-19 01:01:43 +08:00
parent da2a32e420
commit ead017e4b1
64 changed files with 1870 additions and 510 deletions
+9 -8
View File
@@ -15,18 +15,18 @@ import { darkTheme, lightTheme } from 'naive-ui';
import { computed, onMounted, watch } from 'vue';
import homeRouter from '@/router/home';
import store from '@/store';
import globalStore from '@/store';
import { isElectron } from '@/utils';
import { isMobile } from './utils';
const theme = computed(() => {
return store.state.theme;
return globalStore.state.theme;
});
// 监听字体变化并应用
watch(
() => [store.state.setData.fontFamily, store.state.setData.fontScope],
() => [globalStore.state.setData.fontFamily, globalStore.state.setData.fontScope],
([newFont, fontScope]) => {
const appElement = document.body;
if (!appElement) return;
@@ -60,12 +60,13 @@ watch(
);
onMounted(() => {
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
store.dispatch('initializeSystemFonts');
store.dispatch('initializePlayState');
globalStore.dispatch('initializeSettings');
globalStore.dispatch('initializeLanguage');
globalStore.dispatch('initializeTheme');
globalStore.dispatch('initializeSystemFonts');
globalStore.dispatch('initializePlayState');
if (isMobile.value) {
store.commit(
globalStore.commit(
'setMenus',
homeRouter.filter((item) => item.meta.isMobile)
);
+14 -7
View File
@@ -7,7 +7,7 @@
quaternary
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
>
请我喝咖啡
{{ t('comp.coffee.title') }}
</n-button>
</slot>
</template>
@@ -17,20 +17,24 @@
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipayQR"
alt="支付宝收款码"
:alt="t('comp.coffee.alipayQR')"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
<span class="text-sm text-gray-700 dark:text-gray-200">{{
t('comp.coffee.alipay')
}}</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechatQR"
alt="微信收款码"
:alt="t('comp.coffee.wechatQR')"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
<span class="text-sm text-gray-700 dark:text-gray-200">{{
t('comp.coffee.wechat')
}}</span>
</div>
</div>
@@ -39,7 +43,7 @@
class="text-sm text-gray-700 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
@click="copyQQ"
>
QQ群789288579
{{ t('comp.coffee.qqGroup') }}
</p>
</div>
</div>
@@ -48,11 +52,14 @@
</template>
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
import { NButton, NImage, NPopover, useMessage } from 'naive-ui';
import { useI18n } from 'vue-i18n';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
const { t } = useI18n();
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
+35 -15
View File
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { isElectron } from '@/utils';
const store = useStore();
const { locale } = useI18n();
const languages = [
@@ -9,28 +13,44 @@ const languages = [
{ label: 'English', value: 'en-US' }
];
// 从配置中读取语言设置
onMounted(() => {
const savedLanguage = window.electron.ipcRenderer.sendSync('get-store-value', 'set.language');
if (savedLanguage) {
locale.value = savedLanguage;
// 使用计算属性来获取当前语言
const currentLanguage = computed({
get: () => store.state.setData.language || 'zh-CN',
set: (value: string) => {
handleLanguageChange(value);
}
});
// 当语言改变时的处理函数
const handleLanguageChange = (value: string) => {
// 更新 i18n locale
locale.value = value;
// 保存语言设置到配置中
window.electron.ipcRenderer.send('set-store-value', 'set.language', value);
// 通过 mutation 更新 store
store.commit('setLanguage', value);
// 通知主进程语言已更改
window.electron.ipcRenderer.send('change-language', value);
if (isElectron) {
window.electron.ipcRenderer.send('change-language', value);
}
};
// 监听来自主进程的语言切换事件
const handleSetLanguage = (_: any, value: string) => {
handleLanguageChange(value);
};
onMounted(() => {
if (isElectron) {
window.electron.ipcRenderer.on('set-language', handleSetLanguage);
}
});
onUnmounted(() => {
if (isElectron) {
window.electron.ipcRenderer.removeListener('set-language', handleSetLanguage);
}
});
</script>
<template>
<n-select
v-model:value="locale"
:options="languages"
size="small"
@update:value="handleLanguageChange"
/>
<n-select v-model:value="currentLanguage" :options="languages" size="small" />
</template>
+5 -1
View File
@@ -66,7 +66,9 @@
@remove-song="(id) => emit('remove-song', id)"
/>
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<div v-if="isLoadingMore" class="loading-more">
{{ t('common.loadingMore') }}
</div>
<play-bottom />
</div>
</n-spin>
@@ -80,6 +82,7 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
@@ -88,6 +91,7 @@ import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/uti
import PlayBottom from './common/PlayBottom.vue';
const { t } = useI18n();
const store = useStore();
const props = withDefaults(
+12 -8
View File
@@ -54,7 +54,7 @@
</template>
</n-button>
</template>
上一个
{{ t('player.previous') }}
</n-tooltip>
<n-tooltip placement="top">
@@ -68,7 +68,7 @@
</template>
</n-button>
</template>
{{ isPlaying ? '暂停' : '播放' }}
{{ isPlaying ? t('player.pause') : t('player.play') }}
</n-tooltip>
<n-tooltip v-if="!props.noList" placement="top">
@@ -82,7 +82,7 @@
</template>
</n-button>
</template>
下一个
{{ t('player.next') }}
</n-tooltip>
<div class="time-display">
@@ -104,7 +104,7 @@
</template>
</n-button>
</template>
{{ volume === 0 ? '取消静音' : '静音' }}
{{ volume === 0 ? t('player.unmute') : t('player.mute') }}
</n-tooltip>
<n-slider
v-model:value="volume"
@@ -129,7 +129,9 @@
</template>
</n-button>
</template>
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
{{
playMode === 'single' ? t('player.modeHint.single') : t('player.modeHint.list')
}}
</n-tooltip>
<n-tooltip placement="top">
@@ -144,7 +146,7 @@
</template>
</n-button>
</template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
{{ isFullscreen ? t('player.fullscreen.exit') : t('player.fullscreen.enter') }}
</n-tooltip>
<n-tooltip placement="top">
@@ -157,7 +159,7 @@
</template>
</n-button>
</template>
关闭
{{ t('player.close') }}
</n-tooltip>
</div>
</div>
@@ -170,7 +172,7 @@
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
</n-icon>
<div class="mode-text">
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
{{ playMode === 'single' ? t('player.modeHint.single') : t('player.modeHint.list') }}
</div>
</div>
</transition>
@@ -188,11 +190,13 @@
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
import { IMvItem } from '@/type/mv';
const { t } = useI18n();
type PlayMode = 'single' | 'auto';
const PLAY_MODE = {
Single: 'single' as PlayMode,
+9 -2
View File
@@ -1,7 +1,9 @@
<template>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
{{ t('comp.playlistType.title') }}
</div>
<div>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
@@ -34,7 +36,9 @@
"
@click="handleToggleShowAllPlaylistCategory"
>
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
{{
!isShowAllPlaylistCategory ? t('comp.playlistType.showAll') : t('comp.playlistType.hide')
}}
</div>
</div>
</div>
@@ -42,11 +46,14 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import type { IPlayListSort } from '@/type/playlist';
import { setAnimationClass, setAnimationDelay } from '@/utils';
const { t } = useI18n();
// 歌单分类
const playlistCategory = ref<IPlayListSort>();
// 是否显示全部歌单分类
+5 -1
View File
@@ -1,6 +1,8 @@
<template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
{{ t('comp.recommendAlbum.title') }}
</div>
<div class="recommend-album-list">
<template v-for="(item, index) in albumData?.albums" :key="item.id">
<div
@@ -33,6 +35,7 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
@@ -40,6 +43,7 @@ import MusicList from '@/components/MusicList.vue';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
const { t } = useI18n();
const albumData = ref<IAlbumNew>();
const loadAlbumList = async () => {
const { data } = await getNewAlbum();
+7 -3
View File
@@ -19,7 +19,9 @@
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
>
<div class="font-bold text-xl">每日推荐</div>
<div class="font-bold text-lg">
{{ t('comp.recommendSinger.title') }}
</div>
<div class="mt-2">
<p
@@ -45,7 +47,7 @@
class="recommend-singer-item-bg"
></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
{{ item.musicSize }}
{{ t('common.songCount', { count: item.musicSize }) }}
</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
@@ -61,7 +63,7 @@
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
name="每日推荐列表"
:name="t('comp.recommendSinger.songlist')"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
@@ -71,6 +73,7 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getDayRecommend, getHotSinger } from '@/api/home';
@@ -81,6 +84,7 @@ import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
const store = useStore();
const { t } = useI18n();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
@@ -1,6 +1,8 @@
<template>
<div class="recommend-music">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
{{ t('comp.recommendSonglist.title') }}
</div>
<div
v-show="recommendMusic?.result"
v-loading="loading"
@@ -21,6 +23,7 @@
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getRecommendMusic } from '@/api/home';
@@ -29,6 +32,7 @@ import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from './common/SongItem.vue';
const { t } = useI18n();
const store = useStore();
// 推荐歌曲
const recommendMusic = ref<IRecommendMusic>();
@@ -33,7 +33,7 @@
<!-- 标签页切换 -->
<n-tabs v-model:value="activeTab" class="flex-1" type="line" animated>
<n-tab-pane name="songs" :tab="$t('artist.songs')">
<n-tab-pane name="songs" :tab="t('artist.hotSongs')">
<div ref="songListRef" class="songs-list">
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleSongScroll">
<div class="song-list-content">
@@ -44,14 +44,14 @@
:list="true"
@play="handlePlay"
/>
<div v-if="songLoading" class="loading-more">{{ $t('common.loading') }}</div>
<div v-if="songLoading" class="loading-more">{{ t('common.loading') }}</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</n-tab-pane>
<n-tab-pane name="albums" :tab="$t('artist.albums')">
<n-tab-pane name="albums" :tab="t('artist.albums')">
<div ref="albumListRef" class="albums-list">
<n-scrollbar style="max-height: 61vh" :size="5" @scroll="handleAlbumScroll">
<div class="albums-grid">
@@ -69,14 +69,14 @@
type: '专辑'
}"
/>
<div v-if="albumLoading" class="loading-more">{{ $t('common.loading') }}</div>
<div v-if="albumLoading" class="loading-more">{{ t('common.loading') }}</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</n-tab-pane>
<n-tab-pane name="about" :tab="$t('artist.description')">
<n-tab-pane name="about" :tab="t('artist.description')">
<div class="artist-description">
<n-scrollbar style="max-height: 60vh">
<div class="description-content" v-html="artistInfo?.briefDesc"></div>
@@ -91,6 +91,7 @@
<script setup lang="ts">
import { useDateFormat } from '@vueuse/core';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getArtistAlbums, getArtistDetail, getArtistTopSongs } from '@/api/artist';
@@ -102,6 +103,8 @@ import { getImgUrl } from '@/utils';
import PlayBottom from './PlayBottom.vue';
const { t } = useI18n();
const modelValue = defineModel<boolean>('show', { required: true });
const store = useStore();
@@ -5,7 +5,7 @@
<template #icon>
<i class="ri-refresh-line"></i>
</template>
{{ $t('donation.refresh') }}
{{ t('donation.refresh') }}
</n-button>
</div>
<div class="donation-grid" :class="{ 'grid-expanded': isExpanded }">
@@ -72,33 +72,33 @@
<template #icon>
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
</template>
{{ isExpanded ? '收起' : '展开更多' }}
{{ isExpanded ? t('common.collapse') : t('common.expand') }}
</n-button>
</div>
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
<div class="description text-center text-sm text-gray-700 dark:text-gray-200">
<p>{{ $t('donation.description') }}</p>
<p>{{ $t('donation.message') }}</p>
<p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p>
</div>
<div class="flex justify-between">
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipay"
:alt="$t('common.alipay')"
:alt="t('common.alipay')"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ $t('common.alipay') }}</span>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.alipay') }}</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechat"
:alt="$t('common.wechat')"
:alt="t('common.wechat')"
class="w-60 h-60 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ $t('common.wechat') }}</span>
<span class="text-sm text-gray-700 dark:text-gray-200">{{ t('common.wechat') }}</span>
</div>
</div>
</div>
@@ -107,12 +107,15 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Donor } from '@/api/donation';
import { getDonationList } from '@/api/donation';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
const { t } = useI18n();
// 默认头像
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
@@ -15,18 +15,20 @@
placement="bottom"
@after-leave="handleDrawerClose"
>
<n-drawer-content title="下载管理" closable :native-scrollbar="false">
<n-drawer-content :title="t('download.title')" closable :native-scrollbar="false">
<div class="drawer-container">
<n-tabs type="line" animated class="h-full">
<!-- 下载列表 -->
<n-tab-pane name="downloading" tab="下载中" class="h-full">
<n-tab-pane name="downloading" :tab="t('download.tabs.downloading')" class="h-full">
<div class="download-list">
<div v-if="downloadList.length === 0" class="empty-tip">
<n-empty description="暂无下载任务" />
<n-empty :description="t('download.empty.noTasks')" />
</div>
<template v-else>
<div class="total-progress">
<div class="total-progress-text">总进度: {{ totalProgress.toFixed(1) }}%</div>
<div class="total-progress-text">
{{ t('download.progress.total', { progress: totalProgress.toFixed(1) }) }}
</div>
<n-progress
type="line"
:percentage="Number(totalProgress.toFixed(1))"
@@ -52,7 +54,10 @@
{{ item.filename }}
</div>
<div class="download-item-artist">
{{ item.songInfo?.ar?.map((a) => a.name).join(', ') || '未知歌手' }}
{{
item.songInfo?.ar?.map((a) => a.name).join(', ') ||
t('download.artist.unknown')
}}
</div>
<div class="download-item-progress">
<n-progress
@@ -83,10 +88,10 @@
</n-tab-pane>
<!-- 已下载列表 -->
<n-tab-pane name="downloaded" tab="已下载" class="h-full">
<n-tab-pane name="downloaded" :tab="t('download.tabs.downloaded')" class="h-full">
<div class="downloaded-list">
<div v-if="downloadedList.length === 0" class="empty-tip">
<n-empty description="暂无已下载歌曲" />
<n-empty :description="t('download.empty.noDownloaded')" />
</div>
<div v-else class="downloaded-content">
<div class="downloaded-items">
@@ -143,19 +148,28 @@
</n-drawer>
<!-- 删除确认对话框 -->
<n-modal v-model:show="showDeleteConfirm" preset="dialog" type="warning" title="删除确认">
<n-modal
v-model:show="showDeleteConfirm"
preset="dialog"
type="warning"
:title="t('download.delete.title')"
>
<template #header>
<div class="flex items-center">
<i class="iconfont ri-error-warning-line mr-2 text-xl"></i>
<span>删除确认</span>
<span>{{ t('download.delete.title') }}</span>
</div>
</template>
<div class="delete-confirm-content">
确定要删除歌曲 "{{ itemToDelete?.filename }}" 此操作不可恢复
{{ t('download.delete.message', { filename: itemToDelete?.filename }) }}
</div>
<template #action>
<n-button size="small" @click="showDeleteConfirm = false">取消</n-button>
<n-button size="small" type="warning" @click="confirmDelete">确定删除</n-button>
<n-button size="small" @click="showDeleteConfirm = false">{{
t('download.delete.cancel')
}}</n-button>
<n-button size="small" type="warning" @click="confirmDelete">{{
t('download.delete.confirm')
}}</n-button>
</template>
</n-modal>
</template>
@@ -164,12 +178,15 @@
import type { ProgressStatus } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
// import { audioService } from '@/services/audioService';
import { getImgUrl } from '@/utils';
const { t } = useI18n();
interface DownloadItem {
filename: string;
progress: number;
@@ -247,13 +264,13 @@ const getStatusType = (item: DownloadItem) => {
const getStatusText = (item: DownloadItem) => {
switch (item.status) {
case 'downloading':
return '下载中';
return t('download.status.downloading');
case 'completed':
return '已完成';
return t('download.status.completed');
case 'error':
return '失败';
return t('download.status.failed');
default:
return '未知';
return t('download.status.unknown');
}
};
@@ -312,13 +329,13 @@ const confirmDelete = async () => {
)
);
await refreshDownloadedList();
message.success('删除成功');
message.success(t('download.delete.success'));
} else {
message.error('删除失败');
message.error(t('download.delete.failed'));
}
} catch (error) {
console.error('Failed to delete music:', error);
message.error('删除失败');
message.error(t('download.delete.failed'));
} finally {
showDeleteConfirm.value = false;
itemToDelete.value = null;
@@ -398,7 +415,7 @@ const refreshDownloadedList = async () => {
return {
...item,
picUrl: songDetail?.al?.picUrl || item.picUrl || '/images/default_cover.png',
ar: songDetail?.ar || item.ar || [{ name: '本地音乐' }]
ar: songDetail?.ar || item.ar || [{ name: t('download.localMusic') }]
};
});
} catch (detailError) {
@@ -468,7 +485,7 @@ onMounted(() => {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
// 刷新已下载列表
refreshDownloadedList();
message.success(`${data.filename} 下载完成`);
message.success(t('download.message.downloadComplete', { filename: data.filename }));
} else {
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
if (existingItem) {
@@ -481,7 +498,9 @@ onMounted(() => {
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
}, 3000);
}
message.error(`${data.filename} 下载失败: ${data.error}`);
message.error(
t('download.message.downloadFailed', { filename: data.filename, error: data.error })
);
}
});
@@ -13,24 +13,28 @@
</div>
<div class="app-info">
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
<p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
<p class="app-desc mb-2">{{ t('comp.installApp.description') }}</p>
<n-checkbox v-model:checked="noPrompt">{{ t('comp.installApp.noPrompt') }}</n-checkbox>
</div>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
<n-button class="cancel-btn" @click="closeModal">{{
t('comp.installApp.cancel')
}}</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">{{
t('comp.installApp.install')
}}</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
{{ t('comp.installApp.downloadProblem') }}
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
{{ t('comp.installApp.downloadProblemLinkText') }}
</p>
</div>
</div>
@@ -39,12 +43,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { isElectron, isMobile } from '@/utils';
import { getLatestReleaseInfo } from '@/utils/update';
import config from '../../../../package.json';
const { t } = useI18n();
const showModal = ref(false);
const noPrompt = ref(false);
const releaseInfo = ref<any>(null);
@@ -5,7 +5,7 @@
placement="right"
@update:show="$emit('update:modelValue', $event)"
>
<n-drawer-content title="添加到歌单" class="mac-style-drawer">
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
<n-scrollbar class="h-full">
<div class="playlist-drawer">
<!-- 创建新歌单按钮和表单 -->
@@ -18,14 +18,20 @@
<div class="create-playlist-icon">
<i class="iconfont" :class="isCreating ? 'ri-close-line' : 'ri-add-line'"></i>
</div>
<div class="create-playlist-text">{{ isCreating ? '取消创建' : '创建新歌单' }}</div>
<div class="create-playlist-text">
{{
isCreating
? t('comp.playlistDrawer.cancelCreate')
: t('comp.playlistDrawer.createPlaylist')
}}
</div>
</div>
<!-- 创建歌单表单 -->
<div class="create-playlist-form" :class="{ 'is-visible': isCreating }">
<n-input
v-model:value="formValue.name"
placeholder="歌单名称"
:placeholder="t('comp.playlistDrawer.playlistName')"
maxlength="40"
class="mac-style-input"
:status="inputError ? 'error' : undefined"
@@ -40,11 +46,15 @@
class="iconfont"
:class="formValue.privacy ? 'ri-lock-line' : 'ri-earth-line'"
></i>
<span>{{ formValue.privacy ? '私密歌单' : '公开歌单' }}</span>
<span>{{
formValue.privacy
? t('comp.playlistDrawer.privatePlaylist')
: t('comp.playlistDrawer.publicPlaylist')
}}</span>
</div>
<n-switch v-model:value="formValue.privacy" class="mac-style-switch">
<template #checked>私密</template>
<template #unchecked>公开</template>
<template #checked>{{ t('comp.playlistDrawer.private') }}</template>
<template #unchecked>{{ t('comp.playlistDrawer.public') }}</template>
</n-switch>
</div>
<div class="form-actions">
@@ -56,7 +66,7 @@
:disabled="!formValue.name"
@click="handleCreatePlaylist"
>
创建歌单
{{ t('comp.playlistDrawer.create') }}
</n-button>
</div>
</div>
@@ -80,7 +90,10 @@
/>
<div class="playlist-item-info">
<div class="playlist-item-name">{{ playlist.name }}</div>
<div class="playlist-item-count">{{ playlist.trackCount }}首歌曲</div>
<div class="playlist-item-count">
{{ playlist.trackCount }}
{{ t('comp.playlistDrawer.count') }}
</div>
</div>
<div class="playlist-item-action">
<i class="iconfont ri-add-line"></i>
@@ -96,12 +109,14 @@
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { createPlaylist, updatePlaylistTracks } from '@/api/music';
import { getUserPlaylist } from '@/api/user';
import { getImgUrl } from '@/utils';
const { t } = useI18n();
const props = defineProps<{
modelValue: boolean;
songId?: number;
@@ -138,7 +153,7 @@ const fetchUserPlaylists = async () => {
try {
const { user } = store.state;
if (!user?.userId) {
message.error('请先登录');
message.error(t('comp.playlistDrawer.loginFirst'));
emit('update:modelValue', false);
return;
}
@@ -149,7 +164,7 @@ const fetchUserPlaylists = async () => {
}
} catch (error) {
console.error('获取歌单失败:', error);
message.error('获取歌单失败');
message.error(t('comp.playlistDrawer.getPlaylistFailed'));
}
};
@@ -165,21 +180,21 @@ const handleAddToPlaylist = async (playlist: any) => {
console.log('res.data', res.data);
if (res.status === 200) {
message.success('添加成功');
message.success(t('comp.playlistDrawer.addSuccess'));
emit('update:modelValue', false);
} else {
throw new Error(res.data?.msg || '添加失败');
throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed'));
}
} catch (error: any) {
console.error('添加到歌单失败:', error);
message.error(error.message || '添加到歌单失败');
message.error(error.message || t('comp.playlistDrawer.addFailed'));
}
};
// 创建歌单
const handleCreatePlaylist = async () => {
if (!formValue.value.name) {
message.error('请输入歌单名称');
message.error(t('comp.playlistDrawer.inputPlaylistName'));
return;
}
@@ -192,7 +207,7 @@ const handleCreatePlaylist = async () => {
});
if (res.data?.id) {
message.success('创建成功');
message.success(t('comp.playlistDrawer.createSuccess'));
isCreating.value = false;
formValue.value.name = '';
formValue.value.privacy = false;
@@ -200,7 +215,7 @@ const handleCreatePlaylist = async () => {
}
} catch (error) {
console.error('创建歌单失败:', error);
message.error('创建歌单失败');
message.error(t('comp.playlistDrawer.createFailed'));
} finally {
creating.value = false;
}
+14 -11
View File
@@ -89,6 +89,7 @@ import { cloneDeep } from 'lodash';
import type { MenuOption } from 'naive-ui';
import { NImage, NText, useMessage } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getSongUrl } from '@/hooks/MusicListHook';
@@ -97,6 +98,8 @@ import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
item: SongResult;
@@ -194,12 +197,12 @@ const dropdownOptions = computed<MenuOption[]>(() => {
type: 'divider'
},
{
label: '播放',
label: t('songItem.menu.play'),
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: '下一首播放',
label: t('songItem.menu.playNext'),
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
@@ -208,17 +211,17 @@ const dropdownOptions = computed<MenuOption[]>(() => {
key: 'd1'
},
{
label: '下载歌曲',
label: t('songItem.menu.download'),
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: '添加到歌单',
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: isFavorite.value ? '取消喜欢' : '喜欢',
label: isFavorite.value ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
key: 'favorite',
icon: () =>
h('i', {
@@ -234,7 +237,7 @@ const dropdownOptions = computed<MenuOption[]>(() => {
key: 'd2'
},
{
label: '从歌单中删除',
label: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
@@ -271,7 +274,7 @@ const handleSelect = (key: string | number) => {
// 下载音乐
const downloadMusic = async () => {
if (isDownloading.value) {
message.warning('正在下载中,请稍候...');
message.warning(t('songItem.message.downloading'));
return;
}
@@ -280,7 +283,7 @@ const downloadMusic = async () => {
const data = (await getSongUrl(props.item.id, cloneDeep(props.item), true)) as any;
if (!data || !data.url) {
throw new Error('获取音乐下载地址失败');
throw new Error(t('songItem.message.getUrlFailed'));
}
// 构建文件名
@@ -298,7 +301,7 @@ const downloadMusic = async () => {
}
});
message.success('已加入下载队列');
message.success(t('songItem.message.downloadQueued'));
// 监听下载完成事件
const handleDownloadComplete = (_, result) => {
@@ -331,7 +334,7 @@ const downloadMusic = async () => {
} catch (error: any) {
console.error('Download error:', error);
isDownloading.value = false;
message.error(error.message || '下载失败');
message.error(error.message || t('songItem.message.downloadFailed'));
}
};
@@ -398,7 +401,7 @@ const artists = computed(() => {
// 添加到下一首播放
const handlePlayNext = () => {
store.commit('addToNextPlay', props.item);
message.success('已添加到下一首播放');
message.success(t('songItem.message.addedToNextPlay'));
};
</script>
+17 -12
View File
@@ -14,8 +14,10 @@
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
<h2 class="app-name">{{ t('comp.update.title') }} {{ updateInfo.latestVersion }}</h2>
<p class="app-desc mb-2">
{{ t('comp.update.currentVersion') }} {{ updateInfo.currentVersion }}
</p>
</div>
</div>
<div class="update-info">
@@ -39,7 +41,7 @@
:loading="downloading"
@click="closeModal"
>
{{ '暂不更新' }}
{{ t('comp.update.cancel') }}
</n-button>
<n-button
type="primary"
@@ -53,14 +55,14 @@
</div>
<div v-if="!downloading" class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
{{ t('comp.installApp.downloadProblem') }}
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
{{ t('comp.installApp.downloadProblemLinkText') }}
</p>
</div>
</div>
@@ -70,12 +72,15 @@
<script setup lang="ts">
import { marked } from 'marked';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { checkUpdate, getProxyNodes, UpdateResult } from '@/utils/update';
import config from '../../../../package.json';
const { t } = useI18n();
// 配置 marked
marked.setOptions({
breaks: true, // 支持 GitHub 风格的换行
@@ -141,10 +146,10 @@ const checkForUpdates = async () => {
const downloading = ref(false);
const downloadProgress = ref(0);
const downloadStatus = ref('准备下载...');
const downloadStatus = ref(t('comp.update.prepareDownload'));
const downloadBtnText = computed(() => {
if (downloading.value) return '下载中...';
return '立即更新';
if (downloading.value) return t('comp.update.downloading');
return t('comp.update.nowUpdate');
});
// 处理下载状态更新
@@ -159,7 +164,7 @@ const handleDownloadComplete = (_event: any, success: boolean, filePath: string)
if (success) {
window.electron.ipcRenderer.send('install-update', filePath);
} else {
window.$message.error('下载失败,请重试或手动下载');
window.$message.error(t('comp.update.downloadFailed'));
}
};
@@ -225,7 +230,7 @@ const handleUpdate = async () => {
if (downloadUrl) {
try {
downloading.value = true;
downloadStatus.value = '准备下载...';
downloadStatus.value = t('comp.update.prepareDownload');
// 获取代理节点列表
const proxyHosts = await getProxyNodes();
@@ -235,11 +240,11 @@ const handleUpdate = async () => {
window.electron.ipcRenderer.send('start-download', proxyDownloadUrl);
} catch (error) {
downloading.value = false;
window.$message.error('启动下载失败,请重试或手动下载');
window.$message.error(t('comp.update.startFailed'));
console.error('下载失败:', error);
}
} else {
window.$message.error('未找到适合当前系统的安装包,请手动下载');
window.$message.error(t('comp.update.noDownloadUrl'));
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
}
};
+25 -22
View File
@@ -1,83 +1,83 @@
<template>
<div class="settings-panel transparent-popover">
<div class="settings-title">页面设置</div>
<div class="settings-title">{{ t('lyricSettings.title') }}</div>
<div class="settings-content">
<div class="settings-item">
<span>纯净模式</span>
<span>{{ t('lyricSettings.pureMode') }}</span>
<n-switch v-model:value="config.pureModeEnabled" />
</div>
<div class="settings-item">
<span>隐藏封面</span>
<span>{{ t('lyricSettings.hideCover') }}</span>
<n-switch v-model:value="config.hideCover" />
</div>
<div class="settings-item">
<span>居中显示</span>
<span>{{ t('lyricSettings.centerDisplay') }}</span>
<n-switch v-model:value="config.centerLyrics" />
</div>
<div class="settings-item">
<span>显示翻译</span>
<span>{{ t('lyricSettings.showTranslation') }}</span>
<n-switch v-model:value="config.showTranslation" />
</div>
<div class="settings-item">
<span>隐藏播放栏</span>
<span>{{ t('lyricSettings.hidePlayBar') }}</span>
<n-switch v-model:value="config.hidePlayBar" />
</div>
<div class="settings-slider">
<span>字体大小</span>
<span>{{ t('lyricSettings.fontSize') }}</span>
<n-slider
v-model:value="config.fontSize"
:step="1"
:min="12"
:max="32"
:marks="{
12: '小',
22: '中',
32: '大'
12: t('lyricSettings.fontSizeMarks.small'),
22: t('lyricSettings.fontSizeMarks.medium'),
32: t('lyricSettings.fontSizeMarks.large')
}"
/>
</div>
<div class="settings-slider">
<span>文字间距</span>
<span>{{ t('lyricSettings.letterSpacing') }}</span>
<n-slider
v-model:value="config.letterSpacing"
:step="0.2"
:min="-2"
:max="10"
:marks="{
'-2': '紧凑',
0: '默认',
10: '宽松'
'-2': t('lyricSettings.letterSpacingMarks.compact'),
0: t('lyricSettings.letterSpacingMarks.default'),
10: t('lyricSettings.letterSpacingMarks.loose')
}"
/>
</div>
<div class="settings-slider">
<span>行高</span>
<span>{{ t('lyricSettings.lineHeight') }}</span>
<n-slider
v-model:value="config.lineHeight"
:step="0.1"
:min="1"
:max="3"
:marks="{
1: '紧凑',
1.5: '默认',
3: '宽松'
1: t('lyricSettings.lineHeightMarks.compact'),
1.5: t('lyricSettings.lineHeightMarks.default'),
3: t('lyricSettings.lineHeightMarks.loose')
}"
/>
</div>
<div class="settings-item">
<span>背景主题</span>
<span>{{ t('lyricSettings.backgroundTheme') }}</span>
<n-radio-group v-model:value="config.theme" name="theme">
<n-radio value="default">默认</n-radio>
<n-radio value="light">亮色</n-radio>
<n-radio value="dark">暗色</n-radio>
<n-radio value="default">{{ t('lyricSettings.themeOptions.default') }}</n-radio>
<n-radio value="light">{{ t('lyricSettings.themeOptions.light') }}</n-radio>
<n-radio value="dark">{{ t('lyricSettings.themeOptions.dark') }}</n-radio>
</n-radio-group>
</div>
</div>
@@ -86,6 +86,9 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
interface LyricConfig {
hideCover: boolean;
@@ -2,7 +2,7 @@
<n-modal
v-model:show="visible"
preset="dialog"
title="快捷键设置"
:title="t('settings.shortcutSettings.title')"
:show-icon="false"
style="width: 600px"
@after-leave="handleAfterLeave"
@@ -20,7 +20,7 @@
<n-input
:value="formatShortcut(shortcut)"
:status="duplicateKeys[key] ? 'error' : undefined"
placeholder="点击输入快捷键"
:placeholder="t('settings.shortcutSettings.inputPlaceholder')"
readonly
@keydown="(e) => handleKeyDown(e, key)"
@focus="() => startRecording(key)"
@@ -32,7 +32,7 @@
<i class="ri-error-warning-line"></i>
</n-icon>
</template>
快捷键冲突
{{ t('settings.shortcutSettings.shortcutConflict') }}
</n-tooltip>
</div>
</div>
@@ -42,10 +42,12 @@
<div class="shortcut-footer">
<n-space justify="end">
<n-button size="small" @click="handleCancel">取消</n-button>
<n-button size="small" @click="resetShortcuts">恢复默认</n-button>
<n-button size="small" @click="handleCancel">{{ t('common.cancel') }}</n-button>
<n-button size="small" @click="resetShortcuts">{{
t('settings.shortcutSettings.resetShortcuts')
}}</n-button>
<n-button type="primary" size="small" :disabled="hasConflict" @click="handleSave">
保存
{{ t('common.save') }}
</n-button>
</n-space>
</div>
@@ -58,9 +60,12 @@
import { cloneDeep } from 'lodash';
import { useMessage } from 'naive-ui';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { isElectron } from '@/utils';
const { t } = useI18n();
interface Shortcuts {
togglePlay: string;
prevPlay: string;
@@ -121,13 +126,13 @@ onMounted(() => {
});
const shortcutLabels: Record<keyof Shortcuts, string> = {
togglePlay: '播放/暂停',
prevPlay: '上一首',
nextPlay: '下一首',
volumeUp: '音量增加',
volumeDown: '音量减少',
toggleFavorite: '收藏/取消收藏',
toggleWindow: '显示/隐藏窗口'
togglePlay: t('settings.shortcutSettings.togglePlay'),
prevPlay: t('settings.shortcutSettings.prevPlay'),
nextPlay: t('settings.shortcutSettings.nextPlay'),
volumeUp: t('settings.shortcutSettings.volumeUp'),
volumeDown: t('settings.shortcutSettings.volumeDown'),
toggleFavorite: t('settings.shortcutSettings.toggleFavorite'),
toggleWindow: t('settings.shortcutSettings.toggleWindow')
};
const getShortcutLabel = (key: keyof Shortcuts) => shortcutLabels[key];
@@ -221,12 +226,12 @@ const handleKeyDown = (e: KeyboardEvent, key: keyof Shortcuts) => {
const resetShortcuts = () => {
tempShortcuts.value = { ...defaultShortcuts };
message.success('已恢复默认快捷键,请记得保存');
message.success(t('settings.shortcutSettings.messages.resetSuccess'));
};
const saveShortcuts = () => {
if (hasConflict.value) {
message.error('存在冲突的快捷键,请重新设置');
message.error(t('settings.shortcutSettings.messages.conflict'));
return;
}
@@ -241,17 +246,17 @@ const saveShortcuts = () => {
window.electron.ipcRenderer.send('set-store-value', 'shortcuts', shortcutsToSave);
// 然后更新快捷键
window.electron.ipcRenderer.send('update-shortcuts');
message.success('快捷键设置已保存');
message.success(t('settings.shortcutSettings.messages.saveSuccess'));
} catch (error) {
console.error('保存快捷键失败:', error);
message.error('保存快捷键失败,请重试');
message.error(t('settings.shortcutSettings.messages.saveError'));
}
}
};
const cancelEdit = () => {
tempShortcuts.value = { ...shortcuts.value };
message.info('已取消修改');
message.info(t('settings.shortcutSettings.messages.cancelEdit'));
emit('update:show', false);
};
+2 -2
View File
@@ -1,3 +1,4 @@
import { createDiscreteApi } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import useIndexedDB from '@/hooks/IndexDBHook';
@@ -6,7 +7,7 @@ import store from '@/store';
import type { Artist, ILyricText, SongResult } from '@/type/music';
import { isElectron } from '@/utils';
import { getTextColors } from '@/utils/linearColor';
import { createDiscreteApi } from 'naive-ui';
const windowData = window as any;
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
@@ -62,7 +63,6 @@ watch(
sound.value = newSound as Howl;
setupAudioListeners();
} catch (error) {
console.error('播放音频失败:', error);
store.commit('setPlayMusic', false);
message.error('当前歌曲播放失败,播放下一首');
+3 -1
View File
@@ -113,7 +113,7 @@
<!-- 无歌词 -->
<div v-if="!lrcArray.length" class="music-lrc-text mt-40">
<span>暂无歌词, 请欣赏</span>
<span>{{ t('player.lrc.noLrc') }}</span>
</div>
</div>
</n-layout>
@@ -130,6 +130,7 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import LyricSettings from '@/components/lyric/LyricSettings.vue';
@@ -145,6 +146,7 @@ import {
import { getImgUrl, isMobile } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
const { t } = useI18n();
// 定义 refs
const lrcSider = ref<any>(null);
const isMouse = ref(false);
+26 -15
View File
@@ -41,7 +41,9 @@
class="text-3xl"
:class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"
></i>
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
<span class="hover-text">{{
musicFullVisible ? t('player.playBar.collapse') : t('player.playBar.expand')
}}</span>
</div>
</div>
</div>
@@ -105,7 +107,7 @@
@click="toggleFavorite"
></i>
</template>
喜欢
{{ t('player.playBar.like') }}
</n-tooltip>
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger>
@@ -115,7 +117,7 @@
@click="openLyricWindow"
></i>
</template>
歌词
{{ t('player.playBar.lyric') }}
</n-tooltip>
<n-popover
trigger="click"
@@ -132,7 +134,7 @@
<template #trigger>
<i class="iconfont icon-list"></i>
</template>
播放列表
{{ t('player.playBar.playList') }}
</n-tooltip>
</template>
<div class="music-play-list">
@@ -155,6 +157,7 @@
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
@@ -176,7 +179,7 @@ import { showShortcutToast } from '@/utils/shortcutToast';
import MusicFull from './MusicFull.vue';
const store = useStore();
const { t } = useI18n();
// 是否播放
const play = computed(() => store.state.play as boolean);
// 播放列表
@@ -260,13 +263,13 @@ const playModeIcon = computed(() => {
const playModeText = computed(() => {
switch (playMode.value) {
case 0:
return '列表循环';
return t('player.playBar.playMode.sequence');
case 1:
return '单曲循环';
return t('player.playBar.playMode.loop');
case 2:
return '随机播放';
return t('player.playBar.playMode.random');
default:
return '列表循环';
return t('player.playBar.playMode.sequence');
}
});
@@ -368,34 +371,42 @@ if (isElectron) {
case 'togglePlay':
playMusicEvent();
showShortcutToast(
store.state.play ? '开始播放' : '暂停播放',
store.state.play ? t('player.playBar.play') : t('player.playBar.pause'),
store.state.play ? 'ri-pause-circle-line' : 'ri-play-circle-line'
);
break;
case 'prevPlay':
handlePrev();
showShortcutToast('上一首', 'ri-skip-back-line');
showShortcutToast(t('player.playBar.prev'), 'ri-skip-back-line');
break;
case 'nextPlay':
handleNext();
showShortcutToast('下一首', 'ri-skip-forward-line');
showShortcutToast(t('player.playBar.next'), 'ri-skip-forward-line');
break;
case 'volumeUp':
if (volumeSlider.value < 100) {
volumeSlider.value = Math.min(volumeSlider.value + 10, 100);
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-up-line');
showShortcutToast(
`${t('player.playBar.volume')}${volumeSlider.value}%`,
'ri-volume-up-line'
);
}
break;
case 'volumeDown':
if (volumeSlider.value > 0) {
volumeSlider.value = Math.max(volumeSlider.value - 10, 0);
showShortcutToast(`音量${volumeSlider.value}%`, 'ri-volume-down-line');
showShortcutToast(
`${t('player.playBar.volume')}${volumeSlider.value}%`,
'ri-volume-down-line'
);
}
break;
case 'toggleFavorite':
toggleFavorite(new Event('click'));
showShortcutToast(
isFavorite.value ? `已收藏${playMusic.value.name}` : `已取消收藏${playMusic.value.name}`,
isFavorite.value
? t('player.playBar.favorite', { name: playMusic.value.name })
: t('player.playBar.unFavorite', { name: playMusic.value.name }),
isFavorite.value ? 'ri-heart-fill' : 'ri-heart-line'
);
break;
+13 -9
View File
@@ -35,7 +35,9 @@
:src="getImgUrl(store.state.user.avatarUrl)"
@click="selectItem('user')"
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">
{{ t('comp.searchBar.login') }}
</div>
</div>
</template>
<div class="user-popover">
@@ -46,20 +48,20 @@
<div class="menu-items">
<div v-if="!store.state.user" class="menu-item" @click="toLogin">
<i class="iconfont ri-login-box-line"></i>
<span>去登录</span>
<span>{{ t('comp.searchBar.toLogin') }}</span>
</div>
<div v-if="store.state.user" class="menu-item" @click="selectItem('logout')">
<i class="iconfont ri-logout-box-r-line"></i>
<span>退出登录</span>
<span>{{ t('comp.searchBar.logout') }}</span>
</div>
<!-- 切换主题 -->
<div class="menu-item" @click="selectItem('set')">
<i class="iconfont ri-settings-3-line"></i>
<span>设置</span>
<span>{{ t('comp.searchBar.set') }}</span>
</div>
<div class="menu-item">
<i class="iconfont" :class="isDarkTheme ? 'ri-moon-line' : 'ri-sun-line'"></i>
<span>主题</span>
<span>{{ t('comp.searchBar.theme') }}</span>
<n-switch v-model:value="isDarkTheme" class="ml-auto">
<template #checked>
<i class="ri-moon-line"></i>
@@ -71,15 +73,15 @@
</div>
<div class="menu-item" @click="restartApp">
<i class="iconfont ri-restart-line"></i>
<span>重启</span>
<span>{{ t('comp.searchBar.restart') }}</span>
</div>
<div class="menu-item" @click="selectItem('refresh')">
<i class="iconfont ri-refresh-line"></i>
<span>刷新</span>
<span>{{ t('comp.searchBar.refresh') }}</span>
</div>
<div class="menu-item" @click="toGithubRelease">
<i class="iconfont ri-github-fill"></i>
<span>当前版本</span>
<span>{{ t('comp.searchBar.currentVersion') }}</span>
<div class="version-info">
<span class="version-number">{{ updateInfo.currentVersion }}</span>
<n-tag v-if="updateInfo.hasUpdate" type="success" size="small" class="ml-1">
@@ -101,6 +103,7 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -118,9 +121,10 @@ import config from '../../../../package.json';
const router = useRouter();
const store = useStore();
const userSetOptions = ref(USER_SET_OPTIONS);
const { t } = useI18n();
// 推荐热搜词
const hotSearchKeyword = ref('搜索点什么吧...');
const hotSearchKeyword = ref(t('comp.searchBar.searchPlaceholder'));
const hotSearchValue = ref('');
const loadHotSearchKeyword = async () => {
const { data } = await getSearchKeyword();
+10 -4
View File
@@ -19,15 +19,21 @@
:mask-closable="true"
>
<div class="close-dialog-content">
<p>请选择关闭方式</p>
<p>{{ t('comp.titleBar.closeTitle') }}</p>
<div class="remember-choice">
<n-checkbox v-model:checked="rememberChoice">记住我的选择</n-checkbox>
<n-checkbox v-model:checked="rememberChoice">
{{ t('comp.titleBar.rememberChoice') }}
</n-checkbox>
</div>
</div>
<template #action>
<div class="dialog-footer">
<n-button type="primary" @click="handleAction('minimize')">最小化到托盘</n-button>
<n-button @click="handleAction('close')">退出应用</n-button>
<n-button type="primary" @click="handleAction('minimize')">
{{ t('comp.titleBar.minimizeToTray') }}
</n-button>
<n-button @click="handleAction('close')">
{{ t('comp.titleBar.exitApp') }}
</n-button>
</div>
</template>
</n-modal>
+23
View File
@@ -1,5 +1,6 @@
import { createStore } from 'vuex';
import i18n from '@/../i18n/renderer';
import setData from '@/../main/set.json';
import { logout } from '@/api/login';
import { getLikedList, likeSong } from '@/api/music';
@@ -260,6 +261,17 @@ const mutations = {
},
setShowDownloadDrawer(state: State, show: boolean) {
state.showDownloadDrawer = show;
},
setLanguage(state: State, language: string) {
state.setData.language = language;
if (isElectron) {
window.electron.ipcRenderer.send('set-store-value', 'set.language', language);
} else {
localStorage.setItem('appSettings', JSON.stringify(state.setData));
}
},
getLanguage(state: State) {
return state.setData.language;
}
};
@@ -360,6 +372,17 @@ const actions = {
localStorage.removeItem('isPlaying');
}
}
},
initializeLanguage({ state }: { state: State }) {
state.setData.language = getLocalStorageItem('appSettings', { language: 'zh-CN' }).language;
if (isElectron) {
window.electron.ipcRenderer.on('set-language', (_, language: string) => {
state.setData.language = language;
});
} else {
localStorage.setItem('appSettings', JSON.stringify(state.setData));
}
i18n.global.locale.value = state.setData.language as 'zh-CN' | 'en-US';
}
};
+18 -14
View File
@@ -2,8 +2,8 @@
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
<div class="favorite-header" :class="setAnimationClass('animate__fadeInLeft')">
<div class="favorite-header-left">
<h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div>
<h2>{{ t('favorite.title') }}</h2>
<div class="favorite-count">{{ t('favorite.count', { count: favoriteList.length }) }}</div>
</div>
<div v-if="!isComponent && isElectron" class="favorite-header-right">
<n-button
@@ -17,7 +17,7 @@
<template #icon>
<i class="iconfont ri-checkbox-multiple-line"></i>
</template>
批量下载
{{ t('favorite.batchDownload') }}
</n-button>
<div v-else class="select-controls">
<n-checkbox
@@ -26,7 +26,7 @@
:indeterminate="isIndeterminate"
@update:checked="handleSelectAll"
>
全选
{{ t('common.selectAll') }}
</n-checkbox>
<n-button-group class="operation-btns">
<n-button
@@ -40,9 +40,11 @@
<template #icon>
<i class="iconfont ri-download-line"></i>
</template>
下载 ({{ selectedSongs.length }})
{{ t('favorite.download', { count: selectedSongs.length }) }}
</n-button>
<n-button size="small" class="cancel-btn" @click="cancelSelect">
{{ t('common.cancel') }}
</n-button>
<n-button size="small" class="cancel-btn" @click="cancelSelect"> 取消 </n-button>
</n-button-group>
</div>
</div>
@@ -50,7 +52,7 @@
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
<n-scrollbar ref="scrollbarRef" class="favorite-content" @scroll="handleScroll">
<div v-if="favoriteList.length === 0" class="empty-tip">
<n-empty description="还没有收藏歌曲" />
<n-empty :description="t('favorite.emptyTip')" />
</div>
<div v-else class="favorite-list">
<song-item
@@ -66,14 +68,14 @@
@select="handleSelect"
/>
<div v-if="isComponent" class="favorite-list-more text-center">
<n-button text type="primary" @click="handleMore">查看更多</n-button>
<n-button text type="primary" @click="handleMore">{{ t('common.viewMore') }}</n-button>
</div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
<div v-if="noMore" class="no-more-tip">{{ t('common.noMore') }}</div>
</div>
</n-scrollbar>
</div>
@@ -84,6 +86,7 @@
import { cloneDeep } from 'lodash';
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -93,6 +96,7 @@ import { getSongUrl } from '@/hooks/MusicListHook';
import type { SongResult } from '@/type/music';
import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils';
const { t } = useI18n();
const store = useStore();
const message = useMessage();
const favoriteList = computed(() => store.state.favoriteList);
@@ -130,18 +134,18 @@ const handleSelect = (songId: number, selected: boolean) => {
// 批量下载
const handleBatchDownload = async () => {
if (isDownloading.value) {
message.warning('正在下载中,请稍候...');
message.warning(t('favorite.downloading'));
return;
}
if (selectedSongs.value.length === 0) {
message.warning('请先选择要下载的歌曲');
message.warning(t('favorite.selectSongsFirst'));
return;
}
try {
isDownloading.value = true;
message.success('开始下载...');
message.success(t('favorite.downloading'));
// 移除旧的监听器
window.electron.ipcRenderer.removeAllListeners('music-download-complete');
@@ -160,7 +164,7 @@ const handleBatchDownload = async () => {
// 当所有下载完成时
if (successCount + failCount === selectedSongs.value.length) {
isDownloading.value = false;
message.success(`下载完成`);
message.success(t('favorite.downloadSuccess'));
cancelSelect();
}
});
@@ -201,7 +205,7 @@ const handleBatchDownload = async () => {
console.error('下载失败:', error);
isDownloading.value = false;
message.destroyAll();
message.error('下载失败');
message.error(t('favorite.downloadFailed'));
}
};
+8 -4
View File
@@ -1,6 +1,8 @@
<template>
<div class="history-page">
<div class="title" :class="setAnimationClass('animate__fadeInRight')">播放历史</div>
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
{{ t('history.title') }}
</div>
<n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
@@ -12,7 +14,7 @@
>
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count min-w-[60px]">
{{ item.count }}
{{ t('history.playCount', { count: item.count }) }}
</div>
<div class="history-item-delete">
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
@@ -23,7 +25,7 @@
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
<div v-if="noMore" class="no-more-tip">{{ t('common.noMore') }}</div>
</div>
</n-scrollbar>
</div>
@@ -31,6 +33,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
@@ -43,6 +46,7 @@ defineOptions({
name: 'History'
});
const { t } = useI18n();
const store = useStore();
const { delMusic, musicList } = useMusicHistory();
const scrollbarRef = ref();
@@ -89,7 +93,7 @@ const getHistorySongs = async () => {
noMore.value = displayList.value.length >= musicList.value.length;
}
} catch (error) {
console.error('获取历史记录失败:', error);
console.error(t('history.getHistoryFailed'), error);
} finally {
loading.value = false;
}
+26 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -11,6 +12,7 @@ defineOptions({
name: 'Login'
});
const { t } = useI18n();
const message = useMessage();
const store = useStore();
const router = useRouter();
@@ -36,15 +38,12 @@ const loadLogin = async () => {
qrUrl.value = data.data.qrimg;
const timer = timerIsQr(key);
// 添加对定时器的引用,以便在出现错误时可以清除
timerRef.value = timer as any;
} catch (error) {
console.error('加载登录信息时出错:', error);
console.error(t('login.message.loadError'), error);
}
};
// 使用 ref 来保存定时器,便于在任何地方清除它
const timerIsQr = (key: string) => {
const timer = setInterval(async () => {
try {
@@ -59,15 +58,14 @@ const timerIsQr = (key: string) => {
const user = await getUserDetail();
store.state.user = user.data.profile;
localStorage.setItem('user', JSON.stringify(user.data.profile));
message.success('登录成功');
message.success(t('login.message.loginSuccess'));
clearInterval(timer);
timerRef.value = null;
router.push('/user');
}
} catch (error) {
console.error('检查二维码状态时出错:', error);
// 在出现错误时清除定时器
console.error(t('login.message.qrCheckError'), error);
clearInterval(timer);
timerRef.value = null;
}
@@ -96,7 +94,7 @@ const password = ref('');
const loginPhone = async () => {
const { data } = await loginByCellphone(phone.value, password.value);
if (data.code === 200) {
message.success('登录成功');
message.success(t('login.message.loginSuccess'));
store.state.user = data.profile;
localStorage.setItem('token', data.cookie);
setTimeout(() => {
@@ -112,22 +110,34 @@ const loginPhone = async () => {
<div class="bg"></div>
<div class="content">
<div v-if="isQr" class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">扫码登陆</div>
<div class="login-title">{{ t('login.title.qr') }}</div>
<img class="qr-img" :src="qrUrl" />
<div class="text">使用网易云APP扫码登录</div>
<div class="text">{{ t('login.qrTip') }}</div>
</div>
<div v-else class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">手机号登录</div>
<div class="login-title">{{ t('login.title.phone') }}</div>
<div class="phone-page">
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
<input
v-model="phone"
class="phone-input"
type="text"
:placeholder="t('login.placeholder.phone')"
/>
<input
v-model="password"
class="phone-input"
type="password"
:placeholder="t('login.placeholder.password')"
/>
</div>
<div class="text">使用网易云账号登录</div>
<n-button class="btn-login" @click="loginPhone()">登录</n-button>
<div class="text">{{ t('login.phoneTip') }}</div>
<n-button class="btn-login" @click="loginPhone()">{{ t('login.button.login') }}</n-button>
</div>
</div>
<div class="bottom">
<div class="title" @click="chooseQr()">{{ isQr ? '手机号登录' : '扫码登录' }}</div>
<div class="title" @click="chooseQr()">
{{ isQr ? t('login.button.switchToPhone') : t('login.button.switchToQr') }}
</div>
</div>
</div>
</div>
+9 -7
View File
@@ -6,7 +6,7 @@
:class="setAnimationClass('animate__fadeInDown')"
:native-scrollbar="false"
>
<div class="title">热搜列表</div>
<div class="title">{{ t('search.title.hotSearch') }}</div>
<div class="hot-search-list">
<template v-for="(item, index) in hotSearchData?.data" :key="index">
<div
@@ -64,20 +64,20 @@
<!-- 加载状态 -->
<div v-if="isLoadingMore" class="loading-more">
<n-spin size="small" />
<span class="ml-2">加载中...</span>
<span class="ml-2">{{ t('search.loading.more') }}</span>
</div>
<div v-if="!hasMore && searchDetail" class="no-more">没有更多了</div>
<div v-if="!hasMore && searchDetail" class="no-more">{{ t('search.noMore') }}</div>
</template>
<!-- 搜索历史 -->
<template v-else>
<div class="search-history">
<div class="search-history-header title">
<span>搜索历史</span>
<span>{{ t('search.title.searchHistory') }}</span>
<n-button text type="error" @click="clearSearchHistory">
<template #icon>
<i class="ri-delete-bin-line"></i>
</template>
清空
{{ t('search.button.clear') }}
</n-button>
</div>
<div class="search-history-list">
@@ -105,6 +105,7 @@
<script lang="ts" setup>
import { useDateFormat } from '@vueuse/core';
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
@@ -119,6 +120,7 @@ defineOptions({
name: 'Search'
});
const { t } = useI18n();
const route = useRoute();
const store = useStore();
@@ -184,7 +186,7 @@ onMounted(() => {
loadSearch(route.query.keyword);
});
const hotKeyword = ref(route.query.keyword || '搜索列表');
const hotKeyword = ref(route.query.keyword || t('search.title.searchList'));
watch(
() => store.state.searchValue,
@@ -286,7 +288,7 @@ const loadSearch = async (keywords: any, type: any = null, isLoadMore = false) =
page.value++;
} catch (error) {
console.error('搜索失败:', error);
console.error(t('search.error.searchFailed'), error);
} finally {
searchDetailLoading.value = false;
isLoadingMore.value = false;
+194 -141
View File
@@ -9,7 +9,7 @@
:class="{ active: currentSection === section.id }"
@click="scrollToSection(section.id)"
>
{{ section.title }}
{{ t(`settings.sections.${section.id}`) }}
</div>
</div>
@@ -18,12 +18,12 @@
<div class="set-page">
<!-- 基础设置 -->
<div id="basic" ref="basicRef" class="settings-section">
<div class="settings-section-title">基础设置</div>
<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">主题模式</div>
<div class="set-item-content">切换日间/夜间主题</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>
@@ -31,15 +31,28 @@
</n-switch>
</div>
<!-- 语言设置 -->
<div class="set-item">
<div>
<div class="set-item-title">字体设置</div>
<div class="set-item-content">选择字体优先使用排在前面的字体</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">全局</n-radio>
<n-radio key="lyric" value="lyric">仅歌词</n-radio>
<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"
@@ -55,52 +68,52 @@
</div>
<div v-if="selectedFonts.length > 0" class="font-preview-container">
<div class="font-preview-title">字体预览</div>
<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">中文</div>
<div class="preview-text">静夜思 床前明月光 疑是地上霜</div>
<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">English</div>
<div class="preview-text">The quick brown fox jumps over the lazy dog</div>
<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">日本語</div>
<div class="preview-text">あいうえお かきくけこ さしすせそ</div>
<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">한국어</div>
<div class="preview-text">가나다라마 바사아자차 카타파하</div>
<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">动画速度</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>关闭</template>
<template #unchecked>开启</template>
<template #checked>{{ t('common.off') }}</template>
<template #unchecked>{{ t('common.on') }}</template>
</n-switch>
<span>是否开启动画</span>
<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-40">
<div class="w-60">
<n-slider
v-model:value="setData.animationSpeed"
:min="0.1"
:max="3"
:step="0.1"
:marks="{
0.1: '极慢',
1: '正常',
3: '极快'
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"
@@ -113,25 +126,25 @@
<!-- 播放设置 -->
<div id="playback" ref="playbackRef" class="settings-section">
<div class="settings-section-title">播放设置</div>
<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">音质设置</div>
<div class="set-item-content">选择音乐播放音质VIP</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: '标准', value: 'standard' },
{ label: '较高', value: 'higher' },
{ label: '极高', value: 'exhigh' },
{ label: '无损', value: 'lossless' },
{ label: 'Hi-Res', value: 'hires' },
{ label: '高清环绕声', value: 'jyeffect' },
{ label: '沉浸环绕声', value: 'sky' },
{ label: '杜比全景声', value: 'dolby' },
{ label: '超清母带', value: 'jymaster' }
{ 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"
/>
@@ -139,12 +152,12 @@
<div class="set-item">
<div>
<div class="set-item-title">自动播放</div>
<div class="set-item-content">重新打开应用时是否自动继续播放</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>开启</template>
<template #unchecked>关闭</template>
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
</div>
</div>
@@ -152,21 +165,19 @@
<!-- 应用设置 -->
<div v-if="isElectron" id="application" ref="applicationRef" class="settings-section">
<div class="settings-section-title">应用设置</div>
<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">关闭行为</div>
<div class="set-item-content">
{{ closeActionLabels[setData.closeAction] || '每次询问' }}
</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: '每次询问', value: 'ask' },
{ label: '最小化到托盘', value: 'minimize' },
{ label: '直接退出', value: 'close' }
{ 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"
/>
@@ -174,40 +185,44 @@
<div class="set-item">
<div>
<div class="set-item-title">快捷键设置</div>
<div class="set-item-content">自定义全局快捷键</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">配置</n-button>
<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">下载管理</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>显示</template>
<template #unchecked>隐藏</template>
<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="store.commit('setShowDownloadDrawer', true)">
打开下载管理
{{ t('settings.application.download') }}
</n-button>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">下载目录</div>
<div class="set-item-title">{{ t('settings.application.downloadPath') }}</div>
<div class="set-item-content">
{{ setData.downloadPath || '默认下载目录' }}
{{ setData.downloadPath || t('settings.application.downloadPathDesc') }}
</div>
</div>
<div class="flex items-center gap-2">
<n-button size="small" @click="openDownloadPath">打开目录</n-button>
<n-button size="small" @click="selectDownloadPath">修改目录</n-button>
<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>
@@ -215,42 +230,41 @@
<!-- 网络设置 -->
<div v-if="isElectron" id="network" ref="networkRef" class="settings-section">
<div class="settings-section-title">网络设置</div>
<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">音乐API端口</div>
<div class="set-item-content">修改后需要重启应用</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">代理设置</div>
<div class="set-item-content">无法访问音乐时可以开启代理</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>开启</template>
<template #unchecked>关闭</template>
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
<n-button size="small" @click="showProxyModal = true">配置</n-button>
<n-button size="small" @click="showProxyModal = true">{{
t('common.configure')
}}</n-button>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">realIP</div>
<div class="set-item-content">
由于限制,此项目在国外使用会受到限制可使用realIP参数,传进国内IP解决,:116.25.146.177
即可解决
</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>开启</template>
<template #unchecked>关闭</template>
<template #checked>{{ t('common.on') }}</template>
<template #unchecked>{{ t('common.off') }}</template>
</n-switch>
<n-input
v-if="setData.enableRealIP"
@@ -266,48 +280,52 @@
<!-- 系统管理 -->
<div v-if="isElectron" id="system" ref="systemRef" class="settings-section">
<div class="settings-section-title">系统管理</div>
<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">缓存管理</div>
<div class="set-item-content">清除缓存</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"> 清除缓存 </n-button>
<n-button size="small" @click="showClearCacheModal = true">
{{ t('settings.system.cacheDesc') }}
</n-button>
</div>
<div class="set-item">
<div>
<div class="set-item-title">重启</div>
<div class="set-item-content">重启应用</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">重启</n-button>
<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">关于</div>
<div class="settings-section-title">{{ t('settings.regard') }}</div>
<div class="settings-section-content">
<div class="set-item">
<div>
<div class="set-item-title">版本</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"
>发现新版本 {{ updateInfo.latestVersion }}</n-tag
>
<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 ? '检查中...' : '检查更新' }}
{{ 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>
@@ -318,13 +336,13 @@
>
<coffee>
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content">algerkong 点个star🌟</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>前往github
<i class="ri-github-line"></i>{{ t('settings.about.gotoGithub') }}
</n-button>
</div>
</div>
@@ -333,18 +351,18 @@
<!-- 捐赠支持 -->
<div id="donation" ref="donationRef" class="settings-section">
<div class="settings-section-title">捐赠支持</div>
<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">捐赠支持</div>
<div class="set-item-content">感谢您的支持让我有动力能够持续改进</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 ? '隐藏列表' : '显示列表' }}
{{ isDonationListVisible ? t('common.hide') : t('common.show') }}
</n-button>
</div>
<donation-list v-if="isDonationListVisible" />
@@ -361,9 +379,9 @@
<n-modal
v-model:show="showProxyModal"
preset="dialog"
title="代理设置"
positive-text="确认"
negative-text="取消"
: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"
@@ -376,7 +394,7 @@
label-width="80"
require-mark-placement="right-hanging"
>
<n-form-item label="代理协议" path="protocol">
<n-form-item :label="t('settings.network.proxy')" path="protocol">
<n-select
v-model:value="proxyForm.protocol"
:options="[
@@ -386,13 +404,16 @@
]"
/>
</n-form-item>
<n-form-item label="代理地址" path="host">
<n-input v-model:value="proxyForm.host" placeholder="请输入代理地址" />
<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="代理端口" path="port">
<n-form-item :label="t('settings.network.proxyPort')" path="port">
<n-input-number
v-model:value="proxyForm.port"
placeholder="请输入代理端口"
:placeholder="t('settings.network.proxyPortPlaceholder')"
:min="1"
:max="65535"
/>
@@ -403,9 +424,9 @@
<n-modal
v-model:show="showClearCacheModal"
preset="dialog"
title="清除缓存"
positive-text="确认"
negative-text="取消"
:title="t('settings.system.cache')"
:positive-text="t('common.confirm')"
:negative-text="t('common.cancel')"
@positive-click="clearCache"
@negative-click="
() => {
@@ -414,7 +435,7 @@
"
>
<n-space vertical>
<p>请选择要清除的缓存类型</p>
<p>{{ t('settings.system.cacheClearTitle') }}</p>
<n-checkbox-group v-model:value="selectedCacheTypes">
<n-space vertical>
<n-checkbox
@@ -425,8 +446,10 @@
>
<template #default>
<div>
<div>{{ option.label }}</div>
<div class="text-gray-400 text-sm">{{ option.description }}</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>
@@ -441,12 +464,14 @@
import type { FormRules } from 'naive-ui';
import { useMessage } from 'naive-ui';
import { computed, h, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
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 { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
@@ -463,11 +488,7 @@ const updateInfo = ref<UpdateResult>({
releaseInfo: null
});
const closeActionLabels = {
ask: '每次询问',
minimize: '最小化到托盘',
close: '直接退出'
} as const;
const { t } = useI18n();
const setData = computed(() => {
const data = store.state.setData;
@@ -515,15 +536,15 @@ const checkForUpdates = async (isClick = false) => {
if (result) {
updateInfo.value = result;
if (!result.hasUpdate && isClick) {
message.success('当前已是最新版本');
message.success(t('settings.about.latest'));
}
} else if (isClick) {
message.success('当前已是最新版本');
message.success(t('settings.about.latest'));
}
} catch (error) {
console.error('检查更新失败:', error);
if (isClick) {
message.error('检查更新失败,请稍后重试');
message.error(t('settings.about.messages.checkError'));
}
} finally {
checking.value = false;
@@ -559,12 +580,12 @@ const proxyForm = ref({
const proxyRules: FormRules = {
protocol: {
required: true,
message: '请选择代理协议',
message: t('settings.validation.selectProxyProtocol'),
trigger: ['blur', 'change']
},
host: {
required: true,
message: '请输入代理地址',
message: t('settings.validation.proxyHost'),
trigger: ['blur', 'change'],
validator: (_rule, value) => {
if (!value) return false;
@@ -576,7 +597,7 @@ const proxyRules: FormRules = {
},
port: {
required: true,
message: '请输入有效的端口号(1-65535)',
message: t('settings.validation.portNumber'),
trigger: ['blur', 'change'],
validator: (_rule, value) => {
return value >= 1 && value <= 65535;
@@ -675,9 +696,9 @@ const handleProxyConfirm = async () => {
}
});
showProxyModal.value = false;
message.success('代理设置已保存,重启应用后生效');
message.success(t('settings.network.messages.proxySuccess'));
} catch (err) {
message.error('请检查输入是否正确');
message.error(t('settings.network.messages.proxyError'));
}
};
@@ -690,10 +711,10 @@ const validateAndSaveRealIP = () => {
enableRealIP: true
});
if (setData.value.realIP) {
message.success('真实IP设置已保存');
message.success(t('settings.network.messages.realIPSuccess'));
}
} else {
message.error('请输入有效的IP地址');
message.error(t('settings.network.messages.realIPError'));
store.commit('setSetData', {
...setData.value,
realIP: ''
@@ -725,13 +746,41 @@ const toggleDonationList = () => {
//
const showClearCacheModal = ref(false);
const clearCacheOptions = ref([
{ label: '播放历史', key: 'history', description: '清除播放过的歌曲记录' },
{ label: '收藏记录', key: 'favorite', description: '清除本地收藏的歌曲记录(不会影响云端收藏)' },
{ label: '用户数据', key: 'user', description: '清除登录信息和用户相关数据' },
{ label: '应用设置', key: 'settings', description: '清除应用的所有自定义设置' },
{ label: '下载记录', key: 'downloads', description: '清除下载历史记录(不会删除已下载的文件)' },
{ label: '音乐资源', key: 'resources', description: '清除已加载的音乐文件、歌词等资源缓存' },
{ label: '歌词资源', key: 'lyrics', description: '清除已加载的歌词资源缓存' }
{
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[]>([]);
@@ -795,7 +844,7 @@ const clearCache = async () => {
});
await Promise.all(clearTasks);
message.success('清除成功,部分设置在重启后生效');
message.success(t('settings.system.messages.clearSuccess'));
showClearCacheModal.value = false;
selectedCacheTypes.value = [];
};
@@ -808,13 +857,13 @@ const handleShortcutsChange = (shortcuts: any) => {
//
const settingSections = [
{ id: 'basic', title: '基础设置' },
{ id: 'playback', title: '播放设置' },
{ id: 'application', title: '应用设置', electron: true },
{ id: 'network', title: '网络设置', electron: true },
{ id: 'system', title: '系统管理', electron: true },
{ id: 'about', title: '关于' },
{ id: 'donation', title: '捐赠支持' }
{ 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') }
];
//
@@ -992,4 +1041,8 @@ onMounted(() => {
}
}
}
:deep(.n-select) {
width: 200px;
}
</style>
+16 -10
View File
@@ -13,22 +13,22 @@
<div class="user-info-list">
<div class="user-info-item">
<div class="label">{{ userDetail.profile.followeds }}</div>
<div>粉丝</div>
<div>{{ t('user.profile.followers') }}</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.profile.follows }}</div>
<div>关注</div>
<div>{{ t('user.profile.following') }}</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.level }}</div>
<div>等级</div>
<div>{{ t('user.profile.level') }}</div>
</div>
</div>
</div>
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">创建的歌单</div>
<div class="title">{{ t('user.playlist.created') }}</div>
<n-scrollbar>
<div
v-for="(item, index) in playList"
@@ -45,7 +45,9 @@
<div class="play-list-item-info">
<div class="play-list-item-name">{{ item.name }}</div>
<div class="play-list-item-count">
{{ item.trackCount }}播放{{ item.playCount }}
{{ t('user.playlist.trackCount', { count: item.trackCount }) }}{{
t('user.playlist.playCount', { count: item.playCount })
}}
</div>
</div>
</div>
@@ -61,7 +63,7 @@
class="right"
:class="setAnimationClass('animate__fadeInRight')"
>
<div class="title">听歌排行</div>
<div class="title">{{ t('user.ranking.title') }}</div>
<div class="record-list">
<n-scrollbar>
<div
@@ -72,7 +74,9 @@
:style="setAnimationDelay(index, 25)"
>
<song-item class="song-item" :item="item" @play="handlePlay" />
<div class="play-count">{{ item.playCount }}</div>
<div class="play-count">
{{ t('user.ranking.playCount', { count: item.playCount }) }}
</div>
</div>
<play-bottom />
</n-scrollbar>
@@ -93,6 +97,7 @@
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
@@ -110,6 +115,7 @@ defineOptions({
name: 'User'
});
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const userDetail = ref<IUserDetail>();
@@ -251,15 +257,15 @@ const handleRemoveFromPlaylist = async (songId: number) => {
});
if (res.status === 200) {
message.success('删除成功');
message.success(t('user.message.deleteSuccess'));
//
await loadPlaylistDetail(list.value.id);
} else {
throw new Error(res.data?.msg || '删除失败');
throw new Error(res.data?.msg || t('user.message.deleteFailed'));
}
} catch (error: any) {
console.error('删除歌曲失败:', error);
message.error(error.message || '删除失败');
message.error(error.message || t('user.message.deleteFailed'));
}
};