mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 14:02:49 +08:00
Merge pull request #175 from zzstar101/feat/artist-search
This commit is contained in:
commit
41b81a6193
17 files changed
+2260
-14
No files matched your search
@@ -20,6 +20,7 @@ const router = createRouter({
|
||||
component: () => import('../views/Playlist.vue'),
|
||||
meta: { kind: 'album' },
|
||||
},
|
||||
{ path: '/artist/:id', name: 'artist', component: () => import('../views/Artist.vue') },
|
||||
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
||||
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
||||
{ path: '/saved-queues', name: 'saved-queues', component: () => import('../views/SavedQueues.vue') },
|
||||
|
||||
@@ -583,6 +583,37 @@ export const usePlayerStore = defineStore('player', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue an artist's songs. The server always loads the singer's FULL
|
||||
* catalogue (never just the hot 50), which costs it a few upstream round
|
||||
* trips, so the caller gets a "loading" notice first.
|
||||
*/
|
||||
async playArtist(artistId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
this.notify('正在载入该歌手的全部歌曲…', 'info');
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`/api/player/${this.activeBotId}/play-artist`,
|
||||
{ artistId, platform },
|
||||
);
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
}
|
||||
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||
this._syncAfterAction();
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status;
|
||||
this.notify(
|
||||
status === 403
|
||||
? '没有权限播放整个歌手'
|
||||
: status === 501
|
||||
? '该音源不支持播放歌手歌曲'
|
||||
: '播放歌手失败',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async pause() {
|
||||
if (!this.activeBotId) return;
|
||||
// Freeze elapsed at the current LIVE interpolated value. Using the cached
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = () => readFileSync(join(currentDir, "Artist.vue"), "utf8");
|
||||
|
||||
describe("Artist page layout", () => {
|
||||
it("has no 全部歌曲 browser section (play buttons queue the catalogue instead)", () => {
|
||||
const src = source();
|
||||
|
||||
expect(src).not.toContain("全部歌曲</h2>");
|
||||
expect(src).not.toContain("加载更多");
|
||||
expect(src).not.toContain("加载全部歌曲");
|
||||
expect(src).not.toContain("ALL_SONGS_PAGE");
|
||||
expect(src).not.toContain("loadMoreSongs");
|
||||
expect(src).not.toContain("allSongs");
|
||||
});
|
||||
|
||||
it("keeps the hot songs and album sections", () => {
|
||||
const src = source();
|
||||
|
||||
expect(src).toContain("热门歌曲");
|
||||
expect(src).toContain("专辑");
|
||||
expect(src).toContain("显示全部");
|
||||
});
|
||||
|
||||
it("plays through playArtist without an all flag (the server always loads everything)", () => {
|
||||
const src = source();
|
||||
|
||||
expect(src).toContain("store.playArtist(artistId(), platform.value)");
|
||||
expect(src).not.toContain("playArtist(artistId(), platform.value, true)");
|
||||
});
|
||||
|
||||
it("refetches when navigating between artists", () => {
|
||||
const src = source();
|
||||
|
||||
expect(src).toContain("watch(");
|
||||
expect(src).toContain("route.params.id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="artist-page">
|
||||
<button class="back-btn" @click="$router.back()">
|
||||
<Icon icon="mdi:arrow-left" />
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<template v-else-if="artist">
|
||||
<!-- Hero: Apple Music style — round portrait, then name / meta / actions. -->
|
||||
<div class="artist-hero">
|
||||
<div class="artist-portrait-wrap">
|
||||
<img
|
||||
v-if="artist.avatarUrl"
|
||||
class="artist-portrait"
|
||||
:src="artist.avatarUrl"
|
||||
:alt="artist.name"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="artist-portrait artist-portrait-fallback">
|
||||
<Icon icon="mdi:account-music" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="artist-meta">
|
||||
<div class="artist-platform">{{ platformLabel }}</div>
|
||||
<h1 class="artist-title">{{ artist.name }}</h1>
|
||||
<div v-if="artist.aliases?.length" class="artist-aliases">
|
||||
{{ artist.aliases.join(' / ') }}
|
||||
</div>
|
||||
<div class="artist-stats">
|
||||
<span v-if="hotSongs.length">热门歌曲 {{ hotSongs.length }} 首</span>
|
||||
<span v-if="albums.length">专辑 {{ albums.length }} 张</span>
|
||||
<span v-if="artist.songCount">共 {{ artist.songCount }} 首歌</span>
|
||||
</div>
|
||||
<p v-if="artist.description" class="artist-desc">{{ artist.description }}</p>
|
||||
<div class="artist-actions">
|
||||
<button
|
||||
v-if="canPlayAll"
|
||||
class="play-all-btn"
|
||||
:disabled="!artist"
|
||||
title="播放该歌手的全部歌曲"
|
||||
@click="playAll"
|
||||
>
|
||||
<Icon icon="mdi:play" />
|
||||
播放
|
||||
</button>
|
||||
<button
|
||||
v-if="canPlayAll"
|
||||
class="shuffle-btn"
|
||||
:disabled="!artist"
|
||||
title="随机播放该歌手的全部歌曲"
|
||||
@click="shuffleAll"
|
||||
>
|
||||
<Icon icon="mdi:shuffle" />
|
||||
随机播放
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="hotSongs.length" class="artist-section">
|
||||
<h2 class="section-title">热门歌曲</h2>
|
||||
<div class="song-list">
|
||||
<SongCard
|
||||
v-for="(song, i) in visibleSongs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.playSong(song)"
|
||||
@playNext="store.playNextSong(song)"
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</div>
|
||||
<button v-if="hotSongs.length > visibleCount" class="more-btn" @click="expanded = true">
|
||||
显示全部 {{ hotSongs.length }} 首
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="albums.length" class="artist-section">
|
||||
<h2 class="section-title">专辑</h2>
|
||||
<div class="album-row">
|
||||
<router-link
|
||||
v-for="al in albums"
|
||||
:key="al.id"
|
||||
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||
class="album-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="al.coverUrl" :size="150" :radius="10" :show-shadow="true" />
|
||||
<div class="album-name">{{ al.name }}</div>
|
||||
<div v-if="al.songCount" class="album-sub">{{ al.songCount }} 首</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="loading">歌手不存在或加载失败</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore, type Song } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
interface ArtistDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
aliases?: string[];
|
||||
songCount?: number;
|
||||
albumCount?: number;
|
||||
platform: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount?: number;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
/** Hot songs shown before the "显示全部" expander (the API returns up to 50). */
|
||||
const HOT_SONG_PREVIEW = 10;
|
||||
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
const { can, guestCan } = useSession();
|
||||
|
||||
// Same permission as "play all" on a playlist: members need player.control,
|
||||
// guests need the playCollection flag.
|
||||
const canPlayAll = computed(() => can('player.control') || guestCan('playCollection'));
|
||||
|
||||
const artist = ref<ArtistDetail | null>(null);
|
||||
const hotSongs = ref<Song[]>([]);
|
||||
const albums = ref<Album[]>([]);
|
||||
const loading = ref(true);
|
||||
const expanded = ref(false);
|
||||
|
||||
const platform = computed(() => (route.query.platform as string) || 'netease');
|
||||
const platformLabel = computed(() => (platform.value === 'qq' ? 'QQ 音乐' : '网易云音乐'));
|
||||
|
||||
const visibleCount = computed(() =>
|
||||
expanded.value ? hotSongs.value.length : Math.min(HOT_SONG_PREVIEW, hotSongs.value.length),
|
||||
);
|
||||
const visibleSongs = computed(() => hotSongs.value.slice(0, visibleCount.value));
|
||||
|
||||
function artistId(): string {
|
||||
return route.params.id as string;
|
||||
}
|
||||
|
||||
async function playAll() {
|
||||
// The server queues the singer's whole catalogue, not just the hot 50.
|
||||
await store.playArtist(artistId(), platform.value);
|
||||
}
|
||||
|
||||
async function shuffleAll() {
|
||||
// Shuffle reuses the queue's own random mode (the same switch the player
|
||||
// toolbar exposes), so the play button and the mode badge stay consistent.
|
||||
await store.setMode('random');
|
||||
await store.playArtist(artistId(), platform.value);
|
||||
}
|
||||
|
||||
async function loadArtist() {
|
||||
loading.value = true;
|
||||
// Reset every per-artist piece: RouterView reuses this component when only the
|
||||
// route params change, so artist → artist navigation must not show stale rows.
|
||||
artist.value = null;
|
||||
hotSongs.value = [];
|
||||
albums.value = [];
|
||||
expanded.value = false;
|
||||
try {
|
||||
const res = await axios.get(`/api/music/artist/${artistId()}`, {
|
||||
params: { platform: platform.value },
|
||||
});
|
||||
artist.value = res.data?.artist ?? null;
|
||||
hotSongs.value = res.data?.songs ?? [];
|
||||
albums.value = res.data?.albums ?? [];
|
||||
} catch {
|
||||
artist.value = null;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(loadArtist);
|
||||
watch(() => `${route.params.id}|${route.query.platform ?? ''}`, loadArtist);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 16px;
|
||||
transition: opacity var(--transition-fast);
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.artist-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.artist-portrait-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artist-portrait {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.artist-portrait-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 72px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.artist-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.artist-platform {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.artist-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.artist-aliases {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artist-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.artist-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
max-width: 640px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-all-btn,
|
||||
.shuffle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
&:not(:disabled):hover { transform: scale(1.04); }
|
||||
&:not(:disabled):active { transform: scale(0.96); }
|
||||
}
|
||||
|
||||
.shuffle-btn {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
&:not(:disabled):hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-8);
|
||||
}
|
||||
}
|
||||
|
||||
.artist-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
margin-top: 10px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.album-row {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
flex: 0 0 auto;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.album-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.artist-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.artist-portrait {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
.artist-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+302
-3
@@ -12,9 +12,35 @@
|
||||
v-model="query"
|
||||
class="search-input"
|
||||
placeholder="搜索歌曲、歌手、专辑..."
|
||||
@focus="historyOpen = true"
|
||||
@blur="historyOpen = false"
|
||||
@keyup.enter="doSearch"
|
||||
autofocus
|
||||
/>
|
||||
<button v-if="query" class="search-clear" @click="query = ''">
|
||||
<Icon icon="mdi:close-circle" />
|
||||
</button>
|
||||
<!-- Recent searches for this browser. mousedown.prevent keeps the input
|
||||
focused so blur doesn't close the list before the click lands. -->
|
||||
<div v-if="historyOpen && historyEntries.length" class="history-dropdown">
|
||||
<div class="history-dropdown-head">
|
||||
<span>搜索历史</span>
|
||||
<button class="history-clear-btn" @mousedown.prevent @click="clearAllHistory">清空</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="h in historyEntries"
|
||||
:key="`${h.platform}:${h.q}`"
|
||||
class="history-dropdown-item"
|
||||
@mousedown.prevent="applyHistory(h)"
|
||||
>
|
||||
<Icon icon="mdi:history" class="history-item-icon" />
|
||||
<span class="history-item-query">{{ h.q }}</span>
|
||||
<span class="platform-badge" :class="badgeClass(h.platform)">{{ badgeLabel(h.platform) }}</span>
|
||||
<button class="history-item-remove" @mousedown.prevent.stop="removeHistoryEntry(h)">
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -55,7 +81,7 @@
|
||||
|
||||
<div v-if="loading" class="loading">搜索中...</div>
|
||||
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length || allArtists.length">
|
||||
<!-- Only enabled sources are offered (enabledProviders gate); Jellyfin
|
||||
(opt-in) comes first when enabled. -->
|
||||
<div class="source-bar">
|
||||
@@ -97,6 +123,31 @@
|
||||
>本地</button>
|
||||
</div>
|
||||
|
||||
<!-- Artist row: inline above the tabs, Apple Music style. Only netease/qq
|
||||
model artists, so the other sources have nothing to show here. -->
|
||||
<section v-if="filteredArtists.length" class="artist-row">
|
||||
<router-link
|
||||
v-for="ar in filteredArtists"
|
||||
:key="`${ar.platform}-${ar.id}`"
|
||||
:to="`/artist/${ar.id}?platform=${ar.platform}`"
|
||||
class="artist-card"
|
||||
>
|
||||
<img
|
||||
v-if="ar.avatarUrl"
|
||||
class="artist-avatar"
|
||||
:src="ar.avatarUrl"
|
||||
:alt="ar.name"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="artist-avatar artist-avatar-fallback">
|
||||
<Icon icon="mdi:account-music" />
|
||||
</div>
|
||||
<div class="artist-card-name">{{ ar.name }}</div>
|
||||
<div v-if="ar.songCount" class="artist-card-sub">{{ ar.songCount }} 首歌</div>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
class="tab"
|
||||
@@ -186,7 +237,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||
<div v-else-if="!searched" class="search-intro">
|
||||
<template v-if="historyEntries.length">
|
||||
<div class="history-tags-title">最近搜索</div>
|
||||
<div class="history-tags">
|
||||
<button
|
||||
v-for="h in historyEntries"
|
||||
:key="`tag-${h.platform}:${h.q}`"
|
||||
class="history-tag"
|
||||
@click="applyHistory(h)"
|
||||
>
|
||||
<Icon icon="mdi:history" />
|
||||
{{ h.q }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="search-intro-text">搜索歌曲、歌手或专辑,歌手支持专属页面</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">未找到相关结果</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -200,6 +269,14 @@ import type { Song } from '../stores/player.js';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import { mergeDedup, hasMore, nextOffset } from './searchPagination.js';
|
||||
import {
|
||||
clearStoredHistory,
|
||||
loadHistory,
|
||||
pushHistory,
|
||||
removeHistory,
|
||||
saveHistory,
|
||||
type SearchHistoryEntry,
|
||||
} from './searchHistory.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -229,11 +306,17 @@ const selectedSource = ref<SearchSource>(loadSource());
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
interface Artist { id: string; name: string; avatarUrl: string; platform: string; songCount?: number; albumCount?: number; }
|
||||
|
||||
const allSongs = ref<Song[]>([]);
|
||||
const allAlbums = ref<Album[]>([]);
|
||||
const allPlaylists = ref<Playlist[]>([]);
|
||||
// "加载更多" 分页状态:hasMore 按 (类型, 音源) 记录,loadingMore 按类型记录。
|
||||
const allArtists = ref<Artist[]>([]);
|
||||
// Search history lives in localStorage (see searchHistory.ts) — loaded on mount
|
||||
// so SSR/blocked-storage environments simply start empty.
|
||||
const historyEntries = ref<SearchHistoryEntry[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
const hasMoreMap = ref<Record<string, boolean>>({});
|
||||
const loadingMore = ref<Record<TabType, boolean>>({ songs: false, albums: false, playlists: false });
|
||||
const loading = ref(false);
|
||||
@@ -257,6 +340,10 @@ const filteredPlaylists = computed(() =>
|
||||
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const filteredArtists = computed(() =>
|
||||
allArtists.value.filter((a) => a.platform === selectedSource.value)
|
||||
);
|
||||
|
||||
const hasLocalSongs = computed(() => localAudioEnabled.value && allSongs.value.some((s) => s.platform === 'local'));
|
||||
|
||||
// Server-side source gate (enabledProviders). Until /providers loads, the
|
||||
@@ -383,6 +470,7 @@ async function toggleFavPlaylist(pl: { id: string; platform: string; name: strin
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
recordHistory();
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
activeTab.value = 'songs';
|
||||
@@ -393,16 +481,43 @@ async function doSearch() {
|
||||
allSongs.value = res.data.songs ?? [];
|
||||
allAlbums.value = res.data.albums ?? [];
|
||||
allPlaylists.value = res.data.playlists ?? [];
|
||||
allArtists.value = res.data.artists ?? [];
|
||||
recordInitialHasMore(allSongs.value, 'songs');
|
||||
recordInitialHasMore(allAlbums.value, 'albums');
|
||||
recordInitialHasMore(allPlaylists.value, 'playlists');
|
||||
} catch {
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||
allSongs.value = []; allAlbums.value = []; allPlaylists.value = []; allArtists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 搜索历史 ----
|
||||
function recordHistory() {
|
||||
historyEntries.value = pushHistory(historyEntries.value, query.value, selectedSource.value);
|
||||
saveHistory(historyEntries.value);
|
||||
}
|
||||
|
||||
/** Re-run a stored search, restoring the source it was made from. */
|
||||
function applyHistory(entry: SearchHistoryEntry) {
|
||||
historyOpen.value = false;
|
||||
query.value = entry.q;
|
||||
if (SEARCH_SOURCES.includes(entry.platform as SearchSource) && sourceEnabled(entry.platform)) {
|
||||
selectedSource.value = entry.platform as SearchSource;
|
||||
}
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function removeHistoryEntry(entry: SearchHistoryEntry) {
|
||||
historyEntries.value = removeHistory(historyEntries.value, entry.q, entry.platform);
|
||||
saveHistory(historyEntries.value);
|
||||
}
|
||||
|
||||
function clearAllHistory() {
|
||||
historyEntries.value = [];
|
||||
clearStoredHistory();
|
||||
}
|
||||
|
||||
|
||||
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
|
||||
const UPLOAD_MAX_MB = 500;
|
||||
@@ -546,6 +661,7 @@ async function loadLocalAudioSetting() {
|
||||
|
||||
onMounted(async () => {
|
||||
loadLocalAudioSetting();
|
||||
historyEntries.value = loadHistory();
|
||||
if (query.value) doSearch();
|
||||
await store.fetchProviders();
|
||||
fixupSelectedSource();
|
||||
@@ -649,6 +765,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
@@ -657,6 +774,188 @@ onMounted(async () => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover { color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.history-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% - 10px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.history-dropdown-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.history-clear-btn {
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { color: var(--color-primary); }
|
||||
}
|
||||
|
||||
.history-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:hover { background: var(--bg-card); }
|
||||
}
|
||||
|
||||
.history-item-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.history-item-query {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
|
||||
.history-dropdown-item:hover & { opacity: 1; }
|
||||
|
||||
&:hover { color: #e74c3c; }
|
||||
}
|
||||
|
||||
.search-intro {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.history-tags-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.search-intro-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.artist-row {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
flex: 0 0 auto;
|
||||
width: 104px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.artist-avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.artist-card:hover .artist-avatar {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.artist-card-name {
|
||||
margin-top: 8px;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: var(--fw-semi);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist-card-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 22px;
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
SEARCH_HISTORY_MAX,
|
||||
SEARCH_HISTORY_STORAGE_KEY,
|
||||
clearStoredHistory,
|
||||
historyForPlatform,
|
||||
loadHistory,
|
||||
parseHistory,
|
||||
pushHistory,
|
||||
removeHistory,
|
||||
sameEntry,
|
||||
saveHistory,
|
||||
serializeHistory,
|
||||
type SearchHistoryEntry,
|
||||
} from "./searchHistory.js";
|
||||
|
||||
function memoryStorage(seed: Record<string, string> = {}) {
|
||||
const map = new Map(Object.entries(seed));
|
||||
return {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
dump: () => Object.fromEntries(map),
|
||||
};
|
||||
}
|
||||
|
||||
const entry = (q: string, platform = "netease", at = 1): SearchHistoryEntry => ({ q, platform, at });
|
||||
|
||||
describe("search history", () => {
|
||||
let storage: ReturnType<typeof memoryStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = memoryStorage();
|
||||
});
|
||||
|
||||
describe("pushHistory", () => {
|
||||
it("prepends the newest query", () => {
|
||||
const list = pushHistory([entry("old")], "new", "netease", 2);
|
||||
expect(list.map((e) => e.q)).toEqual(["new", "old"]);
|
||||
});
|
||||
|
||||
it("trims the query and ignores blank input", () => {
|
||||
expect(pushHistory([], " hello ", "netease", 2)[0].q).toBe("hello");
|
||||
expect(pushHistory([entry("keep")], " ", "netease", 2)).toEqual([entry("keep")]);
|
||||
expect(pushHistory([], "", "netease", 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves a repeated query to the front instead of duplicating it", () => {
|
||||
const list = pushHistory([entry("b", "netease", 2), entry("a", "netease", 1)], "a", "netease", 3);
|
||||
expect(list.map((e) => e.q)).toEqual(["a", "b"]);
|
||||
expect(list[0].at).toBe(3);
|
||||
});
|
||||
|
||||
it("matches repeated queries case-insensitively", () => {
|
||||
const list = pushHistory([entry("Hello", "netease", 1)], "hello", "netease", 2);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].q).toBe("hello");
|
||||
});
|
||||
|
||||
it("keeps the same query from a different source as its own entry", () => {
|
||||
const list = pushHistory([entry("hello", "netease", 1)], "hello", "qq", 2);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.map((e) => e.platform)).toEqual(["qq", "netease"]);
|
||||
});
|
||||
|
||||
it("caps the list at SEARCH_HISTORY_MAX, dropping the oldest", () => {
|
||||
let list: SearchHistoryEntry[] = [];
|
||||
for (let i = 1; i <= SEARCH_HISTORY_MAX + 3; i++) {
|
||||
list = pushHistory(list, `q${i}`, "netease", i);
|
||||
}
|
||||
expect(list).toHaveLength(SEARCH_HISTORY_MAX);
|
||||
expect(list[0].q).toBe(`q${SEARCH_HISTORY_MAX + 3}`);
|
||||
expect(list.at(-1)?.q).toBe("q4");
|
||||
});
|
||||
|
||||
it("does not mutate the input list", () => {
|
||||
const list = [entry("a")];
|
||||
pushHistory(list, "b", "netease", 2);
|
||||
expect(list.map((e) => e.q)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeHistory", () => {
|
||||
it("removes only the matching query+source pair", () => {
|
||||
const list = [entry("a", "netease"), entry("a", "qq"), entry("b", "netease")];
|
||||
expect(removeHistory(list, "a", "netease").map((e) => `${e.platform}:${e.q}`)).toEqual([
|
||||
"qq:a",
|
||||
"netease:b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the query", () => {
|
||||
expect(removeHistory([entry("Hello")], "hello", "netease")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("historyForPlatform", () => {
|
||||
it("returns only the selected source, newest first", () => {
|
||||
const list = [entry("a", "qq", 3), entry("b", "netease", 2), entry("c", "netease", 1)];
|
||||
expect(historyForPlatform(list, "netease").map((e) => e.q)).toEqual(["b", "c"]);
|
||||
expect(historyForPlatform(list, "bilibili")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse/serialize", () => {
|
||||
it("round-trips a list", () => {
|
||||
const list = [entry("a"), entry("b", "qq", 5)];
|
||||
expect(parseHistory(serializeHistory(list))).toEqual(list);
|
||||
});
|
||||
|
||||
it("returns [] for malformed or non-array payloads", () => {
|
||||
expect(parseHistory(null)).toEqual([]);
|
||||
expect(parseHistory("")).toEqual([]);
|
||||
expect(parseHistory("{not json")).toEqual([]);
|
||||
expect(parseHistory('{"q":"a"}')).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops entries that are missing required fields", () => {
|
||||
const raw = JSON.stringify([
|
||||
{ q: "ok", platform: "netease", at: 1 },
|
||||
{ q: "", platform: "netease", at: 2 },
|
||||
{ q: "no-platform", platform: "", at: 3 },
|
||||
{ q: "no-time", platform: "qq" },
|
||||
{ q: "not-an-object", platform: 5, at: 4 },
|
||||
]);
|
||||
expect(parseHistory(raw).map((e) => e.q)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("truncates over-long stored payloads to the cap", () => {
|
||||
const raw = JSON.stringify(
|
||||
Array.from({ length: SEARCH_HISTORY_MAX + 5 }, (_, i) => entry(`q${i}`, "netease", i)),
|
||||
);
|
||||
expect(parseHistory(raw)).toHaveLength(SEARCH_HISTORY_MAX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage helpers", () => {
|
||||
it("round-trips through the injected storage", () => {
|
||||
saveHistory([entry("persisted", "qq", 9)], storage);
|
||||
expect(loadHistory(storage)).toEqual([entry("persisted", "qq", 9)]);
|
||||
expect(Object.keys(storage.dump())).toEqual([SEARCH_HISTORY_STORAGE_KEY]);
|
||||
});
|
||||
|
||||
it("loads [] when the key is absent", () => {
|
||||
expect(loadHistory(storage)).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads [] instead of throwing on corrupt storage", () => {
|
||||
const bad = memoryStorage({ [SEARCH_HISTORY_STORAGE_KEY]: "}{" });
|
||||
expect(loadHistory(bad)).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearStoredHistory removes the key", () => {
|
||||
saveHistory([entry("a")], storage);
|
||||
clearStoredHistory(storage);
|
||||
expect(storage.dump()).toEqual({});
|
||||
expect(loadHistory(storage)).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives a storage that throws on every access", () => {
|
||||
const hostile = {
|
||||
getItem: () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
};
|
||||
expect(loadHistory(hostile)).toEqual([]);
|
||||
expect(() => saveHistory([entry("a")], hostile)).not.toThrow();
|
||||
expect(() => clearStoredHistory(hostile)).not.toThrow();
|
||||
});
|
||||
|
||||
it("tolerates a null storage (SSR / privacy mode)", () => {
|
||||
expect(loadHistory(null)).toEqual([]);
|
||||
expect(() => saveHistory([entry("a")], null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameEntry", () => {
|
||||
it("compares platform and case-insensitive query", () => {
|
||||
expect(sameEntry(entry("Hello", "qq"), " hello ", "qq")).toBe(true);
|
||||
expect(sameEntry(entry("Hello", "qq"), "hello", "netease")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Search history for the search page.
|
||||
*
|
||||
* Deliberately client-side (localStorage, per browser): the bot is shared by
|
||||
* many users, so a server-side history would leak one user's queries into
|
||||
* another's suggestions. Everything here is a pure function except the three
|
||||
* storage helpers, which accept an injected Storage-like so the list logic is
|
||||
* unit-testable without a DOM.
|
||||
*/
|
||||
|
||||
export const SEARCH_HISTORY_MAX = 10;
|
||||
export const SEARCH_HISTORY_STORAGE_KEY = 'search-history';
|
||||
|
||||
export interface SearchHistoryEntry {
|
||||
q: string;
|
||||
platform: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
function defaultStorage(): StorageLike | null {
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
// Accessing localStorage throws in some privacy modes.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isEntry(value: unknown): value is SearchHistoryEntry {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const e = value as Partial<SearchHistoryEntry>;
|
||||
return (
|
||||
typeof e.q === 'string' &&
|
||||
e.q.trim().length > 0 &&
|
||||
typeof e.platform === 'string' &&
|
||||
e.platform.length > 0 &&
|
||||
typeof e.at === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function parseHistory(raw: string | null | undefined): SearchHistoryEntry[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isEntry).slice(0, SEARCH_HISTORY_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeHistory(list: SearchHistoryEntry[]): string {
|
||||
return JSON.stringify(list.slice(0, SEARCH_HISTORY_MAX));
|
||||
}
|
||||
|
||||
export function sameEntry(
|
||||
entry: SearchHistoryEntry,
|
||||
q: string,
|
||||
platform: string,
|
||||
): boolean {
|
||||
return (
|
||||
entry.platform === platform &&
|
||||
entry.q.toLowerCase() === q.trim().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest first. Repeating a query for the same source moves the existing entry
|
||||
* to the front instead of adding a duplicate (case-insensitive on the query).
|
||||
*/
|
||||
export function pushHistory(
|
||||
list: SearchHistoryEntry[],
|
||||
q: string,
|
||||
platform: string,
|
||||
at = Date.now(),
|
||||
): SearchHistoryEntry[] {
|
||||
const query = q.trim();
|
||||
if (!query || !platform) return list;
|
||||
const rest = list.filter((e) => !sameEntry(e, query, platform));
|
||||
return [{ q: query, platform, at }, ...rest].slice(0, SEARCH_HISTORY_MAX);
|
||||
}
|
||||
|
||||
export function removeHistory(
|
||||
list: SearchHistoryEntry[],
|
||||
q: string,
|
||||
platform: string,
|
||||
): SearchHistoryEntry[] {
|
||||
return list.filter((e) => !sameEntry(e, q, platform));
|
||||
}
|
||||
|
||||
/** Entries for the currently selected source, newest first. */
|
||||
export function historyForPlatform(
|
||||
list: SearchHistoryEntry[],
|
||||
platform: string,
|
||||
): SearchHistoryEntry[] {
|
||||
return list.filter((e) => e.platform === platform);
|
||||
}
|
||||
|
||||
export function loadHistory(storage: StorageLike | null = defaultStorage()): SearchHistoryEntry[] {
|
||||
try {
|
||||
return parseHistory(storage?.getItem(SEARCH_HISTORY_STORAGE_KEY) ?? null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHistory(
|
||||
list: SearchHistoryEntry[],
|
||||
storage: StorageLike | null = defaultStorage(),
|
||||
): void {
|
||||
try {
|
||||
storage?.setItem(SEARCH_HISTORY_STORAGE_KEY, serializeHistory(list));
|
||||
} catch {
|
||||
// Storage full or unavailable — history is a convenience, not state.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredHistory(storage: StorageLike | null = defaultStorage()): void {
|
||||
try {
|
||||
storage?.removeItem(SEARCH_HISTORY_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user