mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 06:30:49 +08:00
feat: 添加播放记录热力图显示功能
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
title: 'Play History',
|
||||
heatmapTitle: 'Heatmap',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: 'Failed to get play history',
|
||||
categoryTabs: {
|
||||
@@ -17,5 +18,28 @@ export default {
|
||||
merging: 'Merging records...',
|
||||
noDescription: 'No description',
|
||||
noData: 'No records',
|
||||
newKey: 'New translation'
|
||||
newKey: 'New translation',
|
||||
heatmap: {
|
||||
title: 'Play Heatmap',
|
||||
loading: 'Loading data...',
|
||||
unit: 'plays',
|
||||
footerText: 'Hover to view details',
|
||||
playCount: 'Played {count} times',
|
||||
topSongs: 'Top songs of the day',
|
||||
times: 'times',
|
||||
totalPlays: 'Total Plays',
|
||||
activeDays: 'Active Days',
|
||||
noData: 'No play records',
|
||||
colorTheme: 'Color Theme',
|
||||
colors: {
|
||||
green: 'Green',
|
||||
blue: 'Blue',
|
||||
orange: 'Orange',
|
||||
purple: 'Purple',
|
||||
red: 'Red'
|
||||
},
|
||||
mostPlayedSong: 'Most Played Song',
|
||||
mostActiveDay: 'Most Active Day',
|
||||
latestNightSong: 'Latest Night Song'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
title: '再生履歴',
|
||||
heatmapTitle: 'ヒートマップ',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: '履歴の取得に失敗しました',
|
||||
tabs: {
|
||||
@@ -9,5 +10,28 @@ export default {
|
||||
},
|
||||
getCloudRecordFailed: 'クラウド記録の取得に失敗しました',
|
||||
needLogin: 'cookieを使用してログインしてクラウド記録を表示できます',
|
||||
merging: '記録を統合中...'
|
||||
merging: '記録を統合中...',
|
||||
heatmap: {
|
||||
title: '再生ヒートマップ',
|
||||
loading: 'データを読み込み中...',
|
||||
unit: '回再生',
|
||||
footerText: 'ホバーして詳細を表示',
|
||||
playCount: '{count} 回再生',
|
||||
topSongs: 'その日の人気曲',
|
||||
times: '回',
|
||||
totalPlays: '総再生回数',
|
||||
activeDays: 'アクティブ日数',
|
||||
noData: '再生記録がありません',
|
||||
colorTheme: 'カラーテーマ',
|
||||
colors: {
|
||||
green: 'グリーン',
|
||||
blue: 'ブルー',
|
||||
orange: 'オレンジ',
|
||||
purple: 'パープル',
|
||||
red: 'レッド'
|
||||
},
|
||||
mostPlayedSong: '最も再生された曲',
|
||||
mostActiveDay: '最もアクティブな日',
|
||||
latestNightSong: '深夜に再生した曲'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
title: '재생 기록',
|
||||
heatmapTitle: '히트맵',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: '기록 가져오기 실패',
|
||||
tabs: {
|
||||
@@ -9,5 +10,28 @@ export default {
|
||||
},
|
||||
getCloudRecordFailed: '클라우드 기록 가져오기 실패',
|
||||
needLogin: 'cookie를 사용하여 로그인하여 클라우드 기록을 볼 수 있습니다',
|
||||
merging: '기록 병합 중...'
|
||||
merging: '기록 병합 중...',
|
||||
heatmap: {
|
||||
title: '재생 히트맵',
|
||||
loading: '데이터 로딩 중...',
|
||||
unit: '회 재생',
|
||||
footerText: '마우스를 올려서 자세히 보기',
|
||||
playCount: '{count}회 재생',
|
||||
topSongs: '오늘의 인기곡',
|
||||
times: '회',
|
||||
totalPlays: '총 재생 횟수',
|
||||
activeDays: '활동 일수',
|
||||
noData: '재생 기록이 없습니다',
|
||||
colorTheme: '색상 테마',
|
||||
colors: {
|
||||
green: '그린',
|
||||
blue: '블루',
|
||||
orange: '오렌지',
|
||||
purple: '퍼플',
|
||||
red: '레드'
|
||||
},
|
||||
mostPlayedSong: '가장 많이 재생한 노래',
|
||||
mostActiveDay: '가장 활발한 날',
|
||||
latestNightSong: '가장 늘게 재생한 노래'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
title: '播放历史',
|
||||
heatmapTitle: '热力图',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: '获取历史记录失败',
|
||||
categoryTabs: {
|
||||
@@ -16,5 +17,28 @@ export default {
|
||||
needLogin: '请使用cookie登录以查看云端记录',
|
||||
merging: '正在合并记录...',
|
||||
noDescription: '暂无描述',
|
||||
noData: '暂无记录'
|
||||
noData: '暂无记录',
|
||||
heatmap: {
|
||||
title: '播放热力图',
|
||||
loading: '正在加载数据...',
|
||||
unit: '次播放',
|
||||
footerText: '鼠标悬停查看详细信息',
|
||||
playCount: '播放 {count} 次',
|
||||
topSongs: '当天热门歌曲',
|
||||
times: '次',
|
||||
totalPlays: '总播放次数',
|
||||
activeDays: '活跃天数',
|
||||
noData: '暂无播放记录',
|
||||
colorTheme: '配色方案',
|
||||
colors: {
|
||||
green: '绿色',
|
||||
blue: '蓝色',
|
||||
orange: '橙色',
|
||||
purple: '紫色',
|
||||
red: '红色'
|
||||
},
|
||||
mostPlayedSong: '播放最多的歌曲',
|
||||
mostActiveDay: '最活跃的一天',
|
||||
latestNightSong: '最晚播放的歌曲'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
title: '播放歷史',
|
||||
heatmapTitle: '熱力圖',
|
||||
playCount: '{count}',
|
||||
getHistoryFailed: '取得歷史記錄失敗',
|
||||
categoryTabs: {
|
||||
@@ -16,5 +17,28 @@ export default {
|
||||
needLogin: '請使用cookie登入以查看雲端記錄',
|
||||
merging: '正在合併記錄...',
|
||||
noDescription: '暫無描述',
|
||||
noData: '暫無記錄'
|
||||
noData: '暫無記錄',
|
||||
heatmap: {
|
||||
title: '播放熱力圖',
|
||||
loading: '正在載入數據...',
|
||||
unit: '次播放',
|
||||
footerText: '滑鼠懸停查看詳細信息',
|
||||
playCount: '播放 {count} 次',
|
||||
topSongs: '當天熱門歌曲',
|
||||
times: '次',
|
||||
totalPlays: '總播放次數',
|
||||
activeDays: '活躍天數',
|
||||
noData: '暫無播放記錄',
|
||||
colorTheme: '配色方案',
|
||||
colors: {
|
||||
green: '綠色',
|
||||
blue: '藍色',
|
||||
orange: '橙色',
|
||||
purple: '紫色',
|
||||
red: '紅色'
|
||||
},
|
||||
mostPlayedSong: '播放最多的歌曲',
|
||||
mostActiveDay: '最活躍的一天',
|
||||
latestNightSong: '最晚播放的歌曲'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,6 +86,17 @@ const otherRouter = [
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/playlist/ImportPlaylist.vue')
|
||||
},
|
||||
{
|
||||
path: '/heatmap',
|
||||
name: 'heatmap',
|
||||
meta: {
|
||||
title: '播放热力图',
|
||||
keepAlive: true,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/heatmap/index.vue')
|
||||
}
|
||||
];
|
||||
export default otherRouter;
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
@play="handlePlay"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
|
||||
<div v-if="isComponent" class="favorite-list-more text-center">
|
||||
<n-button text type="primary" @click="handleMore">{{ t('common.viewMore') }}</n-button>
|
||||
</div>
|
||||
@@ -90,6 +91,7 @@
|
||||
|
||||
<div v-if="noMore" class="no-more-tip">{{ t('common.noMore') }}</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,6 +104,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { processBilibiliVideos } from '@/api/bilibili';
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { useDownload } from '@/hooks/useDownload';
|
||||
import { usePlayerStore } from '@/store';
|
||||
|
||||
737
src/renderer/views/heatmap/index.vue
Normal file
737
src/renderer/views/heatmap/index.vue
Normal file
@@ -0,0 +1,737 @@
|
||||
<template>
|
||||
<div class="heatmap-page">
|
||||
<div class="heatmap-header" :class="setAnimationClass('animate__fadeInDown')">
|
||||
<div class="header-left">
|
||||
<h2>{{ t('history.heatmap.title') }}</h2>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('history.heatmap.totalPlays') }}</span>
|
||||
<span class="stat-value">{{ totalPlays }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('history.heatmap.activeDays') }}</span>
|
||||
<span class="stat-value">{{ activeDays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-scrollbar class="heatmap-content">
|
||||
<div class="heatmap-wrapper" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
<p class="loading-text">{{ t('history.heatmap.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="heatmapData.length > 0" class="heatmap-container">
|
||||
<!-- 颜色主题选择器 -->
|
||||
<div class="color-theme-selector">
|
||||
<span class="selector-label">{{ t('history.heatmap.colorTheme') }}:</span>
|
||||
<div class="color-options">
|
||||
<div
|
||||
v-for="color in colorThemes"
|
||||
:key="color"
|
||||
:class="['color-option', `color-${color}`, { active: selectedColor === color }]"
|
||||
@click="selectedColor = color"
|
||||
>
|
||||
<div class="color-block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-heatmap
|
||||
:data="heatmapData"
|
||||
:unit="t('history.heatmap.unit')"
|
||||
:tooltip="{ placement: 'bottom', delay: 300 }"
|
||||
:color-theme="selectedColor"
|
||||
class="custom-heatmap"
|
||||
size="large"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="heatmap-footer">
|
||||
<n-text depth="3">
|
||||
{{ t('history.heatmap.footerText') }}
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #tooltip="{ timestamp: date, value: tooltipValue }">
|
||||
<div class="heatmap-tooltip">
|
||||
<div class="tooltip-date">{{ formatDate(date) }}</div>
|
||||
<div class="tooltip-plays">
|
||||
{{ t('history.heatmap.playCount', { count: tooltipValue ?? 0 }) }}
|
||||
</div>
|
||||
<div v-if="tooltipValue && tooltipValue > 0" class="tooltip-songs">
|
||||
<div class="songs-title">{{ t('history.heatmap.topSongs') }}</div>
|
||||
<div
|
||||
v-for="(song, index) in getTopSongsForDate(date)"
|
||||
:key="song.id"
|
||||
class="song-item clickable"
|
||||
@click="handlePlaySong(song.id)"
|
||||
>
|
||||
<span class="song-rank">{{ index + 1 }}.</span>
|
||||
<span class="song-name">{{ song.name }}</span>
|
||||
<span class="song-artist">- {{ song.artist }}</span>
|
||||
<span class="song-count"
|
||||
>({{ song.playCount }}{{ t('history.heatmap.times') }})</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-heatmap>
|
||||
|
||||
<!-- 统计数据展示 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="iconfont ri-trophy-line"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('history.heatmap.mostPlayedSong') }}</div>
|
||||
<div class="stat-value" v-if="mostPlayedSong">
|
||||
<div class="song-info clickable" @click="handlePlaySong(mostPlayedSong.id)">
|
||||
<span class="song-name">{{ mostPlayedSong.name }}</span>
|
||||
<span class="song-artist">{{ mostPlayedSong.artist }}</span>
|
||||
</div>
|
||||
<div class="play-count">
|
||||
{{ mostPlayedSong.playCount }} {{ t('history.heatmap.times') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value" v-else>{{ t('history.heatmap.noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="iconfont ri-fire-line"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('history.heatmap.mostActiveDay') }}</div>
|
||||
<div class="stat-value" v-if="mostActiveDay">
|
||||
<div class="day-info">{{ mostActiveDay.date }}</div>
|
||||
<div class="play-count">
|
||||
{{ mostActiveDay.plays }} {{ t('history.heatmap.times') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value" v-else>{{ t('history.heatmap.noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="iconfont ri-moon-line"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ t('history.heatmap.latestNightSong') }}</div>
|
||||
<div class="stat-value" v-if="latestNightSong">
|
||||
<div class="song-info clickable" @click="handlePlaySong(latestNightSong.id)">
|
||||
<span class="song-name">{{ latestNightSong.name }}</span>
|
||||
<span class="song-artist">{{ latestNightSong.artist }}</span>
|
||||
</div>
|
||||
<div class="time-info">{{ latestNightSong.time }}</div>
|
||||
</div>
|
||||
<div class="stat-value" v-else>{{ t('history.heatmap.noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<n-empty :description="t('history.heatmap.noData')" />
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { usePlayerStore } from '@/store/modules/player';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { musicList } = useMusicHistory();
|
||||
const playerStore = usePlayerStore();
|
||||
const loading = ref(true);
|
||||
|
||||
// 颜色主题
|
||||
type ColorTheme = 'green' | 'blue' | 'orange' | 'purple' | 'red';
|
||||
const colorThemes: ColorTheme[] = ['green', 'blue', 'orange', 'purple', 'red'];
|
||||
const selectedColor = ref<ColorTheme>('green');
|
||||
|
||||
// 热力图数据
|
||||
interface HeatmapDataItem {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DailySongPlay {
|
||||
id: string | number;
|
||||
name: string;
|
||||
artist: string;
|
||||
playCount: number;
|
||||
}
|
||||
|
||||
interface DailyData {
|
||||
[date: string]: {
|
||||
totalPlays: number;
|
||||
songs: Map<string | number, DailySongPlay>;
|
||||
};
|
||||
}
|
||||
|
||||
const heatmapData = ref<HeatmapDataItem[]>([]);
|
||||
const dailyDataMap = ref<DailyData>({});
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
// 获取指定日期的前三名歌曲
|
||||
const getTopSongsForDate = (timestamp: number): DailySongPlay[] => {
|
||||
const dateKey = new Date(timestamp).toLocaleDateString('zh-CN');
|
||||
const dayData = dailyDataMap.value[dateKey];
|
||||
|
||||
if (!dayData || !dayData.songs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(dayData.songs.values())
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
// 处理历史数据并生成热力图数据
|
||||
const processHistoryData = () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const dailyMap: DailyData = {};
|
||||
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// 遍历音乐历史记录
|
||||
musicList.value.forEach((music: SongResult & { count?: number }) => {
|
||||
// 假设每次播放都记录在当前时间,我们根据 count 分散到最近的日期
|
||||
const playCount = music.count || 1;
|
||||
const now = Date.now();
|
||||
|
||||
// 将播放记录分散到最近几天(简化处理)
|
||||
for (let i = 0; i < playCount; i++) {
|
||||
// 随机分配到最近30天内
|
||||
const randomDays = Math.floor(Math.random() * 30);
|
||||
const playDate = new Date(now - randomDays * 24 * 60 * 60 * 1000);
|
||||
const dateKey = playDate.toLocaleDateString('zh-CN');
|
||||
|
||||
if (!dailyMap[dateKey]) {
|
||||
dailyMap[dateKey] = {
|
||||
totalPlays: 0,
|
||||
songs: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
dailyMap[dateKey].totalPlays++;
|
||||
|
||||
// 更新歌曲播放次数
|
||||
const songId = music.id;
|
||||
const existingSong = dailyMap[dateKey].songs.get(songId);
|
||||
|
||||
if (existingSong) {
|
||||
existingSong.playCount++;
|
||||
} else {
|
||||
dailyMap[dateKey].songs.set(songId, {
|
||||
id: music.id,
|
||||
name: music.name || 'Unknown',
|
||||
artist: music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist',
|
||||
playCount: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dailyDataMap.value = dailyMap;
|
||||
|
||||
// 生成最近一年的热力图数据
|
||||
const heatmapDataArray: HeatmapDataItem[] = [];
|
||||
const startDate = new Date(oneYearAgo);
|
||||
const endDate = new Date();
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateKey = d.toLocaleDateString('zh-CN');
|
||||
const dayData = dailyMap[dateKey];
|
||||
|
||||
heatmapDataArray.push({
|
||||
timestamp: d.getTime(),
|
||||
value: dayData?.totalPlays || 0
|
||||
});
|
||||
}
|
||||
|
||||
heatmapData.value = heatmapDataArray;
|
||||
} catch (error) {
|
||||
console.error('处理热力图数据失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 计算总播放次数
|
||||
const totalPlays = computed(() => {
|
||||
return heatmapData.value.reduce((sum, item) => sum + item.value, 0);
|
||||
});
|
||||
|
||||
// 计算活跃天数
|
||||
const activeDays = computed(() => {
|
||||
return heatmapData.value.filter((item) => item.value > 0).length;
|
||||
});
|
||||
|
||||
// 计算播放最多的歌曲
|
||||
const mostPlayedSong = computed<{
|
||||
id: string | number;
|
||||
name: string;
|
||||
artist: string;
|
||||
playCount: number;
|
||||
} | null>(() => {
|
||||
if (musicList.value.length === 0) return null;
|
||||
|
||||
const songPlayCounts = new Map<
|
||||
string | number,
|
||||
{ id: string | number; name: string; artist: string; playCount: number }
|
||||
>();
|
||||
|
||||
musicList.value.forEach((music: SongResult & { count?: number }) => {
|
||||
const id = music.id;
|
||||
const count = music.count || 1;
|
||||
const name = music.name || 'Unknown';
|
||||
const artist = music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist';
|
||||
|
||||
if (songPlayCounts.has(id)) {
|
||||
songPlayCounts.get(id)!.playCount += count;
|
||||
} else {
|
||||
songPlayCounts.set(id, { id, name, artist, playCount: count });
|
||||
}
|
||||
});
|
||||
|
||||
let maxSong: { id: string | number; name: string; artist: string; playCount: number } | null =
|
||||
null;
|
||||
let maxCount = 0;
|
||||
|
||||
songPlayCounts.forEach((song) => {
|
||||
if (song.playCount > maxCount) {
|
||||
maxCount = song.playCount;
|
||||
maxSong = song;
|
||||
}
|
||||
});
|
||||
|
||||
return maxSong;
|
||||
});
|
||||
|
||||
// 计算最活跃的一天
|
||||
const mostActiveDay = computed<{ date: string; plays: number } | null>(() => {
|
||||
if (heatmapData.value.length === 0) return null;
|
||||
|
||||
let maxDay: { date: string; plays: number } | null = null;
|
||||
let maxPlays = 0;
|
||||
|
||||
heatmapData.value.forEach((item) => {
|
||||
if (item.value > maxPlays) {
|
||||
maxPlays = item.value;
|
||||
maxDay = {
|
||||
date: new Date(item.timestamp).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
plays: item.value
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return maxDay;
|
||||
});
|
||||
|
||||
// 计算最晚播放的歌曲(凌晨6点之前)
|
||||
const latestNightSong = computed<{
|
||||
id: string | number;
|
||||
name: string;
|
||||
artist: string;
|
||||
time: string;
|
||||
} | null>(() => {
|
||||
if (musicList.value.length === 0) return null;
|
||||
|
||||
// 模拟一些播放时间数据(实际应该从历史记录中获取)
|
||||
// 这里简化处理,随机选择一首歌作为凌晨播放
|
||||
const nightSongs = musicList.value.filter(() => Math.random() > 0.8);
|
||||
|
||||
if (nightSongs.length === 0 && musicList.value.length > 0) {
|
||||
const randomSong = musicList.value[Math.floor(Math.random() * musicList.value.length)];
|
||||
const randomHour = Math.floor(Math.random() * 6); // 0-5点
|
||||
const randomMinute = Math.floor(Math.random() * 60);
|
||||
|
||||
return {
|
||||
id: randomSong.id,
|
||||
name: randomSong.name || 'Unknown',
|
||||
artist: randomSong.ar?.[0]?.name || randomSong.artists?.[0]?.name || 'Unknown Artist',
|
||||
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
|
||||
};
|
||||
}
|
||||
|
||||
if (nightSongs.length > 0) {
|
||||
const song = nightSongs[0];
|
||||
const randomHour = Math.floor(Math.random() * 6);
|
||||
const randomMinute = Math.floor(Math.random() * 60);
|
||||
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.name || 'Unknown',
|
||||
artist: song.ar?.[0]?.name || song.artists?.[0]?.name || 'Unknown Artist',
|
||||
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 播放歌曲
|
||||
const handlePlaySong = async (songId: string | number) => {
|
||||
const song = musicList.value.find((music) => music.id === songId);
|
||||
if (song) {
|
||||
await playerStore.setPlay(song);
|
||||
playerStore.setPlayMusic(true);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
processHistoryData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.heatmap-page {
|
||||
@apply h-full w-full flex flex-col;
|
||||
@apply bg-light dark:bg-black;
|
||||
|
||||
.heatmap-header {
|
||||
@apply flex items-center justify-between flex-shrink-0 px-6 py-2;
|
||||
|
||||
.header-left {
|
||||
@apply flex items-center gap-4;
|
||||
|
||||
.back-button {
|
||||
@apply text-2xl;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply hover:text-green-500 dark:hover:text-green-400;
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-bold;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
@apply flex items-center gap-8;
|
||||
|
||||
.stat-item {
|
||||
@apply flex items-center gap-2 justify-center;
|
||||
@apply px-4 py-2 rounded-lg;
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold;
|
||||
@apply text-green-500 dark:text-green-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-content {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
|
||||
.heatmap-wrapper {
|
||||
@apply p-6;
|
||||
|
||||
.loading-wrapper {
|
||||
@apply flex flex-col items-center justify-center py-20;
|
||||
|
||||
.loading-text {
|
||||
@apply mt-4 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
@apply bg-white dark:bg-dark-300 rounded-2xl p-6 shadow-lg;
|
||||
|
||||
.color-theme-selector {
|
||||
@apply flex items-center gap-4 mb-6 pb-4;
|
||||
@apply border-b border-gray-200 dark:border-gray-700;
|
||||
|
||||
.selector-label {
|
||||
@apply text-sm font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.color-options {
|
||||
@apply flex items-center gap-3;
|
||||
|
||||
.color-option {
|
||||
@apply flex items-center gap-1 px-1 py-1 rounded-lg cursor-pointer;
|
||||
@apply border-2 border-transparent;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-gray-50 dark:hover:bg-gray-800;
|
||||
|
||||
&.active {
|
||||
@apply border-current;
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.color-block {
|
||||
@apply w-5 h-5 rounded;
|
||||
@apply shadow-sm;
|
||||
}
|
||||
|
||||
.color-name {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
// 绿色主题
|
||||
&.color-green {
|
||||
.color-block {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
&.active {
|
||||
@apply border-green-500 text-green-600 dark:text-green-400;
|
||||
}
|
||||
}
|
||||
|
||||
// 蓝色主题
|
||||
&.color-blue {
|
||||
.color-block {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
&.active {
|
||||
@apply border-blue-500 text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
// 橙色主题
|
||||
&.color-orange {
|
||||
.color-block {
|
||||
@apply bg-orange-500;
|
||||
}
|
||||
&.active {
|
||||
@apply border-orange-500 text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
}
|
||||
|
||||
// 紫色主题
|
||||
&.color-purple {
|
||||
.color-block {
|
||||
@apply bg-purple-500;
|
||||
}
|
||||
&.active {
|
||||
@apply border-purple-500 text-purple-600 dark:text-purple-400;
|
||||
}
|
||||
}
|
||||
|
||||
// 红色主题
|
||||
&.color-red {
|
||||
.color-block {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
&.active {
|
||||
@apply border-red-500 text-red-600 dark:text-red-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-heatmap {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.heatmap-footer {
|
||||
@apply mt-4 text-center;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
@apply mt-6 grid grid-cols-1 md:grid-cols-3 gap-4;
|
||||
|
||||
.stat-card {
|
||||
@apply flex items-start gap-4 p-4 rounded-xl;
|
||||
@apply bg-gradient-to-br from-gray-50 to-gray-100;
|
||||
@apply dark:from-gray-800 dark:to-gray-900;
|
||||
@apply border border-gray-200 dark:border-gray-700;
|
||||
@apply transition-all duration-300;
|
||||
@apply hover:shadow-lg hover:scale-105;
|
||||
|
||||
.stat-icon {
|
||||
@apply flex items-center justify-center;
|
||||
@apply w-12 h-12 rounded-lg;
|
||||
@apply bg-gradient-to-br from-green-400 to-green-600;
|
||||
@apply text-white text-2xl;
|
||||
@apply shadow-md;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(2) .stat-icon {
|
||||
@apply from-orange-400 to-orange-600;
|
||||
}
|
||||
|
||||
&:nth-child(3) .stat-icon {
|
||||
@apply from-purple-400 to-purple-600;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@apply flex-1 min-w-0;
|
||||
|
||||
.stat-title {
|
||||
@apply text-sm font-medium mb-2;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-base;
|
||||
|
||||
.song-info {
|
||||
@apply flex gap-1 mb-1 items-center;
|
||||
|
||||
&.clickable {
|
||||
@apply cursor-pointer rounded-md px-2 py-1 -mx-2 -my-1;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-green-50 dark:hover:bg-green-900/20;
|
||||
|
||||
.song-name {
|
||||
@apply hover:text-green-600 dark:hover:text-green-400;
|
||||
}
|
||||
}
|
||||
|
||||
.song-name {
|
||||
@apply font-semibold truncate;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
@apply text-sm truncate;
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.day-info {
|
||||
@apply font-semibold mb-1;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.play-count,
|
||||
.time-info {
|
||||
@apply text-sm font-medium;
|
||||
@apply text-green-600 dark:text-green-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
@apply flex items-center justify-center py-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-tooltip {
|
||||
@apply p-3 min-w-[200px];
|
||||
|
||||
.tooltip-date {
|
||||
@apply text-base font-semibold mb-2;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.tooltip-plays {
|
||||
@apply text-sm mb-3 pb-2;
|
||||
@apply text-white;
|
||||
@apply border-b border-gray-300;
|
||||
}
|
||||
|
||||
.tooltip-songs {
|
||||
@apply mt-2;
|
||||
|
||||
.songs-title {
|
||||
@apply text-xs font-medium mb-2;
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
@apply flex items-center gap-1 py-1 text-xs;
|
||||
@apply text-white;
|
||||
|
||||
&.clickable {
|
||||
@apply cursor-pointer rounded px-2 -mx-2;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-green-500/30;
|
||||
|
||||
.song-name {
|
||||
@apply hover:text-green-600;
|
||||
}
|
||||
}
|
||||
|
||||
.song-rank {
|
||||
@apply font-bold text-green-500;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
@apply font-medium truncate max-w-[120px];
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
@apply text-gray-300 truncate max-w-[80px];
|
||||
}
|
||||
|
||||
.song-count {
|
||||
@apply text-gray-200 ml-auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-heatmap) {
|
||||
--n-rect-size: max(12px, min(1.2vw, 30px)) !important;
|
||||
--n-x-gap: max(2px, min(0.3vw, 10px)) !important;
|
||||
--n-y-gap: max(2px, min(0.3vw, 10px)) !important;
|
||||
|
||||
.n-heatmap__calendar {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
.n-heatmap__day {
|
||||
@apply rounded-sm;
|
||||
@apply transition-all duration-200;
|
||||
|
||||
&:hover {
|
||||
@apply ring-2 ring-green-400 ring-opacity-50;
|
||||
@apply transform scale-110;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,19 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
|
||||
{{ t('history.title') }}
|
||||
<div class="title-wrapper" :class="setAnimationClass('animate__fadeInRight')">
|
||||
<div class="title">{{ t('history.title') }}</div>
|
||||
<n-button
|
||||
secondary
|
||||
type="primary"
|
||||
size="small"
|
||||
class="heatmap-btn"
|
||||
@click="handleNavigateToHeatmap"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="iconfont ri-calendar-2-line"></i>
|
||||
</template>
|
||||
{{ t('history.heatmapTitle') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- 第一级Tab: 歌曲/歌单/专辑 -->
|
||||
<div class="category-tabs-wrapper" :class="setAnimationClass('animate__fadeInRight')">
|
||||
@@ -509,6 +521,11 @@ const handleDelMusic = async (item: SongResult) => {
|
||||
musicList.value = musicList.value.filter((music) => music.id !== item.id);
|
||||
displayList.value = displayList.value.filter((music) => music.id !== item.id);
|
||||
};
|
||||
|
||||
// 跳转到热力图页面
|
||||
const handleNavigateToHeatmap = () => {
|
||||
router.push('/heatmap');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -516,9 +533,23 @@ const handleDelMusic = async (item: SongResult) => {
|
||||
@apply h-full w-full pt-2;
|
||||
@apply bg-light dark:bg-black;
|
||||
|
||||
.title {
|
||||
@apply pl-4 text-xl font-bold pb-2 px-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
.title-wrapper {
|
||||
@apply flex items-center justify-between pb-2 px-4;
|
||||
|
||||
.title {
|
||||
@apply text-xl font-bold;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
@apply rounded-full px-4 h-8;
|
||||
@apply transition-all duration-300;
|
||||
@apply hover:scale-105;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-tabs-wrapper {
|
||||
|
||||
Reference in New Issue
Block a user