feat: 更新网易云音乐 API 版本,添加 B站视频搜索功能和播放器组件

This commit is contained in:
alger
2025-03-29 23:19:51 +08:00
parent c5e50c9fd5
commit 280fec1990
12 changed files with 1630 additions and 125 deletions
+519
View File
@@ -0,0 +1,519 @@
<template>
<n-drawer
v-model:show="showModal"
class="bilibili-player-modal"
:mask-closable="true"
:auto-focus="false"
:to="`#layout-main`"
preset="card"
height="80%"
placement="bottom"
>
<div class="bilibili-player-wrapper">
<div class="bilibili-player-header">
<div class="title">{{ videoDetail?.title || '加载中...' }}</div>
<div class="actions">
<n-button quaternary circle @click="closePlayer">
<template #icon>
<i class="ri-close-line"></i>
</template>
</n-button>
</div>
</div>
<div v-if="isLoading" class="loading-wrapper">
<n-spin size="large" />
<p>听书加载中...</p>
</div>
<div v-else-if="errorMessage" class="error-wrapper">
<i class="ri-error-warning-line text-4xl text-red-500"></i>
<p>{{ errorMessage }}</p>
<n-button type="primary" @click="loadVideoSource">重试</n-button>
</div>
<div v-else-if="videoDetail" class="bilibili-info-wrapper">
<div class="bilibili-cover">
<n-image
:src="getBilibiliProxyUrl(videoDetail.pic)"
class="cover-image"
preview-disabled
/>
<div class="play-button" @click="playCurrentAudio">
<i class="ri-play-fill text-4xl"></i>
</div>
</div>
<div class="video-info">
<div class="author">
<i class="ri-user-line mr-1"></i>
<span>{{ videoDetail.owner?.name }}</span>
</div>
<div class="stats">
<span><i class="ri-play-line mr-1"></i>{{ formatNumber(videoDetail.stat?.view) }}</span>
<span
><i class="ri-chat-1-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.danmaku) }}</span
>
<span
><i class="ri-thumb-up-line mr-1"></i>{{ formatNumber(videoDetail.stat?.like) }}</span
>
</div>
<div class="description">
<p>{{ videoDetail.desc }}</p>
</div>
<div class="duration">
<p>总时长: {{ formatTotalDuration(videoDetail.duration) }}</p>
</div>
</div>
</div>
<div v-if="videoDetail?.pages && videoDetail.pages.length > 1" class="video-parts">
<div class="parts-title">分P列表 ({{ videoDetail.pages.length }})</div>
<div class="parts-list">
<n-button
v-for="page in videoDetail.pages"
:key="page.cid"
:type="currentPage?.cid === page.cid ? 'primary' : 'default'"
size="small"
class="part-item"
@click="switchPage(page)"
>
{{ page.part }}
</n-button>
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { getBilibiliPlayUrl, getBilibiliProxyUrl, getBilibiliVideoDetail } from '@/api/bilibili';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import type { IBilibiliPage, IBilibiliVideoDetail } from '@/types/bilibili';
const props = defineProps<{
show: boolean;
bvid?: string;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'close'): void;
}>();
const playerStore = usePlayerStore();
const isLoading = ref(true);
const errorMessage = ref('');
const videoDetail = ref<IBilibiliVideoDetail | null>(null);
const currentPage = ref<IBilibiliPage | null>(null);
const audioList = ref<SongResult[]>([]);
const showModal = computed({
get: () => props.show,
set: (value) => {
emit('update:show', value);
if (!value) {
emit('close');
}
}
});
watch(
() => props.bvid,
async (newBvid) => {
if (newBvid) {
await loadVideoDetail(newBvid);
}
},
{ immediate: true }
);
watch(
() => props.show,
(newValue) => {
console.log('Modal show changed:', newValue);
if (newValue && props.bvid && !videoDetail.value) {
loadVideoDetail(props.bvid);
}
}
);
const closePlayer = () => {
showModal.value = false;
};
const loadVideoDetail = async (bvid: string) => {
if (!bvid) return;
isLoading.value = true;
errorMessage.value = '';
audioList.value = [];
try {
console.log('加载B站视频详情:', bvid);
const res = await getBilibiliVideoDetail(bvid);
console.log('B站视频详情数据:', res.data);
// 确保响应式数据更新
videoDetail.value = JSON.parse(JSON.stringify(res.data));
// 默认加载第一个分P
if (videoDetail.value?.pages && videoDetail.value.pages.length > 0) {
console.log('视频有多个分P,共', videoDetail.value.pages.length, '个');
const [firstPage] = videoDetail.value.pages;
currentPage.value = firstPage;
await loadVideoSource();
} else {
console.log('视频无分P或分P数据为空');
errorMessage.value = '无法加载视频分P信息';
}
} catch (error) {
console.error('获取视频详情失败', error);
errorMessage.value = '获取视频详情失败';
} finally {
isLoading.value = false;
}
};
const loadVideoSource = async () => {
if (!props.bvid || !currentPage.value?.cid) {
console.error('缺少必要参数:', { bvid: props.bvid, cid: currentPage.value?.cid });
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
console.log('加载音频源:', props.bvid, currentPage.value.cid);
// 将当前视频转换为音频格式加入播放列表
const tempAudio = createSongFromBilibiliVideo(); // 创建一个临时对象,还没有URL
// 加载当前分P的音频URL
const currentAudio = await loadSongUrl(currentPage.value, tempAudio);
// 将所有分P添加到播放列表
if (videoDetail.value?.pages) {
audioList.value = videoDetail.value.pages.map((page, index) => {
// 第一个分P直接使用已获取的音频URL
if (index === 0 && currentPage.value?.cid === page.cid) {
return currentAudio;
}
// 其他分P创建占位对象,稍后按需加载
return {
id: `${videoDetail.value!.aid}--${page.cid}`, // 使用aid+cid作为唯一ID
name: `${page.part || ''} - ${videoDetail.value!.title}`,
picUrl: getBilibiliProxyUrl(videoDetail.value!.pic),
type: 0,
canDislike: false,
alg: '',
source: 'bilibili', // 设置来源为B站
song: {
name: `${page.part || ''} - ${videoDetail.value!.title}`,
id: `${videoDetail.value!.aid}--${page.cid}`,
ar: [
{
name: videoDetail.value!.owner.name,
id: videoDetail.value!.owner.mid
}
],
al: {
picUrl: getBilibiliProxyUrl(videoDetail.value!.pic)
}
} as any,
bilibiliData: {
bvid: props.bvid,
cid: page.cid
}
} as SongResult;
});
console.log('已生成音频列表,共', audioList.value.length, '首');
// 预加载下一集
if (audioList.value.length > 1) {
const nextIndex = 1; // 默认加载第二个分P
const nextPage = videoDetail.value.pages[nextIndex];
const nextAudio = audioList.value[nextIndex];
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
}
}
} catch (error) {
console.error('获取音频播放地址失败', error);
errorMessage.value = '获取音频播放地址失败';
} finally {
isLoading.value = false;
}
};
const createSongFromBilibiliVideo = (): SongResult => {
if (!videoDetail.value || !currentPage.value) {
throw new Error('视频详情未加载');
}
const pageName = currentPage.value.part || '';
const title = `${pageName} - ${videoDetail.value.title}`;
return {
id: `${videoDetail.value.aid}--${currentPage.value.cid}`, // 使用aid+cid作为唯一ID
name: title,
picUrl: getBilibiliProxyUrl(videoDetail.value.pic),
type: 0,
canDislike: false,
alg: '',
// 设置来源为B站
source: 'bilibili',
// playMusicUrl属性稍后通过loadSongUrl函数添加
song: {
name: title,
id: `${videoDetail.value.aid}--${currentPage.value.cid}`,
ar: [
{
name: videoDetail.value.owner.name,
id: videoDetail.value.owner.mid
}
],
al: {
picUrl: getBilibiliProxyUrl(videoDetail.value.pic)
}
} as any,
bilibiliData: {
bvid: props.bvid,
cid: currentPage.value.cid
}
} as SongResult;
};
const loadSongUrl = async (page: IBilibiliPage, songItem: SongResult) => {
if (songItem.playMusicUrl) return songItem; // 如果已有URL则直接返回
try {
console.log(`加载分P音频URL: ${page.part}, cid: ${page.cid}`);
const res = await getBilibiliPlayUrl(props.bvid!, page.cid);
const playUrlData = res.data;
let url = '';
// 尝试获取音频URL
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
url = playUrlData.dash.audio[0].baseUrl;
console.log('获取到dash音频URL:', url);
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
url = playUrlData.durl[0].url;
console.log('获取到durl音频URL:', url);
} else {
throw new Error('未找到可用的音频地址');
}
// 设置代理URL
songItem.playMusicUrl = getBilibiliProxyUrl(url);
return songItem;
} catch (error) {
console.error(`加载分P音频URL失败: ${page.part}`, error);
return songItem;
}
};
const switchPage = async (page: IBilibiliPage) => {
console.log('切换到分P:', page.part);
currentPage.value = page;
// 查找对应的音频项
const audioItem = audioList.value.find((item) => item.bilibiliData?.cid === page.cid);
if (audioItem && !audioItem.playMusicUrl) {
// 如果该分P没有音频URL,则先加载
try {
isLoading.value = true;
await loadSongUrl(page, audioItem);
} catch (error) {
console.error('切换分P时加载音频URL失败:', error);
errorMessage.value = '获取音频地址失败,请重试';
} finally {
isLoading.value = false;
}
}
};
const playCurrentAudio = async () => {
if (audioList.value.length === 0) {
console.error('音频列表为空');
errorMessage.value = '音频列表为空,请重试';
return;
}
// 获取当前分P的音频
const currentIndex = audioList.value.findIndex(
(item) => item.bilibiliData?.cid === currentPage.value?.cid
);
if (currentIndex === -1) {
console.error('未找到当前分P的音频');
errorMessage.value = '未找到当前分P的音频';
return;
}
const currentAudio = audioList.value[currentIndex];
console.log('准备播放当前选中的分P:', currentAudio.name);
try {
// 加载当前分P的音频URL(如果需要)
if (!currentAudio.playMusicUrl) {
isLoading.value = true;
await loadSongUrl(currentPage.value!, currentAudio);
if (!currentAudio.playMusicUrl) {
throw new Error('获取音频URL失败');
}
}
// 预加载下一个分P的音频URL(如果有)
const nextIndex = (currentIndex + 1) % audioList.value.length;
if (nextIndex !== currentIndex) {
const nextAudio = audioList.value[nextIndex];
const nextPage = videoDetail.value!.pages.find((p) => p.cid === nextAudio.bilibiliData?.cid);
if (nextPage && !nextAudio.playMusicUrl) {
console.log('预加载下一个分P:', nextPage.part);
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
}
}
// 将B站音频列表设置为播放列表
playerStore.setPlayList(audioList.value);
// 播放当前选中的分P
console.log('播放当前选中的分P:', currentAudio.name, '音频URL:', currentAudio.playMusicUrl);
playerStore.setPlayMusic(currentAudio);
// 关闭模态框
closePlayer();
} catch (error) {
console.error('播放音频失败:', error);
errorMessage.value = error instanceof Error ? error.message : '播放失败,请重试';
} finally {
isLoading.value = false;
}
};
/**
* 格式化总时长
*/
const formatTotalDuration = (seconds?: number) => {
if (!seconds) return '00:00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
/**
* 格式化数字显示
*/
const formatNumber = (num?: number) => {
if (!num) return '0';
if (num >= 10000) {
return `${(num / 10000).toFixed(1)}`;
}
return num.toString();
};
</script>
<style scoped lang="scss">
.bilibili-player-modal {
width: 90vw;
max-width: 1000px;
}
.bilibili-player-wrapper {
@apply flex flex-col p-8 pt-4;
}
.bilibili-player-header {
@apply flex justify-between items-center mb-4;
.title {
@apply text-lg font-medium truncate;
}
.actions {
@apply flex items-center;
}
}
.bilibili-info-wrapper {
@apply flex flex-col md:flex-row gap-4 w-full;
.bilibili-cover {
@apply relative w-full md:w-1/3 aspect-video rounded-lg overflow-hidden;
.cover-image {
@apply w-full h-full object-cover;
}
.play-button {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-white opacity-0 hover:opacity-100 transition-opacity cursor-pointer;
}
}
}
.loading-wrapper,
.error-wrapper {
@apply w-full flex flex-col items-center justify-center py-16 rounded-lg bg-gray-100 dark:bg-gray-800;
aspect-ratio: 16/9;
p {
@apply mt-4 text-gray-600 dark:text-gray-400;
}
}
.error-wrapper {
button {
@apply mt-4;
}
}
.video-info {
@apply flex-1 p-4 rounded-lg bg-gray-100 dark:bg-gray-800;
.author {
@apply flex items-center text-sm mb-2;
}
.stats {
@apply flex gap-4 text-xs text-gray-500 dark:text-gray-400 mb-3;
}
.description {
@apply text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap mb-3;
max-height: 100px;
overflow-y: auto;
}
.duration {
@apply text-sm text-gray-600 dark:text-gray-400;
}
}
.video-parts {
@apply mt-4;
.parts-title {
@apply text-sm font-medium mb-2;
}
.parts-list {
@apply flex flex-wrap gap-2 max-h-60 overflow-y-auto pb-20;
.part-item {
@apply text-xs mb-2;
}
}
}
</style>
+410
View File
@@ -0,0 +1,410 @@
<template>
<div
class="music-bar-container"
:class="{ playing: playerStore.isPlay && playerStore.playMusicUrl }"
>
<div class="music-bar-left">
<div
class="music-cover"
:class="{ loading: playerStore.currentSong?.playLoading }"
@click="openDetailPlayer"
>
<n-image
v-if="playerStore.currentSong && playerStore.currentSong.picUrl"
class="cover-image"
:src="getSourcePic(playerStore.currentSong)"
preview-disabled
:object-fit="'cover'"
/>
<n-spin v-if="playerStore.currentSong?.playLoading" size="small" />
</div>
<div class="music-info">
<div class="music-title ellipsis-text">
{{ playerStore.currentSong ? playerStore.currentSong.name : '' }}
</div>
<div class="music-artist ellipsis-text">
{{ getArtistName(playerStore.currentSong) }}
</div>
</div>
</div>
<div class="music-bar-center">
<div class="player-controls">
<n-button quaternary circle class="control-button prev-button" @click="handlePrevClick">
<template #icon>
<i class="ri-skip-back-fill"></i>
</template>
</n-button>
<n-button
quaternary
circle
class="control-button play-button"
:disabled="!playerStore.currentSong.id"
@click="handlePlayClick"
>
<template #icon>
<i :class="playerStore.isPlay ? 'ri-pause-fill' : 'ri-play-fill'"></i>
</template>
</n-button>
<n-button quaternary circle class="control-button next-button" @click="handleNextClick">
<template #icon>
<i class="ri-skip-forward-fill"></i>
</template>
</n-button>
</div>
<div class="progress-container">
<div class="time-text">{{ formatTime(currentTime) }}</div>
<n-slider
class="progress-slider"
:value="playerProgress"
:step="0.1"
:max="duration"
:tooltip="false"
@update:value="handleProgressUpdate"
/>
<div class="time-text">{{ formatTime(duration) }}</div>
</div>
</div>
<div class="music-bar-right">
<n-button quaternary circle class="control-button mode-button" @click="togglePlayMode">
<template #icon>
<i v-if="playerStore.playMode === 0" class="ri-repeat-2-line" />
<i v-else-if="playerStore.playMode === 1" class="ri-repeat-one-line" />
<i v-else-if="playerStore.playMode === 2" class="ri-shuffle-line" />
</template>
</n-button>
<n-button quaternary circle class="control-button list-button" @click="togglePlayList">
<template #icon>
<i class="ri-play-list-line"></i>
</template>
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { getBilibiliProxyUrl } from '@/api/bilibili';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const router = useRouter();
const playerStore = usePlayerStore();
const currentTime = ref(0);
const duration = ref(0);
const playerProgress = ref(0);
const showPlayList = ref(false);
let audioPlayer: HTMLAudioElement | null = null;
// 计算播放进度
const progress = computed(() => {
if (duration.value === 0) return 0;
return (currentTime.value / duration.value) * 100;
});
// 监听播放状态
watch(
() => playerStore.isPlay,
(isPlay) => {
if (!audioPlayer) return;
if (isPlay) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
} else {
audioPlayer.pause();
}
}
);
// 监听播放URL变化
watch(
() => playerStore.playMusicUrl,
(newUrl) => {
if (!audioPlayer) return;
if (newUrl) {
audioPlayer.src = newUrl;
if (playerStore.play) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
}
}
}
);
// 初始化播放器
onMounted(() => {
audioPlayer = new Audio();
// 设置初始音频
if (playerStore.playMusicUrl) {
audioPlayer.src = playerStore.playMusicUrl;
// 如果应该自动播放
if (playerStore.play) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
}
}
// 添加事件监听
audioPlayer.addEventListener('timeupdate', handleTimeUpdate);
audioPlayer.addEventListener('loadeddata', handleLoadedData);
audioPlayer.addEventListener('ended', handleEnded);
audioPlayer.addEventListener('pause', () => playerStore.setIsPlay(false));
audioPlayer.addEventListener('play', () => playerStore.setIsPlay(true));
audioPlayer.addEventListener('error', handleError);
});
// 处理播放时间更新
const handleTimeUpdate = () => {
if (!audioPlayer) return;
currentTime.value = audioPlayer.currentTime;
playerProgress.value = currentTime.value;
// 保存播放进度到localStorage
const playProgress = {
songId: playerStore.currentSong.id,
progress: currentTime.value
};
localStorage.setItem('playProgress', JSON.stringify(playProgress));
};
// 处理音频加载完成
const handleLoadedData = () => {
if (!audioPlayer) return;
duration.value = audioPlayer.duration;
// 如果有保存的播放进度,恢复到对应位置
if (playerStore.savedPlayProgress !== undefined) {
audioPlayer.currentTime = playerStore.savedPlayProgress;
playerStore.savedPlayProgress = undefined;
}
};
// 处理播放结束
const handleEnded = () => {
// 根据播放模式决定下一步操作
if (playerStore.playMode === 1) {
// 单曲循环
if (audioPlayer) {
audioPlayer.currentTime = 0;
audioPlayer.play().catch((error) => {
console.error('重新播放失败:', error);
playerStore.setIsPlay(false);
});
}
} else {
// 列表循环或随机播放
playerStore.nextPlay();
}
};
// 处理播放错误
const handleError = (e: Event) => {
console.error('音频播放出错:', e);
if (audioPlayer?.error) {
console.error('错误码:', audioPlayer.error.code);
// 如果是B站音频可能链接过期,尝试重新获取
if (playerStore.currentSong.source === 'bilibili' && playerStore.currentSong.bilibiliData) {
console.log('B站音频播放错误,可能链接过期');
// 这里可以添加重新获取B站音频链接的逻辑
}
}
};
// 处理播放/暂停按钮点击
const handlePlayClick = () => {
if (!playerStore.currentSong.id) return;
playerStore.setPlayMusic(!playerStore.isPlay);
};
// 处理上一曲按钮点击
const handlePrevClick = () => {
playerStore.prevPlay();
};
// 处理下一曲按钮点击
const handleNextClick = () => {
playerStore.nextPlay();
};
// 处理进度条更新
const handleProgressUpdate = (value: number) => {
if (!audioPlayer) return;
audioPlayer.currentTime = value;
currentTime.value = value;
};
// 切换播放模式
const togglePlayMode = () => {
playerStore.togglePlayMode();
};
// 切换播放列表显示
const togglePlayList = () => {
showPlayList.value = !showPlayList.value;
// 这里可以添加显示播放列表的逻辑
};
// 打开详情播放页
const openDetailPlayer = () => {
if (!playerStore.currentSong.id) return;
playerStore.setMusicFull(true);
// 根据来源打开不同的详情页
if (playerStore.currentSong.source === 'bilibili') {
// 打开B站详情页
if (playerStore.currentSong.bilibiliData?.bvid) {
// 这里可以跳转到B站详情页或显示B站播放器组件
}
} else {
// 打开网易云音乐详情页
router.push({ path: '/lyric' });
}
};
// 格式化时间
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// 获取艺术家名称
const getArtistName = (song?: SongResult) => {
if (!song) return '';
// B站视频
if (song.source === 'bilibili' && song.song?.ar?.[0]) {
return song.song.ar[0].name || 'B站UP主';
}
// 网易云音乐
if (song.song?.artists) {
return song.song.artists.map((artist: any) => artist.name).join('/');
}
if (song.song?.ar) {
return song.song.ar.map((artist: any) => artist.name).join('/');
}
return '';
};
// 根据来源获取封面图
const getSourcePic = (song: SongResult) => {
if (!song || !song.picUrl) return '';
// B站视频
if (song.source === 'bilibili') {
return song.picUrl; // B站封面已经在创建时使用getBilibiliProxyUrl处理过
}
// 网易云音乐
return getImgUrl(song.picUrl, '150y150');
};
</script>
<style scoped lang="scss">
.music-bar-container {
@apply flex items-center justify-between px-4 h-20 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700;
&.playing {
.music-cover {
animation: rotate 20s linear infinite;
}
}
}
.music-bar-left {
@apply flex items-center;
width: 25%;
.music-cover {
@apply relative w-12 h-12 rounded-lg overflow-hidden flex items-center justify-center cursor-pointer;
.cover-image {
@apply w-full h-full object-cover;
}
&.loading {
animation-play-state: paused;
}
}
.music-info {
@apply ml-2 flex flex-col;
max-width: calc(100% - 56px);
.music-title {
@apply text-sm font-medium;
}
.music-artist {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
}
.music-bar-center {
@apply flex flex-col items-center;
width: 50%;
.player-controls {
@apply flex items-center justify-center gap-2 mb-2;
.control-button {
@apply text-lg;
&.play-button {
@apply text-xl;
}
}
}
.progress-container {
@apply flex items-center w-full;
.time-text {
@apply text-xs px-2 text-gray-500 dark:text-gray-400 whitespace-nowrap;
}
.progress-slider {
@apply flex-1;
}
}
}
.music-bar-right {
@apply flex items-center justify-end;
width: 25%;
.control-button {
@apply text-lg;
}
}
.ellipsis-text {
@apply whitespace-nowrap overflow-hidden text-ellipsis;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,117 @@
<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 type { IBilibiliSearchResult } from '@/types/bilibili';
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)}`;
}
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>