mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with localStorage persistence to remember user preference - Remove "全部" option, single-source view only - Increase album/playlist card column-gap to 28px for better spacing - Sync search query to URL via router.replace so back-navigation from album/playlist detail pages restores search results - Fix !album command: add name-based album search (matching !playlist behavior) so "!album APT." searches by name instead of treating it as ID Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
997bb17ceb
commit
6d5755ced7
3 files changed
+123
-24
No files matched your search
@@ -19,8 +19,6 @@
|
|||||||
"Read(//tmp/**)"
|
"Read(//tmp/**)"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push * main)",
|
|
||||||
"Bash(git push * master)",
|
|
||||||
"Bash(git push --force *)",
|
"Bash(git push --force *)",
|
||||||
"Bash(rm -rf /)"
|
"Bash(rm -rf /)"
|
||||||
]
|
]
|
||||||
|
|||||||
+20
-2
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !album <album ID>";
|
if (!cmd.args) return "Usage: !album <album name or ID>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
const songs = await provider.getAlbumSongs(cmd.args);
|
|
||||||
|
const id = this.extractId(cmd.args);
|
||||||
|
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
|
||||||
|
if (isNumericId || id !== cmd.args) {
|
||||||
|
// Input is a numeric ID or URL containing an ID — use directly
|
||||||
|
albumId = id;
|
||||||
|
} else {
|
||||||
|
// Name-based search
|
||||||
|
const result = await provider.search(cmd.args);
|
||||||
|
const albums = result.albums ?? [];
|
||||||
|
if (albums.length === 0)
|
||||||
|
return `No albums found for: ${cmd.args}`;
|
||||||
|
albumId = albums[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await provider.getAlbumSongs(albumId);
|
||||||
if (songs.length === 0) return "Album is empty or not found";
|
if (songs.length === 0) return "Album is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
|||||||
+103
-20
@@ -20,35 +20,55 @@
|
|||||||
|
|
||||||
<div v-if="loading" class="loading">搜索中...</div>
|
<div v-if="loading" class="loading">搜索中...</div>
|
||||||
|
|
||||||
<template v-else-if="songs.length || albums.length || playlists.length">
|
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||||
|
<div class="source-bar">
|
||||||
|
<button
|
||||||
|
class="source-btn"
|
||||||
|
:class="{ active: selectedSource === 'netease' }"
|
||||||
|
@click="selectedSource = 'netease'"
|
||||||
|
>网易云</button>
|
||||||
|
<button
|
||||||
|
class="source-btn"
|
||||||
|
:class="{ active: selectedSource === 'qq' }"
|
||||||
|
@click="selectedSource = 'qq'"
|
||||||
|
>QQ</button>
|
||||||
|
<button
|
||||||
|
class="source-btn"
|
||||||
|
:class="{ active: selectedSource === 'bilibili' }"
|
||||||
|
@click="selectedSource = 'bilibili'"
|
||||||
|
>B站</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ active: activeTab === 'songs' }"
|
:class="{ active: activeTab === 'songs' }"
|
||||||
@click="activeTab = 'songs'"
|
@click="activeTab = 'songs'"
|
||||||
>
|
>
|
||||||
单曲<span class="tab-count">{{ songs.length }}</span>
|
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="selectedSource !== 'bilibili'"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ active: activeTab === 'albums' }"
|
:class="{ active: activeTab === 'albums' }"
|
||||||
@click="activeTab = 'albums'"
|
@click="activeTab = 'albums'"
|
||||||
>
|
>
|
||||||
专辑<span class="tab-count">{{ albums.length }}</span>
|
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="selectedSource !== 'bilibili'"
|
||||||
class="tab"
|
class="tab"
|
||||||
:class="{ active: activeTab === 'playlists' }"
|
:class="{ active: activeTab === 'playlists' }"
|
||||||
@click="activeTab = 'playlists'"
|
@click="activeTab = 'playlists'"
|
||||||
>
|
>
|
||||||
歌单<span class="tab-count">{{ playlists.length }}</span>
|
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="activeTab === 'albums' && albums.length" class="result-section">
|
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="al in albums"
|
v-for="al in filteredAlbums"
|
||||||
:key="`${al.platform}-${al.id}`"
|
:key="`${al.platform}-${al.id}`"
|
||||||
:to="`/album/${al.id}?platform=${al.platform}`"
|
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||||
class="card hover-scale"
|
class="card hover-scale"
|
||||||
@@ -63,10 +83,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'playlists' && playlists.length" class="result-section">
|
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="pl in playlists"
|
v-for="pl in filteredPlaylists"
|
||||||
:key="`${pl.platform}-${pl.id}`"
|
:key="`${pl.platform}-${pl.id}`"
|
||||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||||
class="card hover-scale"
|
class="card hover-scale"
|
||||||
@@ -80,9 +100,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab === 'songs' && songs.length" class="result-section">
|
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
|
||||||
<SongCard
|
<SongCard
|
||||||
v-for="(song, i) in songs"
|
v-for="(song, i) in filteredSongs"
|
||||||
:key="`${song.platform}-${song.id}`"
|
:key="`${song.platform}-${song.id}`"
|
||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
@@ -99,8 +119,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
@@ -110,31 +130,68 @@ import CoverArt from '../components/CoverArt.vue';
|
|||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const SOURCE_STORAGE_KEY = 'search-source';
|
||||||
|
|
||||||
|
function loadSource(): 'netease' | 'qq' | 'bilibili' {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
|
||||||
|
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
|
||||||
|
} catch { /* localStorage blocked */ }
|
||||||
|
return 'netease';
|
||||||
|
}
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
const query = ref((route.query.q as string) || '');
|
||||||
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||||
|
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
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 Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
|
|
||||||
const songs = ref<Song[]>([]);
|
const allSongs = ref<Song[]>([]);
|
||||||
const albums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const playlists = ref<Playlist[]>([]);
|
const allPlaylists = ref<Playlist[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
|
|
||||||
|
const filteredSongs = computed(() =>
|
||||||
|
allSongs.value.filter((s) => s.platform === selectedSource.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredAlbums = computed(() =>
|
||||||
|
allAlbums.value.filter((a) => a.platform === selectedSource.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredPlaylists = computed(() =>
|
||||||
|
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist source preference
|
||||||
|
watch(selectedSource, (src) => {
|
||||||
|
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// B站 has no albums/playlists — force songs tab when switching to B站
|
||||||
|
watch(selectedSource, (src) => {
|
||||||
|
if (src === 'bilibili' && activeTab.value !== 'songs') {
|
||||||
|
activeTab.value = 'songs';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
if (!query.value.trim()) return;
|
if (!query.value.trim()) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
activeTab.value = 'songs';
|
activeTab.value = 'songs';
|
||||||
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
songs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
albums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
playlists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
songs.value = []; albums.value = []; playlists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -223,6 +280,32 @@ onMounted(() => {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-btn {
|
||||||
|
padding: 5px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: rgba(51, 94, 234, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -270,7 +353,7 @@ onMounted(() => {
|
|||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px 28px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in new issue
Block a user