mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-25 00:37:24 +08:00
✨ feat: 歌单列表相添加布局切换、播放全部、收藏、添加到播放列表
This commit is contained in:
@@ -104,6 +104,17 @@ export default {
|
|||||||
},
|
},
|
||||||
musicList: {
|
musicList: {
|
||||||
searchSongs: 'Search Songs',
|
searchSongs: 'Search Songs',
|
||||||
noSearchResults: 'No search results'
|
noSearchResults: 'No search results',
|
||||||
|
switchToNormal: 'Switch to normal layout',
|
||||||
|
switchToCompact: 'Switch to compact layout',
|
||||||
|
playAll: 'Play All',
|
||||||
|
collect: 'Collect',
|
||||||
|
collectSuccess: 'Collect Success',
|
||||||
|
cancelCollectSuccess: 'Cancel Collect Success',
|
||||||
|
cancelCollect: 'Cancel Collect',
|
||||||
|
addToPlaylist: 'Add to Playlist',
|
||||||
|
addToPlaylistSuccess: 'Add to Playlist Success',
|
||||||
|
operationFailed: 'Operation Failed',
|
||||||
|
songsAlreadyInPlaylist: 'Songs already in playlist'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ export default {
|
|||||||
},
|
},
|
||||||
musicList: {
|
musicList: {
|
||||||
searchSongs: '搜索歌曲',
|
searchSongs: '搜索歌曲',
|
||||||
noSearchResults: '没有找到相关歌曲'
|
noSearchResults: '没有找到相关歌曲',
|
||||||
|
switchToNormal: '切换到默认布局',
|
||||||
|
switchToCompact: '切换到紧凑布局',
|
||||||
|
playAll: '播放全部',
|
||||||
|
collect: '收藏',
|
||||||
|
collectSuccess: '收藏成功',
|
||||||
|
cancelCollectSuccess: '取消收藏成功',
|
||||||
|
operationFailed: '操作失败',
|
||||||
|
cancelCollect: '取消收藏',
|
||||||
|
addToPlaylist: '添加到播放列表',
|
||||||
|
addToPlaylistSuccess: '添加到播放列表成功',
|
||||||
|
songsAlreadyInPlaylist: '歌曲已存在于播放列表中'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -201,3 +201,11 @@ export function getPlaylistDetail(id: string) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribePlaylist(params: { t: number; id: number }) {
|
||||||
|
return request({
|
||||||
|
url: '/playlist/subscribe',
|
||||||
|
method: 'post',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="song-item"
|
class="song-item"
|
||||||
:class="{ 'song-mini': mini, 'song-list': list }"
|
:class="{ 'song-mini': mini, 'song-list': list, 'song-compact': compact }"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
@contextmenu.prevent="handleContextMenu"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
>
|
>
|
||||||
|
<div v-if="compact && index !== undefined" class="song-item-index" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
<div v-if="selectable" class="song-item-select" @click.stop="toggleSelect">
|
<div v-if="selectable" class="song-item-select" @click.stop="toggleSelect">
|
||||||
<n-checkbox :checked="selected" />
|
<n-checkbox :checked="selected" />
|
||||||
</div>
|
</div>
|
||||||
<n-image
|
<n-image
|
||||||
v-if="item.picUrl"
|
v-if="item.picUrl && !compact"
|
||||||
ref="songImg"
|
ref="songImg"
|
||||||
:src="getImgUrl(item.picUrl, '100y100')"
|
:src="getImgUrl(item.picUrl, '100y100')"
|
||||||
class="song-item-img"
|
class="song-item-img"
|
||||||
@@ -18,9 +23,9 @@
|
|||||||
}"
|
}"
|
||||||
@load="imageLoad"
|
@load="imageLoad"
|
||||||
/>
|
/>
|
||||||
<div class="song-item-content">
|
<div class="song-item-content" :class="{ 'song-item-content-compact': compact }">
|
||||||
<div v-if="list" class="song-item-content-wrapper">
|
<div v-if="list" class="song-item-content-wrapper">
|
||||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{
|
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{
|
||||||
item.name
|
item.name
|
||||||
}}</n-ellipsis>
|
}}</n-ellipsis>
|
||||||
<div class="song-item-content-divider">-</div>
|
<div class="song-item-content-divider">-</div>
|
||||||
@@ -35,9 +40,36 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else-if="compact">
|
||||||
|
<div class="song-item-content-compact-wrapper">
|
||||||
|
<div class="w-60 flex-shrink-0 flex items-center" @dblclick="playMusicEvent(item)">
|
||||||
|
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ item.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="w-40 flex-shrink-0 song-item-content-compact-artist flex items-center">
|
||||||
|
<n-ellipsis line-clamp="1">
|
||||||
|
<template v-for="(artist, index) in artists" :key="index">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer hover:text-green-500"
|
||||||
|
@click.stop="handleArtistClick(artist.id)"
|
||||||
|
>{{ artist.name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="index < artists.length - 1"> / </span>
|
||||||
|
</template>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-album flex items-center">
|
||||||
|
<n-ellipsis line-clamp="1">{{ item.al?.name || '-' }}</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-duration flex items-center">
|
||||||
|
{{ formatDuration(getDuration(item)) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="song-item-content-title">
|
<div class="song-item-content-title" @dblclick="playMusicEvent(item)">
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-item-content-name">
|
<div class="song-item-content-name">
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||||
@@ -53,15 +85,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
<div class="song-item-operating" :class="{
|
||||||
<div v-if="favorite" class="song-item-operating-like">
|
'song-item-operating-list': list,
|
||||||
|
'song-item-operating-compact': compact
|
||||||
|
}">
|
||||||
|
<div v-if="favorite" class="song-item-operating-like" :class="{ 'opacity-0': compact && !isHovering && !isFavorite }">
|
||||||
<i
|
<i
|
||||||
class="iconfont icon-likefill"
|
class="iconfont icon-likefill"
|
||||||
:class="{ 'like-active': isFavorite }"
|
:class="{ 'like-active': isFavorite }"
|
||||||
@click.stop="toggleFavorite"
|
@click.stop="toggleFavorite"
|
||||||
></i>
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
|
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="song-item-operating-next" @click.stop="handlePlayNext">
|
<div class="song-item-operating-next" @click.stop="handlePlayNext">
|
||||||
<i class="iconfont ri-skip-forward-fill"></i>
|
<i class="iconfont ri-skip-forward-fill"></i>
|
||||||
@@ -71,12 +106,15 @@
|
|||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<div
|
<div
|
||||||
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
|
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
|
||||||
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
|
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading, 'opacity-0': compact && !isHovering && !isPlaying }"
|
||||||
@click="playMusicEvent(item)"
|
@click="playMusicEvent(item)"
|
||||||
>
|
>
|
||||||
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||||
<i v-else class="iconfont icon-playfill"></i>
|
<i v-else class="iconfont icon-playfill"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="compact" class="song-item-operating-menu" @click.stop="handleMenuClick" :class="{ 'opacity-0': compact && !isHovering && !isPlaying }">
|
||||||
|
<i class="iconfont ri-more-fill"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-dropdown
|
<n-dropdown
|
||||||
v-if="isElectron"
|
v-if="isElectron"
|
||||||
@@ -95,7 +133,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import type { MenuOption } from 'naive-ui';
|
import type { MenuOption } from 'naive-ui';
|
||||||
import { NImage, NText, useMessage } from 'naive-ui';
|
import { NEllipsis, NImage, useMessage } from 'naive-ui';
|
||||||
import { computed, h, inject, ref, useTemplateRef } from 'vue';
|
import { computed, h, inject, ref, useTemplateRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -114,20 +152,24 @@ const props = withDefaults(
|
|||||||
item: SongResult;
|
item: SongResult;
|
||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
list?: boolean;
|
list?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
canRemove?: boolean;
|
canRemove?: boolean;
|
||||||
isNext?: boolean;
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
mini: false,
|
mini: false,
|
||||||
list: false,
|
list: false,
|
||||||
|
compact: false,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
canRemove: false,
|
canRemove: false,
|
||||||
isNext: false
|
isNext: false,
|
||||||
|
index: undefined
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,6 +189,7 @@ const isPlaying = computed(() => {
|
|||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
const dropdownX = ref(0);
|
const dropdownX = ref(0);
|
||||||
const dropdownY = ref(0);
|
const dropdownY = ref(0);
|
||||||
|
const isHovering = ref(false);
|
||||||
|
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
|
|
||||||
@@ -172,26 +215,49 @@ const renderSongPreview = () => {
|
|||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
class: 'flex-1 min-w-0 py-1'
|
class: 'flex-1 min-w-0 py-1 overflow-hidden'
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
class: 'mb-1'
|
class: 'mb-1 overflow-hidden'
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
h(
|
h(
|
||||||
NText,
|
NEllipsis,
|
||||||
{
|
{
|
||||||
|
lineClamp: 1,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
class: 'text-sm font-medium'
|
class: 'text-sm font-medium w-full',
|
||||||
|
style: 'max-width: 150px; min-width: 120px;'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () => props.item.name
|
default: () => props.item.name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'text-xs text-gray-500 dark:text-gray-400 overflow-hidden'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
NEllipsis,
|
||||||
|
{
|
||||||
|
lineClamp: 1,
|
||||||
|
style: 'max-width: 150px;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => {
|
||||||
|
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(' / ');
|
||||||
|
return artistNames || '未知艺术家';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -268,6 +334,13 @@ const handleContextMenu = (e: MouseEvent) => {
|
|||||||
dropdownY.value = e.clientY;
|
dropdownY.value = e.clientY;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showDropdown.value = true;
|
||||||
|
dropdownX.value = e.clientX;
|
||||||
|
dropdownY.value = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelect = (key: string | number) => {
|
const handleSelect = (key: string | number) => {
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
if (key === 'download') {
|
if (key === 'download') {
|
||||||
@@ -435,6 +508,33 @@ const handlePlayNext = () => {
|
|||||||
playerStore.addToNextPlay(props.item);
|
playerStore.addToNextPlay(props.item);
|
||||||
message.success(t('songItem.message.addedToNextPlay'));
|
message.success(t('songItem.message.addedToNextPlay'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取歌曲时长
|
||||||
|
const getDuration = (item: SongResult): number => {
|
||||||
|
// 检查各种可能的时长属性路径
|
||||||
|
if (item.duration) return item.duration;
|
||||||
|
if (typeof item.dt === 'number') return item.dt;
|
||||||
|
// 遍历可能存在的其他时长属性路径
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (!ms) return '--:--';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标悬停事件
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
isHovering.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
isHovering.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -452,12 +552,23 @@ const handlePlayNext = () => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@apply bg-gray-100 dark:bg-gray-800;
|
@apply bg-gray-100 dark:bg-gray-800;
|
||||||
|
|
||||||
|
.song-item-operating-compact {
|
||||||
|
.song-item-operating-like,
|
||||||
|
.song-item-operating-play {
|
||||||
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-img {
|
&-img {
|
||||||
@apply w-12 h-12 rounded-2xl mr-4;
|
@apply w-12 h-12 rounded-2xl mr-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-index {
|
||||||
|
@apply w-8 text-center text-gray-500 dark:text-gray-400 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
@apply flex-1;
|
@apply flex-1;
|
||||||
|
|
||||||
@@ -468,6 +579,26 @@ const handlePlayNext = () => {
|
|||||||
&-name {
|
&-name {
|
||||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-compact {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
|
||||||
|
&-wrapper {
|
||||||
|
@apply flex-1 min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-artist {
|
||||||
|
@apply text-sm text-gray-500 dark:text-gray-400 ml-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-album {
|
||||||
|
@apply w-32 text-sm text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-duration {
|
||||||
|
@apply w-16 text-sm text-gray-500 dark:text-gray-400 text-right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-operating {
|
&-operating {
|
||||||
@@ -514,6 +645,14 @@ const handlePlayNext = () => {
|
|||||||
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-menu {
|
||||||
|
@apply cursor-pointer flex items-center justify-center px-2;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-select {
|
&-select {
|
||||||
@@ -521,6 +660,61 @@ const handlePlayNext = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-compact {
|
||||||
|
@apply rounded-lg p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-700;
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-content {
|
||||||
|
&-title {
|
||||||
|
@apply text-sm cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-content-compact-wrapper {
|
||||||
|
@apply flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-content-compact-artist {
|
||||||
|
@apply w-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-compact {
|
||||||
|
@apply border-none bg-transparent gap-2 flex items-center;
|
||||||
|
|
||||||
|
.song-item-operating-like,
|
||||||
|
.song-item-operating-play {
|
||||||
|
@apply transition-opacity duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-play {
|
||||||
|
@apply w-7 h-7;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-like {
|
||||||
|
@apply mr-1 ml-0;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.song-mini {
|
.song-mini {
|
||||||
@apply p-2 rounded-2xl;
|
@apply p-2 rounded-2xl;
|
||||||
|
|
||||||
|
|||||||
@@ -535,7 +535,9 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
// 记录到本地存储,保持一致性
|
// 记录到本地存储,保持一致性
|
||||||
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
|
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
|
||||||
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
|
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
|
||||||
|
if (success) {
|
||||||
|
isPlay.value = true;
|
||||||
|
}
|
||||||
return success;
|
return success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('设置播放失败:', error);
|
console.error('设置播放失败:', error);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export interface SongResult {
|
|||||||
expiredAt?: number;
|
expiredAt?: number;
|
||||||
// 获取时间
|
// 获取时间
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
// 时长
|
||||||
|
duration?: number;
|
||||||
|
dt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
|
|||||||
@@ -7,20 +7,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
<!-- 搜索框和布局切换 -->
|
||||||
<div class="flex-grow flex-1 flex items-center justify-end">
|
<div class="flex-grow flex-1 flex items-center justify-end gap-2">
|
||||||
<div class="search-container">
|
<!-- 操作按钮组 -->
|
||||||
<n-input
|
<n-tooltip placement="bottom" trigger="hover">
|
||||||
v-model:value="searchKeyword"
|
<template #trigger>
|
||||||
:placeholder="t('comp.musicList.searchSongs')"
|
<div class="action-button hover-green" @click="handlePlayAll">
|
||||||
clearable
|
<i class="icon iconfont ri-play-fill"></i>
|
||||||
round
|
</div>
|
||||||
size="small"
|
</template>
|
||||||
>
|
{{ t('comp.musicList.playAll') }}
|
||||||
<template #prefix>
|
</n-tooltip>
|
||||||
<i class="icon iconfont ri-search-line text-sm"></i>
|
|
||||||
|
<n-tooltip v-if="canCollect" placement="bottom" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="action-button" :class="isCollected ? 'collected' : 'hover-green'" @click="toggleCollect">
|
||||||
|
<i class="icon iconfont" :class="isCollected ? 'ri-heart-fill' : 'ri-heart-line'"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ isCollected ? t('comp.musicList.cancelCollect') : t('comp.musicList.collect') }}
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip placement="bottom" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="action-button hover-green" @click="addToPlaylist">
|
||||||
|
<i class="icon iconfont ri-add-line"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ t('comp.musicList.addToPlaylist') }}
|
||||||
|
</n-tooltip>
|
||||||
|
<!-- 布局切换按钮 -->
|
||||||
|
<div class="layout-toggle">
|
||||||
|
<n-tooltip placement="bottom" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="toggle-button hover-green" @click="toggleLayout">
|
||||||
|
<i class="icon iconfont" :class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'"></i>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
{{ isCompactLayout ? t('comp.musicList.switchToNormal') : t('comp.musicList.switchToCompact') }}
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-container" :class="{ 'search-expanded': isSearchVisible }">
|
||||||
|
<template v-if="isSearchVisible">
|
||||||
|
<n-input
|
||||||
|
v-model:value="searchKeyword"
|
||||||
|
:placeholder="t('comp.musicList.searchSongs')"
|
||||||
|
clearable
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
@blur="handleSearchBlur"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<i class="icon iconfont ri-search-line text-sm"></i>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<i class="icon iconfont ri-close-line text-sm cursor-pointer" @click="closeSearch"></i>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<n-tooltip placement="bottom" trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="search-button" @click="showSearch">
|
||||||
|
<i class="icon iconfont ri-search-line"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ t('comp.musicList.searchSongs') }}
|
||||||
|
</n-tooltip>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,14 +120,16 @@
|
|||||||
class="song-virtual-list"
|
class="song-virtual-list"
|
||||||
style="height: calc(80vh - 60px)"
|
style="height: calc(80vh - 60px)"
|
||||||
:items="filteredSongs"
|
:items="filteredSongs"
|
||||||
:item-size="70"
|
:item-size="isCompactLayout ? 50 : 70"
|
||||||
item-resizable
|
item-resizable
|
||||||
key-field="id"
|
key-field="id"
|
||||||
@scroll="handleVirtualScroll"
|
@scroll="handleVirtualScroll"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item, index }">
|
||||||
<div class="double-item">
|
<div class="double-item">
|
||||||
<song-item
|
<song-item
|
||||||
|
:index="index"
|
||||||
|
:compact="isCompactLayout"
|
||||||
:item="formatSong(item)"
|
:item="formatSong(item)"
|
||||||
:can-remove="canRemove"
|
:can-remove="canRemove"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@@ -97,10 +154,10 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
import PinyinMatch from 'pinyin-match';
|
import PinyinMatch from 'pinyin-match';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { updatePlaylistTracks } from '@/api/music';
|
import { updatePlaylistTracks, subscribePlaylist } from '@/api/music';
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
|
|
||||||
import { getMusicDetail, getMusicListByType } from '@/api/music';
|
import { getMusicDetail, getMusicListByType } from '@/api/music';
|
||||||
@@ -122,6 +179,8 @@ const loading = ref(false);
|
|||||||
const songList = ref<any[]>([]);
|
const songList = ref<any[]>([]);
|
||||||
const listInfo = ref<any>(null);
|
const listInfo = ref<any>(null);
|
||||||
const canRemove = ref(false);
|
const canRemove = ref(false);
|
||||||
|
const canCollect = ref(false);
|
||||||
|
const isCollected = ref(false);
|
||||||
|
|
||||||
const page = ref(0);
|
const page = ref(0);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
@@ -135,6 +194,35 @@ const hasMore = ref(true); // 标记是否还有更多数据可加载
|
|||||||
const searchKeyword = ref(''); // 搜索关键词
|
const searchKeyword = ref(''); // 搜索关键词
|
||||||
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
|
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
|
||||||
|
|
||||||
|
// 添加搜索相关的状态和方法
|
||||||
|
const isSearchVisible = ref(false);
|
||||||
|
const isCompactLayout = ref(localStorage.getItem('musicListLayout') === 'compact'); // 默认使用紧凑布局
|
||||||
|
|
||||||
|
const showSearch = () => {
|
||||||
|
isSearchVisible.value = true;
|
||||||
|
// 添加一个小延迟后聚焦搜索框
|
||||||
|
nextTick(() => {
|
||||||
|
const inputEl = document.querySelector('.search-container input');
|
||||||
|
if (inputEl) {
|
||||||
|
(inputEl as HTMLInputElement).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
isSearchVisible.value = false;
|
||||||
|
searchKeyword.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchBlur = () => {
|
||||||
|
// 如果搜索框为空,则在失焦时关闭搜索框
|
||||||
|
if (!searchKeyword.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
isSearchVisible.value = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 计算总数
|
// 计算总数
|
||||||
const total = computed(() => {
|
const total = computed(() => {
|
||||||
if (listInfo.value?.trackIds) {
|
if (listInfo.value?.trackIds) {
|
||||||
@@ -146,6 +234,7 @@ const total = computed(() => {
|
|||||||
// 初始化数据
|
// 初始化数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initData();
|
initData();
|
||||||
|
checkCollectionStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 从 pinia 或路由参数获取数据
|
// 从 pinia 或路由参数获取数据
|
||||||
@@ -639,6 +728,105 @@ watch(searchKeyword, () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
isPlaylistLoading.value = false;
|
isPlaylistLoading.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 切换布局
|
||||||
|
const toggleLayout = () => {
|
||||||
|
isCompactLayout.value = !isCompactLayout.value;
|
||||||
|
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化歌单收藏状态
|
||||||
|
const checkCollectionStatus = () => {
|
||||||
|
// 只有歌单类型才能收藏
|
||||||
|
if (route.query.type === 'playlist' && listInfo.value?.id) {
|
||||||
|
canCollect.value = true;
|
||||||
|
// 检查是否已收藏
|
||||||
|
isCollected.value = listInfo.value.subscribed || false;
|
||||||
|
} else {
|
||||||
|
canCollect.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换收藏状态
|
||||||
|
const toggleCollect = async () => {
|
||||||
|
if (!listInfo.value?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingList.value = true;
|
||||||
|
const tVal = isCollected.value ? 2 : 1; // 1:收藏, 2:取消收藏
|
||||||
|
const response = await subscribePlaylist({
|
||||||
|
t: tVal,
|
||||||
|
id: listInfo.value.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// 假设API返回格式是 { data: { code: number, msg?: string } }
|
||||||
|
const res = response.data;
|
||||||
|
|
||||||
|
if (res.code === 200) {
|
||||||
|
isCollected.value = !isCollected.value;
|
||||||
|
const msgKey = isCollected.value
|
||||||
|
? 'comp.musicList.collectSuccess'
|
||||||
|
: 'comp.musicList.cancelCollectSuccess';
|
||||||
|
message.success(t(msgKey));
|
||||||
|
// 更新歌单信息
|
||||||
|
listInfo.value.subscribed = isCollected.value;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.msg || t('comp.musicList.operationFailed'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('收藏歌单失败:', error);
|
||||||
|
message.error(t('comp.musicList.operationFailed'));
|
||||||
|
} finally {
|
||||||
|
loadingList.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放全部
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (displayedSongs.value.length === 0) return;
|
||||||
|
|
||||||
|
// 如果有搜索关键词,只播放过滤后的歌曲
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
playerStore.setPlayList(filteredSongs.value.map(formatSong));
|
||||||
|
playerStore.setPlay(formatSong(filteredSongs.value[0]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则播放全部歌曲
|
||||||
|
// 使用setPlayList设置播放列表
|
||||||
|
playerStore.setPlayList(displayedSongs.value.map(formatSong));
|
||||||
|
// 使用setPlay开始播放第一首
|
||||||
|
playerStore.setPlay(formatSong(displayedSongs.value[0]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到播放列表末尾
|
||||||
|
const addToPlaylist = () => {
|
||||||
|
if (displayedSongs.value.length === 0) return;
|
||||||
|
|
||||||
|
// 获取当前播放列表
|
||||||
|
const currentList = playerStore.playList;
|
||||||
|
|
||||||
|
// 如果有搜索关键词,只添加过滤后的歌曲
|
||||||
|
const songsToAdd = searchKeyword.value
|
||||||
|
? filteredSongs.value
|
||||||
|
: displayedSongs.value;
|
||||||
|
|
||||||
|
// 添加歌曲到播放列表(避免重复添加)
|
||||||
|
const newSongs = songsToAdd.filter(song =>
|
||||||
|
!currentList.some(item => item.id === song.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newSongs.length === 0) {
|
||||||
|
message.info(t('comp.musicList.songsAlreadyInPlaylist'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并到当前播放列表末尾
|
||||||
|
const newList = [...currentList, ...newSongs.map(formatSong)];
|
||||||
|
playerStore.setPlayList(newList);
|
||||||
|
|
||||||
|
message.success(t('comp.musicList.addToPlaylistSuccess', { count: newSongs.length }));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -701,15 +889,24 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
@apply max-w-md;
|
@apply max-w-md transition-all duration-300 ease-in-out;
|
||||||
|
|
||||||
|
&.search-expanded {
|
||||||
|
@apply w-52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
@apply w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:bg-light-300 dark:hover:bg-dark-300 transition-colors text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply text-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.n-input) {
|
:deep(.n-input) {
|
||||||
@apply bg-light-200 dark:bg-dark-200;
|
@apply bg-light-200 dark:bg-dark-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
|
||||||
@apply text-gray-500 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-result {
|
.no-result {
|
||||||
@@ -771,4 +968,35 @@ onUnmounted(() => {
|
|||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-toggle {
|
||||||
|
.toggle-button {
|
||||||
|
@apply w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:bg-light-300 dark:hover:bg-dark-300 transition-colors;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply text-lg text-gray-500 dark:text-gray-400 transition-colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-toggle .toggle-button,
|
||||||
|
.action-button {
|
||||||
|
@apply w-8 h-8 rounded-full flex items-center justify-center cursor-pointer hover:bg-light-300 dark:hover:bg-dark-300 transition-colors text-gray-500 dark:text-gray-400;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collected {
|
||||||
|
.icon {
|
||||||
|
@apply text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hover-green:hover {
|
||||||
|
.icon {
|
||||||
|
@apply text-green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user