mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-28 10:57:23 +08:00
refactor: 调整通用组件与列表项
This commit is contained in:
@@ -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>
|
|
||||||
@@ -1,102 +1,139 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="donation-container">
|
<div class="donation-section">
|
||||||
<div class="qrcode-container">
|
<!-- 头部引导区 -->
|
||||||
<div class="description">
|
<div class="my-8 text-center">
|
||||||
<p>{{ t('donation.description') }}</p>
|
<p class="text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
<p>{{ t('donation.message') }}</p>
|
{{ t('donation.description') }}
|
||||||
<n-button type="primary" @click="toDonateList">
|
</p>
|
||||||
|
<div class="mt-4 flex justify-center">
|
||||||
|
<n-button type="primary" secondary round @click="toDonateList">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="ri-cup-line"></i>
|
<i class="ri-heart-3-line"></i>
|
||||||
</template>
|
</template>
|
||||||
{{ t('donation.toDonateList') }}
|
{{ t('donation.toDonateList') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="header-container">
|
<!-- 支付方式卡片 -->
|
||||||
<h3 class="section-title">{{ t('donation.title') }}</h3>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10 max-w-3xl mx-auto">
|
||||||
<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
|
<div
|
||||||
v-for="donor in displayDonors"
|
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"
|
||||||
:key="donor.id"
|
|
||||||
class="donation-card"
|
|
||||||
:class="{ 'no-message': !donor.message }"
|
|
||||||
>
|
>
|
||||||
<div class="card-content">
|
<div
|
||||||
<div class="donor-avatar">
|
class="absolute -right-4 -top-4 w-24 h-24 bg-[#00A0E9]/10 rounded-full blur-2xl group-hover:bg-[#00A0E9]/20 transition-colors"
|
||||||
<n-avatar :src="donor.avatar" :fallback-src="defaultAvatar" round class="avatar-img" />
|
></div>
|
||||||
</div>
|
<img
|
||||||
<div class="donor-info">
|
:src="alipay"
|
||||||
<div class="donor-meta">
|
alt="Alipay"
|
||||||
<div class="donor-name">{{ donor.name }}</div>
|
class="w-52 h-52 rounded-xl shadow-sm mb-4 group-hover:scale-105 transition-transform duration-300"
|
||||||
<!-- <div class="price-tag">¥{{ donor.amount }}</div> -->
|
/>
|
||||||
</div>
|
<div class="flex items-center gap-2 text-[#00A0E9] font-bold text-lg">
|
||||||
<div class="donation-date">{{ donor.date }}</div>
|
<i class="ri-alipay-fill text-2xl"></i>
|
||||||
</div>
|
{{ t('common.alipay') }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 有留言的情况 -->
|
<!-- 微信支付 -->
|
||||||
<n-popover
|
<div
|
||||||
v-if="donor.message"
|
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"
|
||||||
trigger="hover"
|
>
|
||||||
placement="bottom"
|
<div
|
||||||
:show-arrow="true"
|
class="absolute -right-4 -top-4 w-24 h-24 bg-[#09BB07]/10 rounded-full blur-2xl group-hover:bg-[#09BB07]/20 transition-colors"
|
||||||
:width="240"
|
></div>
|
||||||
>
|
<img
|
||||||
<template #trigger>
|
:src="wechat"
|
||||||
<div class="donation-message">
|
alt="WeChat"
|
||||||
<i class="ri-double-quotes-l quote-icon"></i>
|
class="w-52 h-52 rounded-xl shadow-sm mb-4 group-hover:scale-105 transition-transform duration-300"
|
||||||
<span class="message-text">{{ donor.message }}</span>
|
/>
|
||||||
<i class="ri-double-quotes-r quote-icon"></i>
|
<div class="flex items-center gap-2 text-[#09BB07] font-bold text-lg">
|
||||||
</div>
|
<i class="ri-wechat-pay-fill text-2xl"></i>
|
||||||
</template>
|
{{ t('common.wechat') }}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="donors.length > 8" class="expand-button">
|
<!-- 捐赠者列表 -->
|
||||||
<n-button text @click="toggleExpand">
|
<div class="donors-list">
|
||||||
<template #icon>
|
<div class="flex items-center justify-between mb-4 px-1">
|
||||||
<i :class="isExpanded ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
|
<h4 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
</template>
|
<i class="ri-user-heart-line text-primary"></i>
|
||||||
{{ isExpanded ? t('common.collapse') : t('common.expand') }}
|
{{ t('donation.title') }}
|
||||||
</n-button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
import { onActivated, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import type { Donor } from '@/api/donation';
|
import type { Donor } from '@/api/donation';
|
||||||
@@ -106,9 +143,7 @@ import wechat from '@/assets/wechat.png';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// 默认头像
|
|
||||||
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
|
const defaultAvatar = 'https://avatars.githubusercontent.com/u/0?v=4';
|
||||||
|
|
||||||
const donors = ref<Donor[]>([]);
|
const donors = ref<Donor[]>([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
@@ -116,10 +151,13 @@ const fetchDonors = async () => {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await getDonationList();
|
const data = await getDonationList();
|
||||||
donors.value = data.map((donor, index) => ({
|
// Sort by amount desc
|
||||||
...donor,
|
donors.value = data
|
||||||
avatar: `https://api.dicebear.com/7.x/micah/svg?seed=${index}`
|
.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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch donors:', error);
|
console.error('Failed to fetch donors:', error);
|
||||||
} finally {
|
} 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 = () => {
|
const toDonateList = () => {
|
||||||
window.open('http://donate.alger.fun/download', '_blank');
|
window.open('http://donate.alger.fun/download', '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => fetchDonors());
|
||||||
|
onActivated(() => fetchDonors());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.donation-container {
|
.animate-fade-in-up {
|
||||||
@apply w-full overflow-hidden flex flex-col gap-4;
|
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
@keyframes fadeInUp {
|
||||||
@apply flex justify-between items-center px-4 py-2;
|
from {
|
||||||
|
opacity: 0;
|
||||||
.section-title {
|
transform: translateY(10px);
|
||||||
@apply text-lg font-medium text-gray-700 dark:text-gray-200;
|
|
||||||
}
|
}
|
||||||
}
|
to {
|
||||||
|
opacity: 1;
|
||||||
.donation-grid {
|
transform: translateY(0);
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function navigateToMusicList(
|
|||||||
id?: string | number;
|
id?: string | number;
|
||||||
type?: 'album' | 'playlist' | 'dailyRecommend' | string;
|
type?: 'album' | 'playlist' | 'dailyRecommend' | string;
|
||||||
name: string;
|
name: string;
|
||||||
songList: any[];
|
songList?: any[];
|
||||||
listInfo?: any;
|
listInfo?: any;
|
||||||
canRemove?: boolean;
|
canRemove?: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,11 @@ export function navigateToMusicList(
|
|||||||
|
|
||||||
// 如果是每日推荐,不需要设置 musicStore,直接从 recommendStore 获取
|
// 如果是每日推荐,不需要设置 musicStore,直接从 recommendStore 获取
|
||||||
if (type !== 'dailyRecommend') {
|
if (type !== 'dailyRecommend') {
|
||||||
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
|
if (songList) {
|
||||||
|
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
|
||||||
|
} else {
|
||||||
|
musicStore.setBasicListInfo(name, listInfo, canRemove);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 确保 musicStore 的数据被清空,避免显示旧的列表
|
// 确保 musicStore 的数据被清空,避免显示旧的列表
|
||||||
musicStore.clearCurrentMusicList();
|
musicStore.clearCurrentMusicList();
|
||||||
|
|||||||
@@ -14,11 +14,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="show"
|
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="relative z-10 w-full bg-white dark:bg-[#1c1c1e] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]"
|
||||||
:class="[
|
:class="[isMobile ? 'rounded-t-[20px] pb-safe' : 'md:max-w-[720px] md:rounded-2xl']"
|
||||||
isMobile
|
|
||||||
? 'rounded-t-[20px] pb-safe'
|
|
||||||
: 'md:max-w-[720px] md:rounded-2xl'
|
|
||||||
]"
|
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-item" :class="[shape, item.type]" @click="handleClick">
|
<div
|
||||||
<div class="search-item-img">
|
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
|
<n-image
|
||||||
class="w-full h-full"
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '200y200')"
|
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '400y225' : '400y400')"
|
||||||
lazy
|
lazy
|
||||||
preview-disabled
|
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>
|
</div>
|
||||||
<div class="search-item-info">
|
|
||||||
<p class="search-item-name">{{ item.name }}</p>
|
<!-- Info Section -->
|
||||||
<p class="search-item-artist">{{ item.desc }}</p>
|
<div class="mt-3 space-y-1 px-1">
|
||||||
</div>
|
<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"
|
||||||
<div v-if="item.type === '专辑'" class="search-item-size">
|
>
|
||||||
<i class="ri-music-2-line"></i>
|
{{ item.name }}
|
||||||
<span>{{ item.size }}</span>
|
</h3>
|
||||||
|
<p class="line-clamp-1 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||||
|
{{ item.desc }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MV Player Component -->
|
||||||
<mv-player
|
<mv-player
|
||||||
v-if="item.type === 'mv'"
|
v-if="item.type === 'mv'"
|
||||||
v-model:show="showPop"
|
v-model:show="showPop"
|
||||||
@@ -31,90 +61,73 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getAlbum, getListDetail } from '@/api/list';
|
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
|
||||||
import MvPlayer from '@/components/MvPlayer.vue';
|
import MvPlayer from '@/components/MvPlayer.vue';
|
||||||
import { useMusicStore } from '@/store/modules/music';
|
import { usePodcastRadioHistory } from '@/hooks/PodcastRadioHistoryHook';
|
||||||
import { usePlayerStore } from '@/store/modules/player';
|
import { usePlayerStore } from '@/store/modules/player';
|
||||||
import { IMvItem } from '@/types/mv';
|
import { IMvItem } from '@/types/mv';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
item: {
|
||||||
shape?: 'square' | 'rectangle';
|
id: number;
|
||||||
zIndex?: number;
|
picUrl: string;
|
||||||
item: {
|
name: string;
|
||||||
picUrl: string;
|
desc: string;
|
||||||
name: string;
|
type: string;
|
||||||
desc: string;
|
[key: string]: any;
|
||||||
type: string;
|
};
|
||||||
[key: string]: any;
|
}>();
|
||||||
};
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
shape: 'rectangle'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const songList = ref<any[]>([]);
|
|
||||||
|
|
||||||
const showPop = ref(false);
|
const showPop = ref(false);
|
||||||
const listInfo = ref<any>(null);
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const musicStore = useMusicStore();
|
const { addPodcastRadio } = usePodcastRadioHistory();
|
||||||
|
|
||||||
const getCurrentMv = () => {
|
const getCurrentMv = () => {
|
||||||
return {
|
return {
|
||||||
id: props.item.id,
|
id: props.item.id,
|
||||||
name: props.item.name
|
name: props.item.name,
|
||||||
|
cover: props.item.picUrl,
|
||||||
|
artistName: props.item.desc
|
||||||
} as unknown as IMvItem;
|
} as unknown as IMvItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
listInfo.value = null;
|
|
||||||
if (props.item.type === '专辑') {
|
if (props.item.type === '专辑') {
|
||||||
const res = await getAlbum(props.item.id);
|
navigateToMusicList(router, {
|
||||||
songList.value = res.data.songs.map((song: any) => {
|
id: props.item.id,
|
||||||
song.al.picUrl = song.al.picUrl || props.item.picUrl;
|
type: 'album',
|
||||||
return song;
|
name: props.item.name,
|
||||||
});
|
listInfo: { picUrl: props.item.picUrl },
|
||||||
listInfo.value = {
|
canRemove: false
|
||||||
...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' }
|
|
||||||
});
|
});
|
||||||
} else if (props.item.type === 'playlist') {
|
} else if (props.item.type === 'playlist') {
|
||||||
const res = await getListDetail(props.item.id);
|
navigateToMusicList(router, {
|
||||||
songList.value = res.data.playlist.tracks;
|
id: props.item.id,
|
||||||
listInfo.value = res.data.playlist;
|
type: 'playlist',
|
||||||
|
name: props.item.name,
|
||||||
// 保存数据到store
|
listInfo: { picUrl: props.item.picUrl },
|
||||||
musicStore.setCurrentMusicList(songList.value, props.item.name, listInfo.value, false);
|
canRemove: false
|
||||||
|
|
||||||
// 使用路由跳转
|
|
||||||
router.push({
|
|
||||||
name: 'musicList',
|
|
||||||
params: { id: props.item.id },
|
|
||||||
query: { type: 'playlist' }
|
|
||||||
});
|
});
|
||||||
} else if (props.item.type === 'mv') {
|
} else if (props.item.type === 'mv') {
|
||||||
handleShowMv();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.search-item {
|
.line-clamp-1 {
|
||||||
@apply rounded-lg p-0 flex items-center hover:bg-transparent transition cursor-pointer border-none;
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
&.square {
|
-webkit-box-orient: vertical;
|
||||||
@apply flex-col relative;
|
overflow: hidden;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { computed } from 'vue';
|
|||||||
import type { SongResult } from '@/types/music';
|
import type { SongResult } from '@/types/music';
|
||||||
|
|
||||||
import CompactSongItem from './songItemCom/CompactSongItem.vue';
|
import CompactSongItem from './songItemCom/CompactSongItem.vue';
|
||||||
|
import HomeSongItem from './songItemCom/HomeSongItem.vue';
|
||||||
import ListSongItem from './songItemCom/ListSongItem.vue';
|
import ListSongItem from './songItemCom/ListSongItem.vue';
|
||||||
import MiniSongItem from './songItemCom/MiniSongItem.vue';
|
import MiniSongItem from './songItemCom/MiniSongItem.vue';
|
||||||
import StandardSongItem from './songItemCom/StandardSongItem.vue';
|
import StandardSongItem from './songItemCom/StandardSongItem.vue';
|
||||||
@@ -30,6 +31,7 @@ const props = withDefaults(
|
|||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
list?: boolean;
|
list?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
home?: boolean;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -41,6 +43,7 @@ const props = withDefaults(
|
|||||||
mini: false,
|
mini: false,
|
||||||
list: false,
|
list: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
|
home: false,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -57,6 +60,7 @@ const renderComponent = computed(() => {
|
|||||||
if (props.mini) return MiniSongItem;
|
if (props.mini) return MiniSongItem;
|
||||||
if (props.list) return ListSongItem;
|
if (props.list) return ListSongItem;
|
||||||
if (props.compact) return CompactSongItem;
|
if (props.compact) return CompactSongItem;
|
||||||
|
if (props.home) return HomeSongItem;
|
||||||
return StandardSongItem;
|
return StandardSongItem;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -204,24 +204,24 @@ const formatDuration = (ms: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-title {
|
&-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 {
|
&-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 {
|
&-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 {
|
&-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 {
|
.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-like,
|
||||||
.song-item-operating-play,
|
.song-item-operating-play,
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user