🦄 refactor: 重构歌曲组件,添加基础组件和多种样式,优化播放列表抽屉功能

This commit is contained in:
alger
2025-05-23 19:39:46 +08:00
parent 6048e243c7
commit ad7b504eef
11 changed files with 1410 additions and 798 deletions

View File

@@ -4,6 +4,7 @@
:width="400"
placement="right"
@update:show="$emit('update:modelValue', $event)"
:unstable-show-mask="false"
>
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
<n-scrollbar class="h-full">

View File

@@ -1,150 +1,27 @@
<template>
<div
class="song-item"
:class="{ 'song-mini': mini, 'song-list': list, 'song-compact': compact }"
@contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick.stop="playMusicEvent(item)"
>
<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 && !compact"
ref="songImg"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="imageLoad"
/>
<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" :class="{ 'text-green-500': isPlaying }">{{
item.name
}}</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-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>
<template v-else-if="compact">
<div class="song-item-content-compact-wrapper">
<div class="w-60 flex-shrink-0 flex items-center">
<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" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-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>
</template>
</div>
<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">
<template #trigger>
<div class="song-item-operating-next" @click.stop="handlePlayNext">
<i class="iconfont ri-skip-forward-fill"></i>
</div>
</template>
{{ t('songItem.menu.playNext') }}
</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, '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"
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
:z-index="99999999"
placement="bottom-start"
@clickoutside="showDropdown = false"
@select="handleSelect"
/>
</div>
<component
:is="renderComponent"
:item="item"
:favorite="favorite"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="$emit('play', $event)"
@select="$emit('select', $event)"
@remove-song="$emit('remove-song', $event)"
/>
</template>
<script lang="ts" setup>
import type { MenuOption } from 'naive-ui';
import { NEllipsis, NImage, useMessage, useDialog } from 'naive-ui';
import { computed, h, inject, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore } from '@/store';
import { computed } from 'vue';
import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
import { useDownload } from '@/hooks/useDownload';
const { t } = useI18n();
import StandardSongItem from './songItemCom/StandardSongItem.vue';
import MiniSongItem from './songItemCom/MiniSongItem.vue';
import ListSongItem from './songItemCom/ListSongItem.vue';
import CompactSongItem from './songItemCom/CompactSongItem.vue';
const props = withDefaults(
defineProps<{
@@ -172,649 +49,13 @@ const props = withDefaults(
}
);
const playerStore = usePlayerStore();
defineEmits(['play', 'select', 'remove-song']);
const message = useMessage();
const play = computed(() => playerStore.isPlay);
const playMusic = computed(() => playerStore.playMusic);
const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
);
const isPlaying = computed(() => {
return playMusic.value.id === props.item.id;
// 根据属性决定渲染哪个组件
const renderComponent = computed(() => {
if (props.mini) return MiniSongItem;
if (props.list) return ListSongItem;
if (props.compact) return CompactSongItem;
return StandardSongItem;
});
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isHovering = ref(false);
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const { navigateToArtist } = useArtist();
const renderSongPreview = () => {
return h(
'div',
{
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
},
[
h(NImage, {
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
class: 'w-10 h-10 rounded-lg flex-shrink-0',
previewDisabled: true,
imgProps: {
crossorigin: 'anonymous'
}
}),
h(
'div',
{
class: 'flex-1 min-w-0 py-1 overflow-hidden'
},
[
h(
'div',
{
class: 'mb-1 overflow-hidden'
},
[
h(
NEllipsis,
{
lineClamp: 1,
depth: 1,
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 || '未知艺术家';
}
}
)
]
)
]
)
]
);
};
const dropdownOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{
key: 'header',
type: 'render',
render: renderSongPreview
},
{
key: 'divider1',
type: 'divider'
},
{
label: t('songItem.menu.play'),
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: t('songItem.menu.playNext'),
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
{
type: 'divider',
key: 'd1'
},
{
label: t('songItem.menu.download'),
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: isFavorite.value ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
key: 'favorite',
icon: () =>
h('i', {
class: `iconfont ${isFavorite.value ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
})
},
// 不喜欢
{
label: isDislike.value ? t('songItem.menu.undislike') : t('songItem.menu.dislike'),
key: 'dislike',
icon: () => h('i', { class: `iconfont ${isDislike.value ? 'ri-dislike-fill text-green-500': 'ri-dislike-line'}` })
},
];
if (props.canRemove) {
options.push(
{
type: 'divider',
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
);
}
return options;
});
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
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;
switch (key) {
case 'download':
downloadMusic(props.item);
break;
case 'playNext':
handlePlayNext();
break;
case 'addToPlaylist':
openPlaylistDrawer?.(props.item.id);
break;
case 'favorite':
toggleFavorite(new Event('click'));
break;
case 'play':
playMusicEvent(props.item);
break;
case 'remove':
emits('remove-song', props.item.id);
break;
case 'dislike':
toggleDislike(new Event('click'));
break;
default:
break;
}
};
// 下载音乐
const { downloadMusic } = useDownload();
const emits = defineEmits(['play', 'select', 'remove-song']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
try {
// 使用store的setPlay方法该方法已经包含了B站视频URL处理逻辑
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
emits('play', item);
} catch (error) {
console.error('播放出错:', error);
}
};
// 判断是否已收藏
const isFavorite = computed(() => {
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
});
const isDislike = computed(() => {
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id ==='string'? parseInt(props.item.id, 10) : props.item.id;
return playerStore.dislikeList.includes(numericId);
})
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
};
const dialog = useDialog();
const toggleDislike = async (e: Event) => {
e.stopPropagation();
if (isDislike.value) {
playerStore.removeFromDislikeList(props.item.id);
return;
}
dialog.warning({
title: t('songItem.dialog.dislike.title'),
content: t('songItem.dialog.dislike.content'),
positiveText: t('songItem.dialog.dislike.positiveText'),
negativeText: t('songItem.dialog.dislike.negativeText'),
onPositiveClick: () => {
playerStore.addToDislikeList(props.item.id);
}
});
}
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
};
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
// 获取歌手列表最多显示5个
const artists = computed(() => {
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
});
// 添加到下一首播放
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>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
&:hover {
@apply bg-light-100 dark:bg-dark-100;
.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;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-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 {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4 transition-all;
}
&-next {
@apply mr-2 cursor-pointer transition-all;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
&-download {
@apply mr-2 cursor-pointer;
.iconfont {
@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 {
@apply mr-3 cursor-pointer;
}
}
.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;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}
.song-list {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
:deep(.n-dropdown-menu) {
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
.n-dropdown-option {
@apply h-9 text-sm;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
.n-dropdown-option-body {
@apply h-full;
.n-dropdown-option-body__prefix {
@apply w-8 flex justify-center items-center;
.iconfont {
@apply text-base;
}
}
}
}
.n-dropdown-divider {
@apply my-1;
}
}
:deep(.song-preview) {
@apply flex items-center gap-3 p-3 border-b dark:border-gray-800;
.n-image {
@apply w-12 h-12 rounded-lg flex-shrink-0;
}
.song-preview-info {
@apply flex-1 min-w-0 py-1;
.song-preview-name {
@apply text-sm font-medium truncate mb-1;
}
.song-preview-artist {
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
}
}
}
:deep(.n-dropdown-option-body--render) {
@apply p-0;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div
class="song-item"
@contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick.stop="playMusicEvent(item)"
>
<slot name="index"></slot>
<slot name="select" v-if="selectable"></slot>
<slot name="image"></slot>
<slot name="content"></slot>
<slot name="operating"></slot>
<song-item-dropdown
v-if="isElectron"
:item="item"
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
:is-favorite="isFavorite"
:is-dislike="isDislike"
:can-remove="canRemove"
@update:show="showDropdown = $event"
@play="playMusicEvent(item)"
@play-next="handlePlayNext"
@download="downloadMusic(item)"
@toggle-favorite="toggleFavorite"
@toggle-dislike="toggleDislike"
@remove="$emit('remove-song', $event)"
/>
</div>
</template>
<script lang="ts" setup>
import SongItemDropdown from './SongItemDropdown.vue';
import { useSongItem } from '@/hooks/useSongItem';
import { isElectron } from '@/utils';
import type { SongResult } from '@/type/music';
const props = defineProps<{
item: SongResult;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>();
const emits = defineEmits(['play', 'select', 'remove-song']);
// 使用公共逻辑
const {
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
showDropdown,
dropdownX,
dropdownY,
isHovering,
handleImageLoad,
playMusicEvent,
toggleFavorite,
toggleDislike,
handlePlayNext,
handleContextMenu,
handleMenuClick,
handleArtistClick,
handleMouseEnter,
handleMouseLeave,
downloadMusic
} = useSongItem(props);
// 处理图片加载
const imageLoad = async (event: Event) => {
const target = event.target as HTMLImageElement;
if (!target) return;
await handleImageLoad(target);
};
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
};
// 把图片处理、艺术家处理等公共方法暴露给子组件
defineExpose({
imageLoad,
toggleSelect,
handleArtistClick,
handleMenuClick,
playMusicEvent,
toggleFavorite,
handlePlayNext,
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
isHovering
});
</script>
<style lang="scss" scoped>
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
}
.text-ellipsis {
width: 100%;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="$emit('play', $event)"
@select="$emit('select', $event)"
@remove-song="$emit('remove-song', $event)"
class="compact-song-item"
ref="baseItem"
>
<!-- 索引插槽 -->
<template #index>
<div v-if="index !== undefined" class="song-item-index" :class="{ 'text-green-500': isPlaying }">
{{ index + 1 }}
</div>
</template>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content-compact">
<div class="song-item-content-compact-wrapper">
<div class="song-item-content-compact-title w-60 flex-shrink-0">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }}
</n-ellipsis>
</div>
<div class="song-item-content-compact-artist">
<n-ellipsis line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
<div class="song-item-content-compact-album">
<n-ellipsis line-clamp="1">{{ item.al?.name || '-' }}</n-ellipsis>
</div>
<div class="song-item-content-compact-duration">
{{ formatDuration(getDuration(item)) }}
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating-compact">
<div v-if="favorite" class="song-item-operating-like" :class="{ 'opacity-0': !isHovering && !isFavorite }">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading, 'opacity-0': !isHovering && !isPlaying }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
<div class="song-item-operating-menu" @click.stop="onMenuClick" :class="{ 'opacity-0': !isHovering && !isPlaying }">
<i class="iconfont ri-more-fill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const isHovering = computed(() => baseItem.value?.isHovering || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => baseItem.value?.toggleSelect();
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
const onMenuClick = (event: MouseEvent) => baseItem.value?.handleMenuClick(event);
// 从useSongItem.ts导入格式化时长和获取时长方法
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')}`;
};
</script>
<style lang="scss" scoped>
.compact-song-item {
@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-index {
@apply w-8 text-center text-gray-500 dark:text-gray-400 text-sm;
}
.song-item-select {
@apply mr-3 cursor-pointer;
}
.song-item-content-compact {
@apply flex-1 flex items-center gap-4;
&-wrapper {
@apply flex-1 min-w-0 flex items-center;
}
&-title {
@apply text-sm cursor-pointer text-gray-900 dark:text-white flex items-center;
}
&-artist {
@apply w-40 text-sm text-gray-500 dark:text-gray-400 ml-2 flex items-center;
}
&-album {
@apply w-32 flex items-center text-sm text-gray-500 dark:text-gray-400;
}
&-duration {
@apply w-16 flex items-center text-sm text-gray-500 dark:text-gray-400 text-right;
}
}
.song-item-operating-compact {
@apply border-none bg-transparent gap-2 flex items-center;
.song-item-operating-like,
.song-item-operating-play,
.song-item-operating-menu {
@apply transition-opacity duration-200;
}
.song-item-operating-play {
@apply w-7 h-7 flex items-center justify-center cursor-pointer rounded-full bg-gray-300 dark:bg-gray-800 border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
.iconfont {
@apply text-base;
}
}
.song-item-operating-like {
@apply mr-1 ml-0 cursor-pointer;
.iconfont {
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
}
.song-item-operating-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;
}
}
.opacity-0 {
opacity: 0;
}
}
}
// 全局应用
:deep(.text-ellipsis) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="$emit('play', $event)"
@select="$emit('select', $event)"
@remove-song="$emit('remove-song', $event)"
class="list-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-wrapper">
<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>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating song-item-operating-list">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => baseItem.value?.toggleSelect();
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
</script>
<style lang="scss" scoped>
.list-song-item {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating-list {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="$emit('play', $event)"
@select="$emit('select', $event)"
@remove-song="$emit('remove-song', $event)"
class="mini-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-title">
<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">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
import { computed, ref } from 'vue';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从基础组件获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => baseItem.value?.toggleSelect();
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
</script>
<style lang="scss" scoped>
.mini-song-item {
@apply p-2 rounded-2xl;
&:hover {
@apply bg-light-100 dark:bg-dark-100;
}
.song-item-img {
@apply w-10 h-10 mr-2 rounded-xl;
}
.song-item-content {
@apply flex-1;
&-title {
@apply text-sm text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center rounded-full ml-4 pl-2 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1 cursor-pointer;
.icon-likefill {
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
}
&-play {
@apply cursor-pointer rounded-full w-8 h-8 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<n-dropdown
v-if="isElectron"
:show="show"
:x="x"
:y="y"
:options="dropdownOptions"
:z-index="99999999"
placement="bottom-start"
@clickoutside="$emit('update:show', false)"
@select="handleSelect"
/>
</template>
<script lang="ts" setup>
import type { MenuOption } from 'naive-ui';
import { NEllipsis, NImage, NDropdown } from 'naive-ui';
import { computed, h, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SongResult } from '@/type/music';
import { getImgUrl, isElectron } from '@/utils';
const { t } = useI18n();
const props = defineProps<{
item: SongResult;
show: boolean;
x: number;
y: number;
isFavorite: boolean;
isDislike: boolean;
canRemove?: boolean;
}>();
const emits = defineEmits([
'update:show',
'select',
'play',
'play-next',
'download',
'add-to-playlist',
'toggle-favorite',
'toggle-dislike',
'remove'
]);
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
// 渲染歌曲预览
const renderSongPreview = () => {
return h(
'div',
{
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
},
[
h(NImage, {
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
class: 'w-10 h-10 rounded-lg flex-shrink-0',
previewDisabled: true,
imgProps: {
crossorigin: 'anonymous'
}
}),
h(
'div',
{
class: 'flex-1 min-w-0 py-1 overflow-hidden'
},
[
h(
'div',
{
class: 'mb-1 overflow-hidden'
},
[
h(
NEllipsis,
{
lineClamp: 1,
depth: 1,
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 || '未知艺术家';
}
}
)
]
)
]
)
]
);
};
// 下拉菜单选项
const dropdownOptions = computed<MenuOption[]>(() => {
const options: MenuOption[] = [
{
key: 'header',
type: 'render',
render: renderSongPreview
},
{
key: 'divider1',
type: 'divider'
},
{
label: t('songItem.menu.play'),
key: 'play',
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
},
{
label: t('songItem.menu.playNext'),
key: 'playNext',
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
},
{
type: 'divider',
key: 'd1'
},
{
label: t('songItem.menu.download'),
key: 'download',
icon: () => h('i', { class: 'iconfont ri-download-line' })
},
{
label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
},
{
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
key: 'favorite',
icon: () =>
h('i', {
class: `iconfont ${props.isFavorite ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
})
},
{
label: props.isDislike ? t('songItem.menu.undislike') : t('songItem.menu.dislike'),
key: 'dislike',
icon: () => h('i', { class: `iconfont ${props.isDislike ? 'ri-dislike-fill text-green-500': 'ri-dislike-line'}` })
},
];
if (props.canRemove) {
options.push(
{
type: 'divider',
key: 'd2'
},
{
label: t('songItem.menu.removeFromPlaylist'),
key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
}
);
}
return options;
});
// 处理选择
const handleSelect = (key: string | number) => {
emits('update:show', false);
switch (key) {
case 'download':
emits('download');
break;
case 'playNext':
emits('play-next');
break;
case 'addToPlaylist':
openPlaylistDrawer?.(props.item.id);
break;
case 'favorite':
emits('toggle-favorite');
break;
case 'play':
emits('play');
break;
case 'remove':
emits('remove', props.item.id);
break;
case 'dislike':
emits('toggle-dislike');
break;
default:
break;
}
};
</script>
<style lang="scss" scoped>
:deep(.n-dropdown-menu) {
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
.n-dropdown-option {
@apply h-9 text-sm;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
.n-dropdown-option-body {
@apply h-full;
.n-dropdown-option-body__prefix {
@apply w-8 flex justify-center items-center;
.iconfont {
@apply text-base;
}
}
}
}
.n-dropdown-divider {
@apply my-1;
}
}
:deep(.n-dropdown-option-body--render) {
@apply p-0;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<base-song-item
:item="item"
:selectable="selectable"
:selected="selected"
:can-remove="canRemove"
:is-next="isNext"
:index="index"
@play="$emit('play', $event)"
@select="$emit('select', $event)"
@remove-song="$emit('remove-song', $event)"
class="standard-song-item"
ref="baseItem"
>
<!-- 选择框插槽 -->
<template #select>
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
<n-checkbox :checked="selected" />
</div>
</template>
<!-- 图片插槽 -->
<template #image>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="onImageLoad"
/>
</template>
<!-- 内容插槽 -->
<template #content>
<div class="song-item-content">
<div class="song-item-content-title">
<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">
<template v-for="(artist, index) in artists" :key="index">
<span
class="cursor-pointer hover:text-green-500"
@click.stop="onArtistClick(artist.id)"
>{{ artist.name }}</span
>
<span v-if="index < artists.length - 1"> / </span>
</template>
</n-ellipsis>
</div>
</div>
</template>
<!-- 操作插槽 -->
<template #operating>
<div class="song-item-operating">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="onToggleFavorite"
></i>
</div>
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
<template #trigger>
<div class="song-item-operating-next" @click.stop="onPlayNext">
<i class="iconfont ri-skip-forward-fill"></i>
</div>
</template>
{{ t('songItem.menu.playNext') }}
</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 }"
@click="onPlayMusic"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</template>
</base-song-item>
</template>
<script lang="ts" setup>
import { NCheckbox, NEllipsis, NImage, NTooltip } from 'naive-ui';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { usePlayerStore } from '@/store';
import BaseSongItem from './BaseSongItem.vue';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const { t } = useI18n();
const playerStore = usePlayerStore();
const props = withDefaults(
defineProps<{
item: SongResult;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
canRemove?: boolean;
isNext?: boolean;
index?: number;
}>(),
{
favorite: true,
selectable: false,
selected: false,
canRemove: false,
isNext: false,
index: undefined
}
);
defineEmits(['play', 'select', 'remove-song']);
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
// 从playerStore和baseItem获取响应式状态
const play = computed(() => playerStore.isPlay);
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
const playLoading = computed(() => baseItem.value?.playLoading || false);
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
const artists = computed(() => baseItem.value?.artists || []);
// 包装方法避免直接访问可能为undefined的ref
const onToggleSelect = () => baseItem.value?.toggleSelect();
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
const onPlayNext = () => baseItem.value?.handlePlayNext();
</script>
<style lang="scss" scoped>
.standard-song-item {
&:hover {
@apply bg-light-100 dark:bg-dark-100;
}
.song-item-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
.song-item-content {
@apply flex-1;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4 transition-all;
}
&-next {
@apply mr-2 cursor-pointer transition-all;
.iconfont {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
}
}
.like-active {
@apply text-red-500 dark:text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
.song-item-select {
@apply mr-3 cursor-pointer;
}
}
</style>

View File

@@ -0,0 +1,188 @@
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/type/music';
import { computed, ref } from 'vue';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
import { useMessage, useDialog } from 'naive-ui';
import { useI18n } from 'vue-i18n';
import { useDownload } from './useDownload';
import { useArtist } from './useArtist';
export function useSongItem(props: {
item: SongResult;
canRemove?: boolean;
}) {
const { t } = useI18n();
const playerStore = usePlayerStore();
const message = useMessage();
const dialog = useDialog();
const { downloadMusic } = useDownload();
const { navigateToArtist } = useArtist();
// 状态变量
const showDropdown = ref(false);
const dropdownX = ref(0);
const dropdownY = ref(0);
const isHovering = ref(false);
// 计算属性
const play = computed(() => playerStore.isPlay);
const playMusic = computed(() => playerStore.playMusic);
const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
);
const isPlaying = computed(() => playMusic.value.id === props.item.id);
// 收藏与不喜欢状态
const isFavorite = computed(() => {
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
});
const isDislike = computed(() => {
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.dislikeList.includes(numericId);
});
// 获取艺术家列表
const artists = computed(() => {
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
});
// 处理图片加载
const handleImageLoad = async (imageElement: HTMLImageElement) => {
if (!imageElement) return;
const { backgroundColor } = await getImageBackground(imageElement);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐
const playMusicEvent = async (item: SongResult) => {
try {
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
return true;
} catch (error) {
console.error('播放出错:', error);
return false;
}
};
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(numericId);
}
};
// 切换不喜欢状态
const toggleDislike = async (e: Event) => {
e.stopPropagation();
if (isDislike.value) {
playerStore.removeFromDislikeList(props.item.id);
return;
}
dialog.warning({
title: t('songItem.dialog.dislike.title'),
content: t('songItem.dialog.dislike.content'),
positiveText: t('songItem.dialog.dislike.positiveText'),
negativeText: t('songItem.dialog.dislike.negativeText'),
onPositiveClick: () => {
playerStore.addToDislikeList(props.item.id);
}
});
};
// 添加到下一首播放
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 handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
// 处理菜单点击
const handleMenuClick = (e: MouseEvent) => {
e.preventDefault();
showDropdown.value = true;
dropdownX.value = e.clientX;
dropdownY.value = e.clientY;
};
// 处理艺术家点击
const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
// 鼠标悬停处理
const handleMouseEnter = () => {
isHovering.value = true;
};
const handleMouseLeave = () => {
isHovering.value = false;
};
return {
t,
play,
playMusic,
playLoading,
isPlaying,
isFavorite,
isDislike,
artists,
showDropdown,
dropdownX,
dropdownY,
isHovering,
playerStore,
message,
getImgUrl,
handleImageLoad,
playMusicEvent,
toggleFavorite,
toggleDislike,
handlePlayNext,
getDuration,
formatDuration,
handleContextMenu,
handleMenuClick,
handleArtistClick,
handleMouseEnter,
handleMouseLeave,
downloadMusic
};
}

View File

@@ -37,22 +37,22 @@
:style="isMobile && playerStore.musicFull ? 'bottom: 0;' : ''"
/>
</template>
<!-- 下载管理抽屉 -->
<download-drawer
v-if="
isElectron &&
(settingsStore.setData?.alwaysShowDownloadButton ||
settingsStore.showDownloadDrawer ||
settingsStore.setData?.hasDownloadingTasks)
"
/>
<!-- 播放列表抽屉 -->
<play-list-drawer />
</div>
<install-app-modal v-if="!isElectron"></install-app-modal>
<update-modal v-if="isElectron" />
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
<SleepTimerTop v-if="!isMobile"/>
<!-- 下载管理抽屉 -->
<download-drawer
v-if="
isElectron &&
(settingsStore.setData?.alwaysShowDownloadButton ||
settingsStore.showDownloadDrawer ||
settingsStore.setData?.hasDownloadingTasks)
"
/>
<!-- 播放列表抽屉 -->
<playing-list-drawer />
</div>
</template>
@@ -92,7 +92,7 @@ const PlayBar = defineAsyncComponent(() => import('@/components/player/PlayBar.v
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const PlayListDrawer = defineAsyncComponent(() => import('@/components/player/PlayListDrawer.vue'));
const PlayingListDrawer = defineAsyncComponent(() => import('@/components/player/PlayingListDrawer.vue'));
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
const playerStore = usePlayerStore();
@@ -112,9 +112,9 @@ const showPlaylistDrawer = ref(false);
const currentSongId = ref<number | undefined>();
// 提供一个方法来打开歌单抽屉
const openPlaylistDrawer = (songId: number) => {
const openPlaylistDrawer = (songId: number, isOpen: boolean = true) => {
currentSongId.value = songId;
showPlaylistDrawer.value = true;
showPlaylistDrawer.value = isOpen;
};
// 将方法提供给全局