refactor: 调整通用组件与列表项

This commit is contained in:
alger
2026-02-04 20:15:54 +08:00
parent b06459f10d
commit 6b5382e37a
8 changed files with 380 additions and 542 deletions

View File

@@ -1,121 +0,0 @@
<template>
<div class="bilibili-item" @click="handleClick">
<div class="bilibili-item-img">
<n-image class="w-full h-full" :src="item.pic" lazy preview-disabled />
<div class="play">
<i class="ri-play-fill text-4xl"></i>
</div>
<div class="duration">{{ formatDuration(item.duration) }}</div>
</div>
<div class="bilibili-item-info">
<p class="bilibili-item-title" v-html="item.title"></p>
<p class="bilibili-item-author"><i class="ri-user-line mr-1"></i>{{ item.author }}</p>
<div class="bilibili-item-stats">
<span><i class="ri-play-line mr-1"></i>{{ formatNumber(item.view) }}</span>
<span><i class="ri-chat-1-line mr-1"></i>{{ formatNumber(item.danmaku) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { IBilibiliSearchResult } from '@/types/bilibili';
const { t } = useI18n();
const props = defineProps<{
item: IBilibiliSearchResult;
}>();
const emit = defineEmits<{
(e: 'play', item: IBilibiliSearchResult): void;
}>();
const handleClick = () => {
emit('play', props.item);
};
/**
* 格式化数字显示
*/
const formatNumber = (num?: number) => {
if (!num) return '0';
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}${t('bilibili.player.num')}`;
}
return num.toString();
};
/**
* 格式化视频时长
*/
const formatDuration = (duration?: number | string) => {
if (!duration) return '00:00:00';
// 处理字符串格式 (例如 "4352:29")
if (typeof duration === 'string') {
// 检查是否是合法的格式
if (/^\d+:\d+$/.test(duration)) {
// 分解分钟和秒数
const [minutes, seconds] = duration.split(':').map(Number);
// 转换为时:分:秒格式
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return '00:00:00';
}
// 数字处理逻辑 (秒数转为"时:分:秒"格式)
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
</script>
<style scoped lang="scss">
.bilibili-item {
@apply rounded-lg flex items-start hover:bg-light-200 dark:hover:bg-dark-200 p-3 transition cursor-pointer border-none;
&-img {
@apply w-40 rounded-lg overflow-hidden relative mr-4;
aspect-ratio: 16/9;
&:hover {
.play {
@apply opacity-80;
}
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity text-white;
}
.duration {
@apply absolute bottom-1 right-1 text-xs text-white px-1 py-0.5 rounded-sm bg-black/60 backdrop-blur-sm;
}
}
&-info {
@apply flex-1 overflow-hidden;
}
&-title {
@apply text-gray-800 dark:text-gray-200 text-sm font-medium mb-1 line-clamp-2 leading-tight;
}
&-author {
@apply text-gray-500 dark:text-gray-400 text-xs flex items-center mb-1;
}
&-stats {
@apply flex items-center text-xs text-gray-500 dark:text-gray-400 gap-3;
}
}
</style>

View File

@@ -1,102 +1,139 @@
<template>
<div class="donation-container">
<div class="qrcode-container">
<div class="description">
<p>{{ t('donation.description') }}</p>
<p>{{ t('donation.message') }}</p>
<n-button type="primary" @click="toDonateList">
<div class="donation-section">
<!-- 头部引导区 -->
<div class="my-8 text-center">
<p class="text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
{{ t('donation.description') }}
</p>
<div class="mt-4 flex justify-center">
<n-button type="primary" secondary round @click="toDonateList">
<template #icon>
<i class="ri-cup-line"></i>
<i class="ri-heart-3-line"></i>
</template>
{{ t('donation.toDonateList') }}
</n-button>
</div>
<div class="qrcode-grid">
<div class="qrcode-item">
<n-image :src="alipay" :alt="t('common.alipay')" class="qrcode-image" preview-disabled />
<span class="qrcode-label">{{ t('common.alipay') }}</span>
</div>
<div class="qrcode-item">
<n-image :src="wechat" :alt="t('common.wechat')" class="qrcode-image" preview-disabled />
<span class="qrcode-label">{{ t('common.wechat') }}</span>
</div>
</div>
</div>
<div class="header-container">
<h3 class="section-title">{{ t('donation.title') }}</h3>
<n-button secondary round size="small" :loading="isLoading" @click="fetchDonors">
<template #icon>
<i class="ri-refresh-line"></i>
</template>
{{ t('donation.refresh') }}
</n-button>
</div>
<div class="donation-grid" :class="{ 'grid-expanded': isExpanded }">
<!-- 支付方式卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10 max-w-3xl mx-auto">
<!-- 支付宝 -->
<div
v-for="donor in displayDonors"
:key="donor.id"
class="donation-card"
:class="{ 'no-message': !donor.message }"
class="pay-card group relative overflow-hidden rounded-2xl bg-[#00A0E9]/5 border border-[#00A0E9]/20 p-6 flex flex-col items-center transition-all hover:bg-[#00A0E9]/10 hover:shadow-lg hover:shadow-[#00A0E9]/10"
>
<div class="card-content">
<div class="donor-avatar">
<n-avatar :src="donor.avatar" :fallback-src="defaultAvatar" round class="avatar-img" />
</div>
<div class="donor-info">
<div class="donor-meta">
<div class="donor-name">{{ donor.name }}</div>
<!-- <div class="price-tag">{{ donor.amount }}</div> -->
</div>
<div class="donation-date">{{ donor.date }}</div>
</div>
<div
class="absolute -right-4 -top-4 w-24 h-24 bg-[#00A0E9]/10 rounded-full blur-2xl group-hover:bg-[#00A0E9]/20 transition-colors"
></div>
<img
:src="alipay"
alt="Alipay"
class="w-52 h-52 rounded-xl shadow-sm mb-4 group-hover:scale-105 transition-transform duration-300"
/>
<div class="flex items-center gap-2 text-[#00A0E9] font-bold text-lg">
<i class="ri-alipay-fill text-2xl"></i>
{{ t('common.alipay') }}
</div>
</div>
<!-- 有留言的情况 -->
<n-popover
v-if="donor.message"
trigger="hover"
placement="bottom"
:show-arrow="true"
:width="240"
>
<template #trigger>
<div class="donation-message">
<i class="ri-double-quotes-l quote-icon"></i>
<span class="message-text">{{ donor.message }}</span>
<i class="ri-double-quotes-r quote-icon"></i>
</div>
</template>
<div class="message-popover">
<i class="ri-double-quotes-l quote-icon"></i>
<span>{{ donor.message }}</span>
<i class="ri-double-quotes-r quote-icon"></i>
</div>
</n-popover>
<!-- 没有留言的情况显示占位符 -->
<div v-else class="donation-message-placeholder">
<i class="ri-emotion-line"></i>
<span>{{ t('donation.noMessage') }}</span>
<!-- 微信支付 -->
<div
class="pay-card group relative overflow-hidden rounded-2xl bg-[#09BB07]/5 border border-[#09BB07]/20 p-6 flex flex-col items-center transition-all hover:bg-[#09BB07]/10 hover:shadow-lg hover:shadow-[#09BB07]/10"
>
<div
class="absolute -right-4 -top-4 w-24 h-24 bg-[#09BB07]/10 rounded-full blur-2xl group-hover:bg-[#09BB07]/20 transition-colors"
></div>
<img
:src="wechat"
alt="WeChat"
class="w-52 h-52 rounded-xl shadow-sm mb-4 group-hover:scale-105 transition-transform duration-300"
/>
<div class="flex items-center gap-2 text-[#09BB07] font-bold text-lg">
<i class="ri-wechat-pay-fill text-2xl"></i>
{{ t('common.wechat') }}
</div>
</div>
</div>
<div v-if="donors.length > 8" class="expand-button">
<n-button text @click="toggleExpand">
<template #icon>
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
</template>
{{ isExpanded ? t('common.collapse') : t('common.expand') }}
</n-button>
<!-- 捐赠者列表 -->
<div class="donors-list">
<div class="flex items-center justify-between mb-4 px-1">
<h4 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="ri-user-heart-line text-primary"></i>
{{ t('donation.title') }}
</h4>
<n-button quaternary size="small" :loading="isLoading" @click="fetchDonors">
<template #icon><i class="ri-refresh-line"></i></template>
{{ t('donation.refresh') }}
</n-button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="(donor, index) in donors"
:key="donor.id"
class="donor-card group animate-fade-in-up"
:style="{ animationDelay: `${index * 10}ms` }"
>
<div
class="h-full bg-white dark:bg-neutral-800/50 border border-gray-100 dark:border-gray-800 rounded-xl p-3 flex gap-3 hover:border-primary/30 hover:shadow-md hover:bg-white dark:hover:bg-neutral-800 transition-all duration-300"
>
<!-- 头像 -->
<div class="relative flex-shrink-0">
<n-avatar
:src="donor.avatar"
:fallback-src="defaultAvatar"
round
:size="40"
class="border border-gray-100 dark:border-gray-700"
/>
<div
v-if="index < 3"
class="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center text-[10px] text-white border border-white dark:border-gray-800"
:class="[
index === 0 ? 'bg-yellow-400' : index === 1 ? 'bg-gray-400' : 'bg-orange-400'
]"
>
<i class="ri-trophy-fill"></i>
</div>
</div>
<!-- 信息 -->
<div class="flex-1 min-w-0 flex flex-col justify-center">
<div class="flex justify-between items-center">
<span class="font-bold text-gray-900 dark:text-gray-100 truncate text-sm">
{{ donor.name }}
</span>
<span class="text-xs font-mono text-primary/80 bg-primary/5 px-1.5 py-0.5 rounded">
¥{{ donor.amount }}
</span>
</div>
<!-- 留言或日期 -->
<div class="mt-1">
<n-popover v-if="donor.message" trigger="hover" placement="top">
<template #trigger>
<div
class="text-xs text-gray-500 dark:text-gray-400 truncate cursor-help border-b border-dashed border-gray-300 dark:border-gray-600 inline-block max-w-full"
>
"{{ donor.message }}"
</div>
</template>
<div class="max-w-[200px] text-xs">{{ donor.message }}</div>
</n-popover>
<div v-else class="text-xs text-gray-400 dark:text-gray-600">
{{ donor.date }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { onActivated, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Donor } from '@/api/donation';
@@ -106,9 +143,7 @@ import wechat from '@/assets/wechat.png';
const { t } = useI18n();
// 默认头像
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
const donors = ref<Donor[]>([]);
const isLoading = ref(false);
@@ -116,10 +151,13 @@ const fetchDonors = async () => {
isLoading.value = true;
try {
const data = await getDonationList();
donors.value = data.map((donor, index) => ({
...donor,
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${index}`
}));
// Sort by amount desc
donors.value = data
.sort((a, b) => Number(b.amount) - Number(a.amount))
.map((donor) => ({
...donor,
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${donor.name}`
}));
} catch (error) {
console.error('Failed to fetch donors:', error);
} finally {
@@ -127,196 +165,27 @@ const fetchDonors = async () => {
}
};
onMounted(() => {
fetchDonors();
});
onActivated(() => {
fetchDonors();
});
const isExpanded = ref(false);
const displayDonors = computed(() => {
if (isExpanded.value) {
return donors.value;
}
return donors.value.slice(0, 8);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const toDonateList = () => {
window.open('http://donate.alger.fun/download', '_blank');
};
onMounted(() => fetchDonors());
onActivated(() => fetchDonors());
</script>
<style lang="scss" scoped>
.donation-container {
@apply w-full overflow-hidden flex flex-col gap-4;
.animate-fade-in-up {
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.header-container {
@apply flex justify-between items-center px-4 py-2;
.section-title {
@apply text-lg font-medium text-gray-700 dark:text-gray-200;
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
}
.donation-grid {
@apply grid gap-3 transition-all duration-300 overflow-hidden;
grid-template-columns: repeat(2, 1fr);
max-height: 320px;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
}
&.grid-expanded {
@apply max-h-none;
}
}
.donation-card {
@apply rounded-lg p-2.5 transition-all duration-200 hover:shadow-md;
@apply bg-light-100 dark:bg-gray-800/5 backdrop-blur-sm;
@apply border border-gray-200 dark:border-gray-700/10;
@apply flex flex-col;
min-height: 100px;
.card-content {
@apply flex items-start gap-2 mb-2;
}
}
.donor-avatar {
@apply relative flex-shrink-0;
.avatar-img {
@apply border border-gray-200 dark:border-gray-700/10 shadow-sm;
@apply w-9 h-9;
}
}
.donor-info {
@apply flex-1 min-w-0 flex flex-col justify-center;
.donor-meta {
@apply flex justify-between items-center mb-0.5;
.donor-name {
@apply text-sm font-medium truncate flex-1 mr-1;
}
.price-tag {
@apply text-xs text-gray-400/80 dark:text-gray-500/80 whitespace-nowrap;
}
}
.donation-date {
@apply text-xs text-gray-400/60 dark:text-gray-500/60;
}
}
.donation-message {
@apply text-xs text-gray-500 dark:text-gray-400 italic mt-1 px-2 py-1.5;
@apply bg-gray-100/10 dark:bg-dark-300 rounded;
@apply flex items-start;
@apply cursor-pointer transition-all duration-200;
.quote-icon {
@apply text-gray-300 dark:text-gray-600 flex-shrink-0 opacity-60;
&:first-child {
@apply mr-1 self-start;
}
&:last-child {
@apply ml-1 self-end;
}
}
.message-text {
@apply flex-1 line-clamp-2;
}
&:hover {
@apply bg-gray-100/40 dark:bg-dark-200;
}
}
.donation-message-placeholder {
@apply text-xs text-gray-400 dark:text-gray-500 mt-1 px-2 py-1.5;
@apply bg-gray-100/5 dark:bg-dark-300 rounded;
@apply flex items-center justify-center gap-1 italic;
@apply border border-transparent;
i {
@apply text-gray-300 dark:text-gray-600;
}
}
.message-popover {
@apply text-sm text-gray-700 dark:text-gray-200 italic p-2;
@apply flex items-start;
.quote-icon {
@apply text-gray-400 dark:text-gray-500 flex-shrink-0;
&:first-child {
@apply mr-1.5 self-start;
}
&:last-child {
@apply ml-1.5 self-end;
}
}
}
.expand-button {
@apply flex justify-center items-center py-2;
:deep(.n-button) {
@apply transition-all duration-200 hover:-translate-y-0.5;
}
}
.qrcode-container {
@apply p-5 rounded-lg shadow-sm bg-light-100 dark:bg-gray-800/5 backdrop-blur-sm border border-gray-200 dark:border-gray-700/10;
.description {
@apply text-center text-sm text-gray-600 dark:text-gray-300 mb-4;
p {
@apply mb-2;
}
}
.qrcode-grid {
@apply flex justify-between items-center gap-4 flex-wrap;
.qrcode-item {
@apply flex flex-col items-center gap-2;
.qrcode-image {
@apply w-36 h-36 rounded-lg border border-gray-200 dark:border-gray-700/10 shadow-sm transition-transform duration-200 hover:scale-105;
}
.qrcode-label {
@apply text-sm text-gray-600 dark:text-gray-300;
}
}
.donate-button {
@apply flex flex-col items-center justify-center;
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -13,7 +13,7 @@ export function navigateToMusicList(
id?: string | number;
type?: 'album' | 'playlist' | 'dailyRecommend' | string;
name: string;
songList: any[];
songList?: any[];
listInfo?: any;
canRemove?: boolean;
}
@@ -23,7 +23,11 @@ export function navigateToMusicList(
// 如果是每日推荐,不需要设置 musicStore直接从 recommendStore 获取
if (type !== 'dailyRecommend') {
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
if (songList) {
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
} else {
musicStore.setBasicListInfo(name, listInfo, canRemove);
}
} else {
// 确保 musicStore 的数据被清空,避免显示旧的列表
musicStore.clearCurrentMusicList();

View File

@@ -14,11 +14,7 @@
<div
v-if="show"
class="relative z-10 w-full bg-white dark:bg-[#1c1c1e] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]"
:class="[
isMobile
? 'rounded-t-[20px] pb-safe'
: 'md:max-w-[720px] md:rounded-2xl'
]"
:class="[isMobile ? 'rounded-t-[20px] pb-safe' : 'md:max-w-[720px] md:rounded-2xl']"
@click.stop
>
<!-- Header -->

View File

@@ -1,26 +1,56 @@
<template>
<div class="search-item" :class="[shape, item.type]" @click="handleClick">
<div class="search-item-img">
<div
class="search-item group cursor-pointer transition-all duration-300"
:class="[item.type === 'mv' ? 'flex flex-col' : 'flex flex-col']"
@click="handleClick"
>
<!-- Image Container -->
<div
class="relative overflow-hidden rounded-2xl shadow-sm transition-all duration-500 group-hover:shadow-xl group-hover:-translate-y-1"
:class="[item.type === 'mv' ? 'aspect-video' : 'aspect-square']"
>
<n-image
class="w-full h-full"
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '200y200')"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '400y225' : '400y400')"
lazy
preview-disabled
/>
<div v-if="item.type === 'mv'" class="play">
<i class="iconfont icon icon-play"></i>
<!-- Play Overlay (for MV) -->
<div
v-if="item.type === 'mv'"
class="absolute inset-0 flex items-center justify-center bg-black/0 transition-all duration-300 group-hover:bg-black/30"
>
<div
class="play-icon flex h-12 w-12 items-center justify-center rounded-full bg-white/90 opacity-0 scale-75 transition-all duration-300 shadow-xl group-hover:opacity-100 group-hover:scale-100"
>
<i class="ri-play-fill text-2xl text-neutral-900 ml-1" />
</div>
</div>
<!-- Item Size Badge (for Album) -->
<div
v-if="item.type === '专辑' && item.size"
class="absolute top-2 right-2 flex items-center gap-1 rounded-lg bg-black/40 px-2 py-1 text-[10px] font-bold text-white backdrop-blur-md opacity-0 transition-opacity duration-300 group-hover:opacity-100"
>
<i class="ri-music-2-line" />
<span>{{ item.size }}</span>
</div>
</div>
<div class="search-item-info">
<p class="search-item-name">{{ item.name }}</p>
<p class="search-item-artist">{{ item.desc }}</p>
</div>
<div v-if="item.type === '专辑'" class="search-item-size">
<i class="ri-music-2-line"></i>
<span>{{ item.size }}</span>
<!-- Info Section -->
<div class="mt-3 space-y-1 px-1">
<h3
class="line-clamp-1 text-sm font-bold text-neutral-800 transition-colors duration-200 group-hover:text-primary dark:text-neutral-200 dark:group-hover:text-white md:text-base"
>
{{ item.name }}
</h3>
<p class="line-clamp-1 text-xs font-medium text-neutral-500 dark:text-neutral-400">
{{ item.desc }}
</p>
</div>
<!-- MV Player Component -->
<mv-player
v-if="item.type === 'mv'"
v-model:show="showPop"
@@ -31,90 +61,73 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getAlbum, getListDetail } from '@/api/list';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import MvPlayer from '@/components/MvPlayer.vue';
import { useMusicStore } from '@/store/modules/music';
import { usePodcastRadioHistory } from '@/hooks/PodcastRadioHistoryHook';
import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/types/mv';
import { getImgUrl } from '@/utils';
const props = withDefaults(
defineProps<{
shape?: 'square' | 'rectangle';
zIndex?: number;
item: {
picUrl: string;
name: string;
desc: string;
type: string;
[key: string]: any;
};
}>(),
{
shape: 'rectangle'
}
);
const songList = ref<any[]>([]);
const props = defineProps<{
item: {
id: number;
picUrl: string;
name: string;
desc: string;
type: string;
[key: string]: any;
};
}>();
const showPop = ref(false);
const listInfo = ref<any>(null);
const playerStore = usePlayerStore();
const router = useRouter();
const musicStore = useMusicStore();
const { addPodcastRadio } = usePodcastRadioHistory();
const getCurrentMv = () => {
return {
id: props.item.id,
name: props.item.name
name: props.item.name,
cover: props.item.picUrl,
artistName: props.item.desc
} as unknown as IMvItem;
};
const handleClick = async () => {
listInfo.value = null;
if (props.item.type === '专辑') {
const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl;
return song;
});
listInfo.value = {
...res.data.album,
creator: {
avatarUrl: res.data.album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
},
description: res.data.album.description
};
// 保存数据到store
musicStore.setCurrentMusicList(songList.value, props.item.name, listInfo.value, false);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'album' }
navigateToMusicList(router, {
id: props.item.id,
type: 'album',
name: props.item.name,
listInfo: { picUrl: props.item.picUrl },
canRemove: false
});
} else if (props.item.type === 'playlist') {
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist;
// 保存数据到store
musicStore.setCurrentMusicList(songList.value, props.item.name, listInfo.value, false);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'playlist' }
navigateToMusicList(router, {
id: props.item.id,
type: 'playlist',
name: props.item.name,
listInfo: { picUrl: props.item.picUrl },
canRemove: false
});
} else if (props.item.type === 'mv') {
handleShowMv();
} else if (props.item.type === 'djRadio') {
addPodcastRadio({
id: props.item.id,
name: props.item.name,
picUrl: props.item.picUrl,
dj: props.item.dj,
type: 'djRadio'
});
router.push({
name: 'podcastRadio',
params: { id: props.item.id }
});
}
};
@@ -125,77 +138,10 @@ const handleShowMv = async () => {
</script>
<style scoped lang="scss">
.search-item {
@apply rounded-lg p-0 flex items-center hover:bg-transparent transition cursor-pointer border-none;
&.square {
@apply flex-col relative;
.search-item-img {
@apply w-full aspect-square mb-2 mr-0 rounded-lg overflow-hidden hover:shadow-xl transition-all duration-300 shadow-sm shadow-black/20 dark:shadow-white/20;
img {
@apply object-cover w-full h-full transition-transform duration-500;
}
}
.search-item-info {
@apply w-full text-left px-0;
.search-item-name {
@apply truncate mb-1 font-medium text-base text-gray-800 dark:text-gray-200;
}
.search-item-artist {
@apply truncate text-sm text-gray-500 dark:text-gray-400;
}
}
.search-item-size {
@apply absolute top-2 right-2 text-xs text-white px-2 py-1 rounded-full bg-black/30 backdrop-blur-sm;
i {
@apply text-xs;
}
}
}
&.rectangle {
@apply hover:bg-light-200 dark:hover:bg-dark-200 p-3;
.search-item-img {
@apply w-12 h-12 mr-4 rounded-lg overflow-hidden;
}
}
.search-item-info {
@apply flex-1 overflow-hidden;
&-name {
@apply text-white text-sm text-center;
}
&-artist {
@apply text-gray-400 text-xs text-center;
}
}
}
.search-item.mv {
&:hover {
.play {
@apply opacity-60;
}
}
.search-item-img {
width: 160px !important;
height: 90px !important;
@apply rounded-lg relative;
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
.icon {
@apply text-white text-5xl;
}
}
}
.search-item-size {
@apply flex items-center gap-2 text-gray-400;
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -20,6 +20,7 @@ import { computed } from 'vue';
import type { SongResult } from '@/types/music';
import CompactSongItem from './songItemCom/CompactSongItem.vue';
import HomeSongItem from './songItemCom/HomeSongItem.vue';
import ListSongItem from './songItemCom/ListSongItem.vue';
import MiniSongItem from './songItemCom/MiniSongItem.vue';
import StandardSongItem from './songItemCom/StandardSongItem.vue';
@@ -30,6 +31,7 @@ const props = withDefaults(
mini?: boolean;
list?: boolean;
compact?: boolean;
home?: boolean;
favorite?: boolean;
selectable?: boolean;
selected?: boolean;
@@ -41,6 +43,7 @@ const props = withDefaults(
mini: false,
list: false,
compact: false,
home: false,
favorite: true,
selectable: false,
selected: false,
@@ -57,6 +60,7 @@ const renderComponent = computed(() => {
if (props.mini) return MiniSongItem;
if (props.list) return ListSongItem;
if (props.compact) return CompactSongItem;
if (props.home) return HomeSongItem;
return StandardSongItem;
});
</script>

View File

@@ -204,24 +204,24 @@ const formatDuration = (ms: number): string => {
}
&-title {
@apply flex-[2.5] min-w-0 text-sm cursor-pointer text-gray-900 dark:text-white;
@apply flex-[2.5] min-w-0 text-sm cursor-pointer text-gray-900 dark:text-white flex items-center;
}
&-artist {
@apply flex-[1.5] min-w-0 text-sm text-gray-500 dark:text-gray-400;
@apply flex-[1.5] min-w-0 text-sm text-gray-500 dark:text-gray-400 flex items-center;
}
&-album {
@apply flex-[1.5] min-w-0 text-sm text-gray-500 dark:text-gray-400;
@apply flex-[1.5] min-w-0 text-sm text-gray-500 dark:text-gray-400 flex items-center;
}
&-duration {
@apply w-14 flex-shrink-0 text-sm text-gray-500 dark:text-gray-400 justify-end;
@apply w-14 flex-shrink-0 text-sm text-gray-500 dark:text-gray-400 flex items-center justify-end;
}
}
.song-item-operating-compact {
@apply border-none bg-transparent gap-2 flex items-center;
@apply border-none bg-transparent gap-3 flex items-center justify-end min-w-[160px];
.song-item-operating-like,
.song-item-operating-play,

View File

@@ -0,0 +1,140 @@
<template>
<div
class="home-song-card group flex cursor-pointer items-center gap-3 md:gap-4 rounded-xl md:rounded-2xl p-2 md:p-2.5 transition-all duration-300 hover:bg-light-200 dark:hover:bg-dark-200"
@click="onPlayMusic"
@contextmenu.prevent="onMenuClick"
>
<!-- Album Cover -->
<div
class="cover relative h-14 w-14 md:h-16 md:w-16 flex-shrink-0 overflow-hidden rounded-lg md:rounded-xl bg-neutral-100 dark:bg-neutral-800 shadow-sm"
>
<n-image
v-if="item.picUrl"
:src="getImgUrl(item.picUrl, '200y200')"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
preview-disabled
:img-props="{
crossorigin: 'anonymous',
loading: 'lazy',
alt: item.name
}"
/>
<div
class="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
>
<i class="iconfont icon-playfill text-lg md:text-xl text-white drop-shadow-lg"></i>
</div>
</div>
<!-- Song Info -->
<div class="song-info flex flex-col overflow-hidden flex-1 min-w-0">
<n-ellipsis
class="song-name text-sm md:text-base font-semibold text-neutral-800 dark:text-neutral-100 transition-colors duration-200 group-hover:text-primary dark:group-hover:text-white"
:class="{ 'text-green-500': isPlaying }"
>
{{ item.name }}
</n-ellipsis>
<n-ellipsis
class="artist-name text-xs md:text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"
>
<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>
<!-- More Button -->
<button
class="more-btn flex h-8 w-8 items-center justify-center rounded-full opacity-0 transition-all duration-300 group-hover:bg-white dark:group-hover:bg-neutral-800 group-hover:opacity-100 hover:scale-110 active:scale-95"
@click.stop="onMenuClick"
>
<i class="ri-more-fill text-sm text-neutral-600 dark:text-neutral-300"></i>
</button>
<!-- Dropdown Menu -->
<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="onPlayMusic"
@play-next="handlePlayNext"
@download="downloadMusic"
@toggle-favorite="toggleFavorite"
@toggle-dislike="toggleDislike"
@remove="$emit('remove-song', $event)"
/>
</div>
</template>
<script lang="ts" setup>
import { NEllipsis, NImage } from 'naive-ui';
import { useSongItem } from '@/hooks/useSongItem';
import type { SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils';
import SongItemDropdown from './SongItemDropdown.vue';
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
}
);
const emit = defineEmits(['play', 'select', 'remove-song']);
// 使用公共逻辑
const {
isPlaying,
isFavorite,
isDislike,
artists,
showDropdown,
dropdownX,
dropdownY,
playMusicEvent,
toggleFavorite,
toggleDislike,
handlePlayNext,
handleMenuClick,
handleArtistClick,
downloadMusic
} = useSongItem(props);
const onPlayMusic = () => {
playMusicEvent(props.item);
emit('play', props.item);
};
const onArtistClick = (id: number) => handleArtistClick(id);
const onMenuClick = (event: MouseEvent) => handleMenuClick(event);
</script>
<style lang="scss" scoped>
.home-song-card {
// 所有样式都已在模板中通过 Tailwind 类定义
}
</style>