mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +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>
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
140
src/renderer/components/common/songItemCom/HomeSongItem.vue
Normal file
140
src/renderer/components/common/songItemCom/HomeSongItem.vue
Normal 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>
|
||||
Reference in New Issue
Block a user