feat: 歌单列表相添加布局切换、播放全部、收藏、添加到播放列表

This commit is contained in:
alger
2025-05-15 21:20:01 +08:00
parent 3c792ce3cc
commit b32408b44e
7 changed files with 498 additions and 41 deletions

View File

@@ -104,6 +104,17 @@ export default {
},
musicList: {
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'
}
};

View File

@@ -102,6 +102,17 @@ export default {
},
musicList: {
searchSongs: '搜索歌曲',
noSearchResults: '没有找到相关歌曲'
noSearchResults: '没有找到相关歌曲',
switchToNormal: '切换到默认布局',
switchToCompact: '切换到紧凑布局',
playAll: '播放全部',
collect: '收藏',
collectSuccess: '收藏成功',
cancelCollectSuccess: '取消收藏成功',
operationFailed: '操作失败',
cancelCollect: '取消收藏',
addToPlaylist: '添加到播放列表',
addToPlaylistSuccess: '添加到播放列表成功',
songsAlreadyInPlaylist: '歌曲已存在于播放列表中'
}
};

View File

@@ -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
});
}

View File

@@ -1,14 +1,19 @@
<template>
<div
class="song-item"
:class="{ 'song-mini': mini, 'song-list': list }"
:class="{ 'song-mini': mini, 'song-list': list, 'song-compact': compact }"
@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">
<n-checkbox :checked="selected" />
</div>
<n-image
v-if="item.picUrl"
v-if="item.picUrl && !compact"
ref="songImg"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
@@ -18,9 +23,9 @@
}"
@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">
<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
}}</n-ellipsis>
<div class="song-item-content-divider">-</div>
@@ -35,9 +40,36 @@
</template>
</n-ellipsis>
</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>
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
<div class="song-item-content-title" @dblclick="playMusicEvent(item)">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
@@ -53,15 +85,18 @@
</div>
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div v-if="favorite" class="song-item-operating-like">
<div class="song-item-operating" :class="{
'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
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="toggleFavorite"
></i>
</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>
<div class="song-item-operating-next" @click.stop="handlePlayNext">
<i class="iconfont ri-skip-forward-fill"></i>
@@ -71,12 +106,15 @@
</n-tooltip>
<div
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)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</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>
<n-dropdown
v-if="isElectron"
@@ -95,7 +133,7 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash';
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 { useI18n } from 'vue-i18n';
@@ -114,20 +152,24 @@ const props = withDefaults(
item: SongResult;
mini?: boolean;
list?: boolean;
compact?: boolean;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
mini: false,
list: false,
compact: false,
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false
isNext: false,
index: undefined
}
);
@@ -147,6 +189,7 @@ const isPlaying = computed(() => {
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isHovering = ref(false);
const isDownloading = ref(false);
@@ -172,26 +215,49 @@ const renderSongPreview = () => {
h(
'div',
{
class: 'flex-1 min-w-0 py-1'
class: 'flex-1 min-w-0 py-1 overflow-hidden'
},
[
h(
'div',
{
class: 'mb-1'
class: 'mb-1 overflow-hidden'
},
[
h(
NText,
NEllipsis,
{
lineClamp: 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
}
)
]
),
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;
};
const handleMenuClick = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
const handleSelect = (key: string | number) => {
showDropdown.value = false;
if (key === 'download') {
@@ -435,6 +508,33 @@ const handlePlayNext = () => {
playerStore.addToNextPlay(props.item);
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>
<style lang="scss" scoped>
@@ -452,12 +552,23 @@ const handlePlayNext = () => {
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
.song-item-operating-compact {
.song-item-operating-like,
.song-item-operating-play {
@apply opacity-100;
}
}
}
&-img {
@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 {
@apply flex-1;
@@ -468,6 +579,26 @@ const handlePlayNext = () => {
&-name {
@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 {
@@ -514,6 +645,14 @@ const handlePlayNext = () => {
@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 {
@@ -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 {
@apply p-2 rounded-2xl;

View File

@@ -535,7 +535,9 @@ export const usePlayerStore = defineStore('player', () => {
// 记录到本地存储,保持一致性
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
if (success) {
isPlay.value = true;
}
return success;
} catch (error) {
console.error('设置播放失败:', error);

View File

@@ -42,6 +42,9 @@ export interface SongResult {
expiredAt?: number;
// 获取时间
createdAt?: number;
// 时长
duration?: number;
dt?: number;
}
export interface Song {

View File

@@ -7,20 +7,75 @@
</div>
</n-ellipsis>
<!-- 搜索框 -->
<div class="flex-grow flex-1 flex items-center justify-end">
<div class="search-container">
<n-input
v-model:value="searchKeyword"
:placeholder="t('comp.musicList.searchSongs')"
clearable
round
size="small"
>
<template #prefix>
<i class="icon iconfont ri-search-line text-sm"></i>
<!-- 搜索框和布局切换 -->
<div class="flex-grow flex-1 flex items-center justify-end gap-2">
<!-- 操作按钮组 -->
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<div class="action-button hover-green" @click="handlePlayAll">
<i class="icon iconfont ri-play-fill"></i>
</div>
</template>
{{ t('comp.musicList.playAll') }}
</n-tooltip>
<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>
</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>
@@ -65,14 +120,16 @@
class="song-virtual-list"
style="height: calc(80vh - 60px)"
:items="filteredSongs"
:item-size="70"
:item-size="isCompactLayout ? 50 : 70"
item-resizable
key-field="id"
@scroll="handleVirtualScroll"
>
<template #default="{ item }">
<template #default="{ item, index }">
<div class="double-item">
<song-item
<song-item
:index="index"
:compact="isCompactLayout"
:item="formatSong(item)"
:can-remove="canRemove"
@play="handlePlay"
@@ -97,10 +154,10 @@ defineOptions({
});
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 { useRoute } from 'vue-router';
import { updatePlaylistTracks } from '@/api/music';
import { updatePlaylistTracks, subscribePlaylist } from '@/api/music';
import { useMessage } from 'naive-ui';
import { getMusicDetail, getMusicListByType } from '@/api/music';
@@ -122,6 +179,8 @@ const loading = ref(false);
const songList = ref<any[]>([]);
const listInfo = ref<any>(null);
const canRemove = ref(false);
const canCollect = ref(false);
const isCollected = ref(false);
const page = ref(0);
const pageSize = 40;
@@ -135,6 +194,35 @@ const hasMore = ref(true); // 标记是否还有更多数据可加载
const searchKeyword = ref(''); // 搜索关键词
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(() => {
if (listInfo.value?.trackIds) {
@@ -146,6 +234,7 @@ const total = computed(() => {
// 初始化数据
onMounted(() => {
initData();
checkCollectionStatus();
});
// 从 pinia 或路由参数获取数据
@@ -639,6 +728,105 @@ watch(searchKeyword, () => {
onUnmounted(() => {
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>
<style scoped lang="scss">
@@ -701,15 +889,24 @@ onUnmounted(() => {
}
.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) {
@apply bg-light-200 dark:bg-dark-200;
}
.icon {
@apply text-gray-500 dark:text-gray-400;
}
}
.no-result {
@@ -771,4 +968,35 @@ onUnmounted(() => {
@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>